diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml new file mode 100644 index 0000000..46b5e5a --- /dev/null +++ b/.github/workflows/build-binaries.yml @@ -0,0 +1,216 @@ +# Builds the FFmpeg binaries for every slice the package ships (issue #7). +# +# Each slice builds on its own runner in parallel, then one macOS job merges +# them with scripts/ffmpeg/package.sh β€” the same script a local release uses β€” +# so a CI build and a local build produce the same bundle layout, checksum +# manifest, and build-info.txt. +# +# Run it by hand from the Actions tab (optionally attaching the result to a +# GitHub release), or let it run on pull requests that touch the build scripts. +name: Build binaries + +on: + workflow_dispatch: + inputs: + ffmpeg_version: + description: FFmpeg release to build + default: '9.0.1' + release_tag: + description: >- + Attach the bundle to this GitHub release (for example v0.6.0). Leave + empty to only keep it as a workflow artifact. + default: '' + pull_request: + paths: + - scripts/ffmpeg/** + - .github/workflows/build-binaries.yml + +concurrency: + group: build-binaries-${{ github.ref }} + cancel-in-progress: true + +env: + FFMPEG_VERSION: ${{ inputs.ffmpeg_version || '9.0.1' }} + FFMPEG_WORKSPACE: ${{ github.workspace }}/.build + NDK_VERSION: 27.1.12297006 + +jobs: + android: + name: Android ${{ matrix.abi }} + runs-on: ubuntu-latest + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + abi: [arm64-v8a, armeabi-v7a, x86_64] + steps: + - uses: actions/checkout@v4 + + # fontconfig 2.16 needs Meson 1.6+, newer than the apt package, so Meson + # comes from pip. + - name: Install build tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + ninja-build nasm yasm pkg-config gperf \ + autoconf automake libtool cmake xz-utils + python3 -m pip install --user 'meson>=1.6' + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + # Pinned to the same NDK the local build uses, so both produce the same + # binaries; the runner's preinstalled "latest" NDK moves over time. + - name: Install Android NDK ${{ env.NDK_VERSION }} + run: | + yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$NDK_VERSION" > /dev/null + echo "ANDROID_NDK=$ANDROID_HOME/ndk/$NDK_VERSION" >> "$GITHUB_ENV" + + - name: Cache dependency sources + uses: actions/cache@v4 + with: + path: ${{ env.FFMPEG_WORKSPACE }}/deps/*.tar.* + key: deps-${{ hashFiles('scripts/ffmpeg/build-android.sh', 'scripts/ffmpeg/build-ios.sh') }} + + - name: Build FFmpeg for ${{ matrix.abi }} + run: | + scripts/ffmpeg/fetch-source.sh + scripts/ffmpeg/build-android.sh "${{ matrix.abi }}" + + - name: Show what was built + run: | + lib="$FFMPEG_WORKSPACE/out/android-${{ matrix.abi }}/lib/libmunimffmpeg.so" + ls -la "$lib" + "$ANDROID_NDK"/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf -d "$lib" | grep NEEDED + + - name: Upload build logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: logs-android-${{ matrix.abi }} + path: ${{ env.FFMPEG_WORKSPACE }}/build/*.log + if-no-files-found: ignore + + - name: Upload slice + uses: actions/upload-artifact@v4 + with: + name: slice-android-${{ matrix.abi }} + if-no-files-found: error + path: | + ${{ env.FFMPEG_WORKSPACE }}/out/android-${{ matrix.abi }}/lib/libmunimffmpeg.so + ${{ env.FFMPEG_WORKSPACE }}/build/ffmpeg-${{ matrix.abi }}-configure.log + ${{ env.FFMPEG_WORKSPACE }}/build/android-${{ matrix.abi }}/ffbuild/config.log + + ios: + name: iOS ${{ matrix.sdk }} ${{ matrix.arch }} + runs-on: macos-15 + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - { sdk: iphoneos, arch: arm64 } + - { sdk: iphonesimulator, arch: arm64 } + - { sdk: iphonesimulator, arch: x86_64 } + steps: + - uses: actions/checkout@v4 + + - name: Install build tools + run: brew install meson ninja nasm yasm pkg-config gperf autoconf automake libtool + + - name: Cache dependency sources + uses: actions/cache@v4 + with: + path: ${{ env.FFMPEG_WORKSPACE }}/deps/*.tar.* + key: deps-${{ hashFiles('scripts/ffmpeg/build-android.sh', 'scripts/ffmpeg/build-ios.sh') }} + + - name: Build FFmpeg for ${{ matrix.sdk }} ${{ matrix.arch }} + run: | + scripts/ffmpeg/fetch-source.sh + scripts/ffmpeg/build-ios.sh "${{ matrix.sdk }}" "${{ matrix.arch }}" + + - name: Show what was built + run: | + lib="$FFMPEG_WORKSPACE/out/ios-${{ matrix.sdk }}-${{ matrix.arch }}/lib/libmunimffmpeg.a" + ls -la "$lib" + lipo -info "$lib" + + - name: Upload build logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: logs-ios-${{ matrix.sdk }}-${{ matrix.arch }} + path: ${{ env.FFMPEG_WORKSPACE }}/build/*.log + if-no-files-found: ignore + + - name: Upload slice + uses: actions/upload-artifact@v4 + with: + name: slice-ios-${{ matrix.sdk }}-${{ matrix.arch }} + if-no-files-found: error + path: | + ${{ env.FFMPEG_WORKSPACE }}/out/ios-${{ matrix.sdk }}-${{ matrix.arch }}/lib/libmunimffmpeg.a + ${{ env.FFMPEG_WORKSPACE }}/build/ffmpeg-${{ matrix.sdk }}-${{ matrix.arch }}-configure.log + ${{ env.FFMPEG_WORKSPACE }}/build/ios-${{ matrix.sdk }}-${{ matrix.arch }}/ffbuild/config.log + + package: + name: Package bundle + needs: [android, ios] + runs-on: macos-15 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + + # Every slice artifact was uploaded relative to the workspace root, so + # merging them recreates the out/ and build/ layout package.sh expects. + - name: Collect slices + uses: actions/download-artifact@v4 + with: + pattern: slice-* + path: ${{ env.FFMPEG_WORKSPACE }} + merge-multiple: true + + - name: Assemble the bundle + run: scripts/ffmpeg/package.sh + + - name: Summarise + run: | + { + echo '### munim-ffmpeg binaries' + echo + echo '```' + cat scripts/binaries.json + echo '```' + echo + echo '
build-info.txt' + echo + echo '```' + cat dist-binaries/build-info.txt + echo '```' + echo '
' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload bundle + uses: actions/upload-artifact@v4 + with: + name: munim-ffmpeg-binaries + if-no-files-found: error + path: | + dist-binaries/munim-ffmpeg-binaries.tar.gz + dist-binaries/munim-ffmpeg-binaries.tar.gz.sha256 + dist-binaries/build-info.txt + scripts/binaries.json + + # A release built here must be the one scripts/binaries.json points at, + # so the checksum is printed for the maintainer to commit alongside it. + - name: Attach to release ${{ inputs.release_tag }} + if: inputs.release_tag != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${{ inputs.release_tag }}" \ + dist-binaries/munim-ffmpeg-binaries.tar.gz \ + dist-binaries/build-info.txt \ + --clobber + echo "Attached to ${{ inputs.release_tag }}; commit scripts/binaries.json with sha256 $(cat dist-binaries/munim-ffmpeg-binaries.tar.gz.sha256)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f05290..b459e20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ -# The native binaries build on a Mac with the NDK and Xcode and take ~40 -# minutes, so CI only guards the JavaScript surface: types, lint, the compiled -# lib, and that the published package stays well-formed. +# The native binaries take ~40 minutes to build, so this workflow only guards +# the JavaScript surface: types, lint, the compiled lib, and that the published +# package stays well-formed. build-binaries.yml builds the native libraries. name: CI on: diff --git a/README.md b/README.md index d827283..96370f4 100644 --- a/README.md +++ b/README.md @@ -115,27 +115,30 @@ - πŸ§ͺ **Capability discovery:** Ask the bundled build which encoders, decoders, muxers, demuxers, filters, and protocols it actually has - πŸ’¬ **Subtitle burn-in:** libass renders ASS/SSA and SRT subtitles β€” styling, positioning, outlines, shadows, and proper Arabic/Urdu shaping via HarfBuzz and FriBidi - πŸ“Ž **Soft subtitle embedding:** Mux SRT/ASS tracks into MKV or MP4 so players can toggle them without re-encoding the video +- πŸ–ΌοΈ **AVIF and AV1:** libaom encodes AV1 video and AVIF stills, dav1d decodes them +- πŸ“¦ **One native library per platform:** a single `libmunimffmpeg.so` per Android ABI and one static library in the iOS xcframework, so nothing else has to be linked, loaded, or packaged - 🎯 **TypeScript:** Complete public callback and result types - πŸ—‚οΈ **16 KB Android pages:** Built with the alignment Google Play requires ## Platform support matrix -| Capability | iOS | Android | Notes | -| ---------------------------- | --------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| FFmpeg argument execution | βœ… | βœ… | Commands run asynchronously through the native compatibility library. | -| FFprobe argument execution | βœ… | βœ… | Custom FFprobe arguments return `FFmpegSessionResult`. | -| Parsed media information | βœ… | βœ… | `getMediaInformation()` returns parsed FFprobe JSON. | -| Log callback | βœ… | βœ… | Logs are delivered while a session is active. | -| Encoding-statistics callback | βœ… | βœ… | Available for FFmpeg execution. | -| Immediate session ID | βœ… | βœ… | `onSessionCreated` fires after the native session is created. | -| Cancel one FFmpeg session | βœ… | βœ… | Pass the positive safe-integer ID received by `execute`'s `onSessionCreated`. The native dependency does not expose FFprobe cancellation. | -| Cancel all FFmpeg sessions | βœ… | βœ… | Use `cancelAll()` or call `cancel()` without an ID. | -| Expo Go | ❌ | ❌ | A native development build is required. | -| Capability discovery | βœ… | βœ… | `listEncoders()`, `listDecoders()`, `listMuxers()`, `listDemuxers()`, `listFilters()`, `listProtocols()`, and `pickEncoder()` report what the bundled build supports. | -| Subtitle burn-in | βœ… | βœ… | libass with system fonts: Core Text on iOS, fontconfig over `/system/fonts` on Android. | -| H.264 encoding | VideoToolbox | MediaCodec | Hardware on both, `libopenh264` as the software fallback; use `pickEncoder(['h264_videotoolbox', 'h264_mediacodec', 'libopenh264'])` instead of hard-coding an encoder. | -| Remote HTTP(S) inputs | βœ… | βœ… | iOS links SecureTransport, Android links mbedTLS. Remote server behaviour still varies; prefer local files for predictable app workflows. | -| Soft subtitle embedding | βœ… | βœ… | Mux SRT/ASS as toggleable tracks (MKV: `srt`/`ass`, MP4: `mov_text`). | +| Capability | iOS | Android | Notes | +| ---------------------------- | ------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| FFmpeg argument execution | βœ… | βœ… | Commands run asynchronously through the native compatibility library. | +| FFprobe argument execution | βœ… | βœ… | Custom FFprobe arguments return `FFmpegSessionResult`. | +| Parsed media information | βœ… | βœ… | `getMediaInformation()` returns parsed FFprobe JSON. | +| Log callback | βœ… | βœ… | Logs are delivered while a session is active. | +| Encoding-statistics callback | βœ… | βœ… | Available for FFmpeg execution. | +| Immediate session ID | βœ… | βœ… | `onSessionCreated` fires after the native session is created. | +| Cancel one FFmpeg session | βœ… | βœ… | Pass the positive safe-integer ID received by `execute`'s `onSessionCreated`. The native dependency does not expose FFprobe cancellation. | +| Cancel all FFmpeg sessions | βœ… | βœ… | Use `cancelAll()` or call `cancel()` without an ID. | +| Expo Go | ❌ | ❌ | A native development build is required. | +| Capability discovery | βœ… | βœ… | `listEncoders()`, `listDecoders()`, `listMuxers()`, `listDemuxers()`, `listFilters()`, `listProtocols()`, and `pickEncoder()` report what the bundled build supports. | +| Subtitle burn-in | βœ… | βœ… | libass with system fonts: Core Text on iOS, fontconfig over `/system/fonts` on Android. | +| H.264 encoding | VideoToolbox | MediaCodec | Hardware on both, `libopenh264` as the software fallback; use `pickEncoder(['h264_videotoolbox', 'h264_mediacodec', 'libopenh264'])` instead of hard-coding an encoder. | +| Remote HTTP(S) inputs | βœ… | βœ… | iOS links SecureTransport, Android links mbedTLS. Remote server behaviour still varies; prefer local files for predictable app workflows. | +| Soft subtitle embedding | βœ… | βœ… | Mux SRT/ASS as toggleable tracks (MKV: `srt`/`ass`, MP4: `mov_text`). | +| AVIF / AV1 encoding | βœ… | βœ… | `libaom-av1` encodes (add `-still-picture 1 -f avif` for images); `libdav1d` decodes. | Codec availability is determined by the native FFmpeg builds described in [Bundled FFmpeg builds](#bundled-ffmpeg-builds). Do not assume every FFmpeg codec or external library is present. @@ -143,14 +146,14 @@ Codec availability is determined by the native FFmpeg builds described in [Bundl Every release runs the example's 25-check device suite. For 0.4.x: -| Target | Result | -| --- | --- | -| iPad Air (M3), iOS 26 | 25/25 | -| iOS Simulator, arm64 | 25/25 | -| Galaxy A14 5G, arm64-v8a | 25/25 | -| Android emulator, arm64 | Software encoding passes; hardware encoding does not β€” see below | -| Android emulator, `x86_64` | Same: `libopenh264` passes, MediaCodec does not | -| Android `armeabi-v7a` | Built and statically checked, not executed | +| Target | Result | +| -------------------------- | ---------------------------------------------------------------- | +| iPad Air (M3), iOS 26 | 25/25 | +| iOS Simulator, arm64 | 25/25 | +| Galaxy A14 5G, arm64-v8a | 25/25 | +| Android emulator, arm64 | Software encoding passes; hardware encoding does not β€” see below | +| Android emulator, `x86_64` | Same: `libopenh264` passes, MediaCodec does not | +| Android `armeabi-v7a` | Built and statically checked, not executed | `x86_64` was verified on an Intel Windows machine, since the Android emulator refuses non-native system images on Apple Silicon: FFmpeg 9.0.1, 185 encoders, and the software H.264 encode passes. `armeabi-v7a` has no hardware to hand, so it was checked statically instead β€” correct ELF architecture, the expected JNI exports, only system libraries unresolved, and the same FFmpeg and codec set as arm64. @@ -160,11 +163,20 @@ An Android emulator has no working MediaCodec **encoder**. `h264_mediacodec` and This is an emulator limitation, not a package one, but it is worth knowing before debugging: **test video encoding on a physical device**. -`pickEncoder` cannot detect it, because MediaCodec *is* present in an emulator β€” it just does not work. When you need encoding to succeed regardless of environment, ask for the software encoder by name: +`pickEncoder` cannot detect it, because MediaCodec _is_ present in an emulator β€” it just does not work. When you need encoding to succeed regardless of environment, ask for the software encoder by name: ```typescript // Deterministic anywhere: emulators, CI, older devices. -await execute(['-y', '-i', input, '-c:v', 'libopenh264', '-pix_fmt', 'yuv420p', output]) +await execute([ + '-y', + '-i', + input, + '-c:v', + 'libopenh264', + '-pix_fmt', + 'yuv420p', + output, +]) ``` `libopenh264`, `mpeg4` and `libvpx-vp9` are all software encoders and work everywhere. @@ -173,30 +185,34 @@ await execute(['-y', '-i', input, '-c:v', 'libopenh264', '-pix_fmt', 'yuv420p', Both platforms run **FFmpeg 9.0.1**, built from [ffmpeg.org](https://www.ffmpeg.org/) by the scripts in [`scripts/ffmpeg/`](./scripts/ffmpeg). There is no FFmpegKit here: that project was retired in 2025 and pinned to FFmpeg 6.0. -| | iOS | Android | -| --- | --- | --- | -| FFmpeg | 9.0.1 | 9.0.1 | -| Architectures | arm64 device, arm64 + x86_64 simulator | arm64-v8a, armeabi-v7a, x86_64 | -| Hardware codecs | VideoToolbox, AudioToolbox | MediaCodec | -| TLS | SecureTransport | mbedTLS | -| Minimum | iOS 15.1 | API 24, 16 KB pages | +| | iOS | Android | +| --------------- | ------------------------------------------------------- | ------------------------------- | +| FFmpeg | 9.0.1 | 9.0.1 | +| Architectures | arm64 device, arm64 + x86_64 simulator | arm64-v8a, armeabi-v7a, x86_64 | +| Hardware codecs | VideoToolbox, AudioToolbox | MediaCodec | +| TLS | SecureTransport | mbedTLS | +| Minimum | iOS 15.1 | API 24, 16 KB pages | +| Ships as | `MunimFFmpeg.xcframework`, one static library per slice | one `libmunimffmpeg.so` per ABI | -Linked libraries, identical on both: **LAME** (MP3), **Opus**, **libvpx** (VP8/VP9), **dav1d** (AV1 decoding), **openh264** (software H.264), **libass** with **FreeType**, **HarfBuzz**, and **FriBidi** (subtitle rendering and text shaping), plus everything FFmpeg builds natively. Android additionally links **fontconfig** and **expat** so libass can discover the system fonts; iOS uses Core Text for the same job. +Linked libraries, identical on both: **LAME** (MP3), **Opus**, **libvpx** (VP8/VP9), **dav1d** (AV1 decoding), **libaom** (AV1 and AVIF encoding), **openh264** (software H.264), **libass** with **FreeType**, **HarfBuzz**, and **FriBidi** (subtitle rendering and text shaping), plus everything FFmpeg builds natively. Android additionally links **fontconfig** and **expat** so libass can discover the system fonts; iOS uses Core Text for the same job. FFmpeg's own `ffmpeg` and `ffprobe` tools are compiled to run inside your app process, so the argument arrays you pass are handled by the real command-line code paths rather than a reimplementation. +Everything above is linked statically into a single library per platform: FFmpeg, both tools, the core, and every external library. On Android that is one `libmunimffmpeg.so` per ABI whose only dependencies are the system libraries and the `libc++_shared.so` React Native already bundles; on iOS it is one static library per slice inside `MunimFFmpeg.xcframework`. Only the JNI entry points are exported on Android, so the bundled FFmpeg cannot collide with another copy an app might carry. + ### Encoders Verified by running the example's device suite: iOS reports 187 encoders, Android 185. Everything FFmpeg builds natively (`aac`, `alac`, `flac`, `mpeg4`, `mjpeg`, `png`, `gif`, `pcm_*`, …) is on both, as are `libmp3lame`, `libopus`, `libvpx`, and `libvpx-vp9`. H.264 and HEVC come from the platform's hardware encoder, which is faster and uses less power than a software encoder. `libopenh264` is there as a software H.264 fallback for anywhere hardware encoding is unavailable β€” an emulator, for instance: -| Encoder | iOS | Android | -| --- | --- | --- | -| `h264_videotoolbox`, `hevc_videotoolbox`, `prores_videotoolbox` | βœ… | ❌ | -| `h264_mediacodec`, `hevc_mediacodec`, `vp8_mediacodec`, `vp9_mediacodec` | ❌ | βœ… | -| `aac_at`, `alac_at` (AudioToolbox) | βœ… | ❌ | -| `libopenh264` (H.264, software) | βœ… | βœ… | +| Encoder | iOS | Android | +| ------------------------------------------------------------------------ | --- | ------- | +| `h264_videotoolbox`, `hevc_videotoolbox`, `prores_videotoolbox` | βœ… | ❌ | +| `h264_mediacodec`, `hevc_mediacodec`, `vp8_mediacodec`, `vp9_mediacodec` | ❌ | βœ… | +| `aac_at`, `alac_at` (AudioToolbox) | βœ… | ❌ | +| `libopenh264` (H.264, software) | βœ… | βœ… | +| `libaom-av1` (AV1, software; AVIF stills) | βœ… | βœ… | Resolve the name at runtime instead of branching on `Platform.OS`: @@ -216,7 +232,7 @@ await execute(['-y', '-i', inputPath, '-c:v', h264, outputPath]) Two things to know about hardware encoders: they want NV12 input on Android (`-pix_fmt nv12`) and planar YUV on iOS, and they reject very small frames β€” 176Γ—144 is the smallest size that works everywhere. -Decoding is uniform: H.264, HEVC, VP8/VP9, AV1, MPEG-4, MP3, AAC, Vorbis, Opus, FLAC and the usual containers, on both platforms. Both link TLS, so `https://` inputs work. +Decoding is uniform: H.264, HEVC, VP8/VP9, AV1 (via dav1d, including AVIF images), MPEG-4, MP3, AAC, Vorbis, Opus, FLAC and the usual containers, on both platforms. Both link TLS, so `https://` inputs work. ## πŸ“¦ Installation @@ -281,7 +297,7 @@ You can also create an [EAS development build](https://docs.expo.dev/develop/dev ### Native binaries -The FFmpeg libraries are around 200 MB across all six architectures, which does not belong in an npm tarball, so they are downloaded from the matching GitHub release when the package installs and verified against the checksum in `scripts/binaries.json`. +The FFmpeg libraries are well over 100 MB across all six architectures, which does not belong in an npm tarball, so they are downloaded from the matching GitHub release when the package installs and verified against the checksum in `scripts/binaries.json`. If your environment blocks install scripts (`npm install --ignore-scripts`), fetch them explicitly: @@ -569,7 +585,11 @@ if (!result.success) { ```typescript import { execute, pickEncoder } from 'munim-ffmpeg' -const encoder = await pickEncoder(['h264_videotoolbox', 'h264_mediacodec', 'libopenh264']) +const encoder = await pickEncoder([ + 'h264_videotoolbox', + 'h264_mediacodec', + 'libopenh264', +]) if (!encoder) throw new Error('No H.264 encoder available in this build') // MediaCodec wants NV12 input; the others take planar YUV. @@ -610,6 +630,39 @@ const result = await execute([ ]) ``` +### Write an AVIF still + +AVIF is AV1 in an image container. `libaom-av1` encodes it; `-still-picture 1` switches the encoder into single-image mode and `-f avif` picks the container. Decoding an AVIF back β€” or any AV1 video β€” goes through dav1d automatically. + +```typescript +await execute([ + '-y', + '-ss', + '1.5', + '-i', + inputPath, + '-frames:v', + '1', + '-vf', + 'scale=-2:720', + '-c:v', + 'libaom-av1', + '-still-picture', + '1', + '-cpu-used', + '6', // 0 (slowest, best) … 8 (fastest) + '-crf', + '28', // quality; lower is larger + '-pix_fmt', + 'yuv420p', + '-f', + 'avif', + outputPath, // ends in .avif +]) +``` + +React Native's `` displays AVIF natively on iOS 16+ and Android 12+. + ### Burn subtitles into a video The bundled builds include libass with FreeType, HarfBuzz, and FriBidi, so ASS/SSA styling and complex scripts (Arabic, Urdu, and other RTL or shaped text) render correctly. System fonts are found automatically β€” through Core Text on iOS and through fontconfig scanning `/system/fonts` on Android. @@ -621,18 +674,24 @@ import { execute, normalizePath } from 'munim-ffmpeg' // shadows, positioning, karaoke β€” everything the format supports. await execute([ '-y', - '-i', inputPath, - '-vf', `ass=filename=${normalizePath(subtitlePath)}`, - '-c:a', 'copy', + '-i', + inputPath, + '-vf', + `ass=filename=${normalizePath(subtitlePath)}`, + '-c:a', + 'copy', outputPath, ]) // SRT can be styled at burn time with force_style. await execute([ '-y', - '-i', inputPath, - '-vf', `subtitles=filename=${normalizePath(srtPath)}:force_style='Fontsize=28,PrimaryColour=&H00FFFF00,Outline=2'`, - '-c:a', 'copy', + '-i', + inputPath, + '-vf', + `subtitles=filename=${normalizePath(srtPath)}:force_style='Fontsize=28,PrimaryColour=&H00FFFF00,Outline=2'`, + '-c:a', + 'copy', outputPath, ]) ``` @@ -649,16 +708,41 @@ import { execute } from 'munim-ffmpeg' // Bundle one video, two audio languages, and a subtitle track into MKV. await execute([ '-y', - '-i', videoPath, '-i', urduAudioPath, '-i', subtitlePath, - '-map', '0:v:0', '-map', '0:a:0', '-map', '1:a:0', '-map', '2:s:0', - '-c:v', 'copy', '-c:a', 'aac', '-c:s', 'srt', - '-metadata:s:a:1', 'language=urd', + '-i', + videoPath, + '-i', + urduAudioPath, + '-i', + subtitlePath, + '-map', + '0:v:0', + '-map', + '0:a:0', + '-map', + '1:a:0', + '-map', + '2:s:0', + '-c:v', + 'copy', + '-c:a', + 'aac', + '-c:s', + 'srt', + '-metadata:s:a:1', + 'language=urd', outputMkvPath, ]) // Extract the second audio track without re-encoding. await execute([ - '-y', '-i', outputMkvPath, '-map', '0:a:1', '-c', 'copy', trackPath, + '-y', + '-i', + outputMkvPath, + '-map', + '0:a:1', + '-c', + 'copy', + trackPath, ]) ``` @@ -672,22 +756,60 @@ import { execute } from 'munim-ffmpeg' // MKV with English SRT and styled Urdu ASS tracks, video and audio untouched. await execute([ '-y', - '-i', videoPath, '-i', englishSrtPath, '-i', urduAssPath, - '-map', '0:v:0', '-map', '0:a:0', '-map', '1:0', '-map', '2:0', - '-c:v', 'copy', '-c:a', 'copy', - '-c:s:0', 'srt', '-c:s:1', 'ass', - '-metadata:s:s:0', 'language=eng', '-metadata:s:s:0', 'title=English', - '-metadata:s:s:1', 'language=urd', '-metadata:s:s:1', 'title=Urdu', + '-i', + videoPath, + '-i', + englishSrtPath, + '-i', + urduAssPath, + '-map', + '0:v:0', + '-map', + '0:a:0', + '-map', + '1:0', + '-map', + '2:0', + '-c:v', + 'copy', + '-c:a', + 'copy', + '-c:s:0', + 'srt', + '-c:s:1', + 'ass', + '-metadata:s:s:0', + 'language=eng', + '-metadata:s:s:0', + 'title=English', + '-metadata:s:s:1', + 'language=urd', + '-metadata:s:s:1', + 'title=Urdu', outputMkvPath, ]) // MP4 needs mov_text instead. await execute([ '-y', - '-i', videoPath, '-i', subtitlePath, - '-map', '0:v:0', '-map', '0:a:0', '-map', '1:0', - '-c:v', 'copy', '-c:a', 'copy', '-c:s', 'mov_text', - '-metadata:s:s:0', 'language=eng', + '-i', + videoPath, + '-i', + subtitlePath, + '-map', + '0:v:0', + '-map', + '0:a:0', + '-map', + '1:0', + '-c:v', + 'copy', + '-c:a', + 'copy', + '-c:s', + 'mov_text', + '-metadata:s:s:0', + 'language=eng', outputMp4Path, ]) ``` @@ -746,9 +868,9 @@ if (result.success) { The JavaScript, TypeScript, Swift, Kotlin, C core, and generated Nitro bridge in this repository are Apache-2.0. -The bundled FFmpeg 9.0.1 is **LGPLv3**, on both platforms. It is configured without `--enable-gpl`, so no x264, x265, xvid, or vid.stab. The external libraries it links are LAME (LGPL), Opus (BSD), libvpx (BSD), dav1d (BSD), openh264 (BSD 2-clause), libass (ISC), FreeType (FTL, BSD-style with credit), HarfBuzz (MIT-style), FriBidi (LGPL), and, on Android only, mbedTLS (Apache-2.0), fontconfig (MIT-style), and expat (MIT). None of them change the LGPL story. +The bundled FFmpeg 9.0.1 is **LGPLv3**, on both platforms. It is configured without `--enable-gpl`, so no x264, x265, xvid, or vid.stab. The external libraries it links are LAME (LGPL), Opus (BSD), libvpx (BSD), dav1d (BSD), libaom (BSD 2-clause with the Alliance for Open Media patent licence), openh264 (BSD 2-clause), libass (ISC), FreeType (FTL, BSD-style with credit), HarfBuzz (MIT-style), FriBidi (LGPL), and, on Android only, mbedTLS (Apache-2.0), fontconfig (MIT-style), and expat (MIT). None of them change the LGPL story. -> **A note on H.264 patents.** Hardware encoders are covered by the licences device manufacturers already pay for. Software H.264 encoding through `libopenh264` is not: Cisco's royalty coverage applies to *their* prebuilt binary, and this package builds openh264 from source. If you ship software H.264 encoding at scale, check where you stand with AVC licensing. Hardware encoders avoid the question entirely, which is why `pickEncoder` should list them first. +> **A note on H.264 patents.** Hardware encoders are covered by the licences device manufacturers already pay for. Software H.264 encoding through `libopenh264` is not: Cisco's royalty coverage applies to _their_ prebuilt binary, and this package builds openh264 from source. If you ship software H.264 encoding at scale, check where you stand with AVC licensing. Hardware encoders avoid the question entirely, which is why `pickEncoder` should list them first. In practice that means your application does **not** inherit GPL obligations. LGPL still applies: the FFmpeg libraries are linked and their license and notices must be conveyed with your app, and users must be able to relink against a modified FFmpeg. The exact configuration used is recorded in [`scripts/ffmpeg/build-ios.sh`](./scripts/ffmpeg/build-ios.sh) and [`build-android.sh`](./scripts/ffmpeg/build-android.sh), and the binaries can be reproduced from them. @@ -807,7 +929,10 @@ Nitrogen output under `nitrogen/generated` is committed. Change the `.nitro.ts` ### Example app -`example/` is an Expo app that runs a 25-check device suite: H.264 and HEVC encoding, VP9/Opus in WebM, MP3, AAC, scaling and multi-step filter graphs, software H.264 via openh264, muxing, demuxing, trimming, concatenation, thumbnails, audio resampling, awkward file paths, concurrent sessions, single and global cancellation, protocol support, and both failure paths. Fixtures are generated in JavaScript, so the suite needs no network or bundled media. Results are rendered on screen, written to `munim-ffmpeg-suite.json` in the app's document directory, and logged as `MUNIM_FFMPEG_SUITE_RESULT`. +`example/` is an Expo SDK 57 app β€” a development build, since Expo Go cannot load native modules β€” with two screens: + +- **Playground** picks a video with `expo-document-picker` (or generates one from JavaScript fixtures), inspects it, transcodes it through the device's hardware H.264 encoder via `pickEncoder`, writes an AVIF still, embeds soft subtitles into an MKV, burns them in with libass, and shows progress from the statistics callback with a cancel button wired to `onSessionCreated`. Each action is a plain argument array, so [`example/Playground.tsx`](./example/Playground.tsx) doubles as a recipe book. +- **Device suite** runs the 30+ checks used to verify every release: H.264 and HEVC encoding, VP9/Opus in WebM, MP3, AAC, AVIF, scaling and multi-step filter graphs, software H.264 via openh264, subtitle burn-in and embedding, muxing, demuxing, trimming, concatenation, thumbnails, audio resampling, awkward file paths, concurrent sessions, single and global cancellation, protocol support, and both failure paths. It runs on launch, renders each result, writes `munim-ffmpeg-suite.json` to the app's document directory, and logs it as `MUNIM_FFMPEG_SUITE_RESULT`. ```bash npm run example:ios @@ -815,7 +940,7 @@ npm run example:ios npm run example:android ``` -FFmpeg encoding is slow in a simulator or emulator; run the suite on a physical device. +FFmpeg encoding is slow in a simulator or emulator, and emulators have no working hardware encoder; run it on a physical device. [`example/README.md`](./example/README.md) has the details. ### Rebuilding FFmpeg @@ -828,14 +953,16 @@ See [`scripts/ffmpeg/README.md`](./scripts/ffmpeg/README.md) for what the build ### Releasing -Releases run locally from a clean `main`; this repository does not use GitHub Actions. +Releases run locally from a clean `main`: ```bash npm run check npm run release:local ``` -`release:local` runs semantic-release with the npm token from the macOS Keychain and the GitHub CLI token, so commit messages must follow Conventional Commits. It also uploads `dist-binaries/munim-ffmpeg-binaries.tar.gz` to the GitHub release, which is where `postinstall` fetches it from β€” so run `npm run binaries:package` first. +`release:local` runs semantic-release with the npm token from the macOS Keychain and the GitHub CLI token, so commit messages must follow Conventional Commits. It also uploads `dist-binaries/munim-ffmpeg-binaries.tar.gz` and `build-info.txt` to the GitHub release, which is where `postinstall` fetches the binaries from β€” so run `npm run binaries:package` first. + +Two GitHub Actions workflows back this up: `CI` checks the JavaScript surface on every push and pull request, and `Build binaries` compiles every iOS and Android slice in parallel, on demand or when a pull request touches `scripts/ffmpeg/`, and can attach the result to a release. See [`scripts/ffmpeg/README.md`](./scripts/ffmpeg/README.md#building-in-github-actions). ## πŸ‘ Contributing diff --git a/android/src/main/java/com/margelo/nitro/munimffmpeg/FFmpegNative.kt b/android/src/main/java/com/margelo/nitro/munimffmpeg/FFmpegNative.kt index 7126534..8ee429e 100644 --- a/android/src/main/java/com/margelo/nitro/munimffmpeg/FFmpegNative.kt +++ b/android/src/main/java/com/margelo/nitro/munimffmpeg/FFmpegNative.kt @@ -67,7 +67,7 @@ class FFmpegSession( object FFmpegNative { init { configureFontconfig() - System.loadLibrary("munimffmpeg9") + System.loadLibrary("munimffmpeg") } /** Return code the tools report when a run was cancelled. */ diff --git a/example/App.tsx b/example/App.tsx index 7e750bf..513b4b3 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -12,9 +12,13 @@ import { View, } from 'react-native' +import { Playground } from './Playground' import { runSuite, type CheckResult } from './suite' +type Tab = 'playground' | 'suite' + export default function App() { + const [tab, setTab] = useState('playground') const [running, setRunning] = useState(false) const [version, setVersion] = useState() const [checks, setChecks] = useState([]) @@ -59,73 +63,113 @@ export default function App() { - NITRO MODULE + EXPO SDK 57 Β· NITRO MODULE Munim FFmpeg Type-safe FFmpeg and FFprobe for Expo and React Native. - - - {running - ? `Running on ${Platform.OS}…` - : `${passed} passed${failed > 0 ? `, ${failed} failed` : ''}`} - - {version ? ( - - {version} - - ) : null} + + {( + [ + ['playground', 'Playground'], + [ + 'suite', + `Device suite${checks.length ? ` Β· ${passed}/${checks.length}` : ''}`, + ], + ] as const + ).map(([key, label]) => ( + setTab(key)} + style={[styles.tab, tab === key && styles.tabActive]} + > + + {label} + + + ))} - [ - styles.button, - pressed && styles.buttonPressed, - running && styles.buttonDisabled, - ]} - > - {running ? ( - - ) : ( - Run Device Suite - )} - + {tab === 'playground' ? ( + <> + {running ? ( + + Device suite is running in the background; FFmpeg runs one + command at a time, so actions queue behind it. + + ) : null} + + + ) : ( + <> + + + {running + ? `Running on ${Platform.OS}…` + : `${passed} passed${failed > 0 ? `, ${failed} failed` : ''}`} + + {version ? ( + + {version} + + ) : null} + - {error ? ( - - Suite failed to run - - {error} - - - ) : null} + [ + styles.button, + pressed && styles.buttonPressed, + running && styles.buttonDisabled, + ]} + > + {running ? ( + + ) : ( + Run Device Suite + )} + - {checks.map((check) => ( - - - + Suite failed to run + + {error} + + + ) : null} + + {checks.map((check) => ( + - {check.passed ? 'βœ“' : 'βœ—'} - - {check.name} - {check.durationMs} ms - - - {check.detail} - - - ))} + + + {check.passed ? 'βœ“' : 'βœ—'} + + {check.name} + + {check.durationMs} ms + + + + {check.detail} + + + ))} + + )} ) @@ -169,8 +213,34 @@ const styles = StyleSheet.create({ marginTop: 10, maxWidth: 360, }, - summary: { + tabs: { + backgroundColor: '#0d1d13', + borderColor: '#1e3926', + borderRadius: 14, + borderWidth: 1, + flexDirection: 'row', marginTop: 24, + padding: 4, + }, + tab: { + alignItems: 'center', + borderRadius: 10, + flex: 1, + paddingVertical: 10, + }, + tabActive: { + backgroundColor: '#1e3926', + }, + tabText: { + color: '#a8bdad', + fontSize: 14, + fontWeight: '700', + }, + tabTextActive: { + color: '#72f59b', + }, + summary: { + marginTop: 20, }, summaryText: { color: '#72f59b', diff --git a/example/Playground.tsx b/example/Playground.tsx new file mode 100644 index 0000000..9976f95 --- /dev/null +++ b/example/Playground.tsx @@ -0,0 +1,606 @@ +import { useCallback, useRef, useState } from 'react' +import * as DocumentPicker from 'expo-document-picker' +import { Directory, File, Paths } from 'expo-file-system' +import { + ActivityIndicator, + Image, + Platform, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native' +import { + cancel, + execute, + getMediaInformation, + normalizePath, + pickEncoder, + type FFmpegSessionResult, +} from 'munim-ffmpeg' + +import { rawVideoFrames, RAW_VIDEO, wavFixture } from './fixture' + +/** + * Interactive counterpart to the device suite: pick a file (or generate one), + * inspect it, transcode it with whichever hardware encoder the device has, + * embed or burn in subtitles, write an AVIF still, and cancel mid-run. + * + * Each action is a plain FFmpeg argument array β€” the same command you would + * type on a desktop β€” so it doubles as a set of copy-pasteable recipes. + */ + +type StreamInfo = { + codec_type?: string + codec_name?: string + width?: number + height?: number + sample_rate?: string + channels?: number + tags?: { language?: string; title?: string } +} +type MediaInfo = { + format?: { format_name?: string; duration?: string; size?: string } + streams?: StreamInfo[] +} + +type Output = { name: string; uri: string; bytes: number; note: string } + +// Hardware first, software as the fallback; the same list works on both +// platforms because pickEncoder asks the bundled build what it has. +const H264_ENCODERS = ['h264_videotoolbox', 'h264_mediacodec', 'libopenh264'] + +// MediaCodec wants NV12 frames, everything else takes planar YUV. +function encoderArguments(encoder: string) { + return [ + '-c:v', + encoder, + '-pix_fmt', + encoder.endsWith('_mediacodec') ? 'nv12' : 'yuv420p', + ] +} + +// Filter-graph strings have their own escaping: a path used inside +// `subtitles=` needs `\` `:` and `'` escaped or FFmpeg stops parsing at them. +function filterPath(uri: string) { + return normalizePath(uri).replace(/[\\:']/g, (character) => `\\${character}`) +} + +const SAMPLE_SRT = `1 +00:00:00,000 --> 00:00:01,400 +Soft subtitles stay toggleable. + +2 +00:00:01,500 --> 00:00:03,000 +Burned-in ones are pixels forever. +` + +function workspace() { + const directory = new Directory(Paths.cache, 'munim-ffmpeg-playground') + if (!directory.exists) directory.create({ intermediates: true }) + return directory +} + +function writeFile( + directory: Directory, + name: string, + contents: string | Uint8Array +) { + const file = new File(directory, name) + file.create({ overwrite: true }) + file.write(contents) + return file +} + +function describe(information: MediaInfo) { + const streams = (information.streams ?? []).map((stream) => { + if (stream.codec_type === 'video') { + return `video ${stream.codec_name} ${stream.width}Γ—${stream.height}` + } + if (stream.codec_type === 'audio') { + return `audio ${stream.codec_name} ${stream.sample_rate} Hz Γ—${stream.channels}` + } + const tags = [stream.tags?.language, stream.tags?.title].filter(Boolean) + return `${stream.codec_type} ${stream.codec_name}${tags.length ? ` (${tags.join(', ')})` : ''}` + }) + const seconds = Number(information.format?.duration ?? 0) + return [ + `${information.format?.format_name ?? '?'} Β· ${seconds.toFixed(2)} s`, + ...streams, + ].join('\n') +} + +export function Playground() { + const directory = useRef(workspace()).current + const [input, setInput] = useState<{ uri: string; name: string }>() + const [inputInfo, setInputInfo] = useState() + const [duration, setDuration] = useState(0) + const [busy, setBusy] = useState() + const [progress, setProgress] = useState() + const [statistics, setStatistics] = useState() + const [log, setLog] = useState([]) + const [outputs, setOutputs] = useState([]) + const [error, setError] = useState() + const session = useRef(undefined) + + const inspect = useCallback(async (uri: string) => { + const information = (await getMediaInformation(uri)) as MediaInfo + setInputInfo(describe(information)) + setDuration(Number(information.format?.duration ?? 0)) + }, []) + + /** + * Runs one FFmpeg command with progress, the log tail, and cancellation + * wired to the UI. `-progress`-style statistics arrive through the + * statistics callback; the session ID lands before the first log line so + * the cancel button always has something to cancel. + */ + const runCommand = useCallback( + async (label: string, arguments_: string[], expectedSeconds: number) => { + setBusy(label) + setError(undefined) + setProgress(expectedSeconds > 0 ? 0 : undefined) + setStatistics(undefined) + setLog([]) + try { + const result: FFmpegSessionResult = await execute( + arguments_, + (message) => + setLog((previous) => [...previous.slice(-11), message.trimEnd()]), + (timeMs, _size, bitrate, speed, _frame, fps) => { + if (expectedSeconds > 0) { + setProgress(Math.min(1, timeMs / 1000 / expectedSeconds)) + } + setStatistics( + `${(timeMs / 1000).toFixed(1)} s Β· ${fps.toFixed(0)} fps Β· ${speed.toFixed(2)}Γ— Β· ${bitrate.toFixed(0)} kbit/s` + ) + }, + (id) => { + session.current = id + } + ) + if (!result.success) { + throw new Error( + result.cancelled + ? 'Cancelled' + : (result.failStackTrace ?? + result.output.trim().split('\n').pop() ?? + `exit ${result.returnCode}`) + ) + } + return result + } finally { + session.current = undefined + setBusy(undefined) + setProgress(undefined) + } + }, + [] + ) + + const addOutput = useCallback((file: File, note: string) => { + setOutputs((previous) => [ + { name: file.name, uri: file.uri, bytes: file.size ?? 0, note }, + ...previous.filter((output) => output.name !== file.name), + ]) + }, []) + + const guard = useCallback( + (label: string, action: () => Promise) => async () => { + try { + await action() + } catch (thrown) { + setError( + `${label}: ${thrown instanceof Error ? thrown.message : String(thrown)}` + ) + } + }, + [] + ) + + const pickFile = guard('Pick', async () => { + const picked = await DocumentPicker.getDocumentAsync({ + type: ['video/*', 'audio/*'], + copyToCacheDirectory: true, + }) + if (picked.canceled || !picked.assets?.[0]) return + const asset = picked.assets[0] + // The picker returns a file:// URI; normalizePath() (applied by every API + // call) turns it into the plain path FFmpeg's file protocol expects. + setInput({ uri: asset.uri, name: asset.name }) + setOutputs([]) + await inspect(asset.uri) + }) + + const generateSample = guard('Sample', async () => { + // Fixtures generated in JavaScript: no bundled media, no network. + const raw = writeFile(directory, 'sample.rgb', rawVideoFrames()) + const wav = writeFile( + directory, + 'sample.wav', + wavFixture(RAW_VIDEO.frames / RAW_VIDEO.fps) + ) + const encoder = await pickEncoder(H264_ENCODERS) + if (!encoder) throw new Error('no H.264 encoder in this build') + const sample = new File(directory, 'sample.mp4') + await runCommand( + `Generating sample with ${encoder}`, + [ + '-y', + '-hide_banner', + '-f', + 'rawvideo', + '-pixel_format', + 'rgb24', + '-video_size', + `${RAW_VIDEO.width}x${RAW_VIDEO.height}`, + '-framerate', + String(RAW_VIDEO.fps), + '-i', + raw.uri, + '-i', + wav.uri, + // Hardware encoders reject tiny frames; 320Γ—240 is safe everywhere. + '-vf', + 'scale=320:240', + ...encoderArguments(encoder), + '-c:a', + 'aac', + '-shortest', + sample.uri, + ], + RAW_VIDEO.frames / RAW_VIDEO.fps + ) + setInput({ uri: sample.uri, name: 'sample.mp4 (generated)' }) + setOutputs([]) + await inspect(sample.uri) + }) + + const transcode = guard('Transcode', async () => { + if (!input) return + const encoder = await pickEncoder(H264_ENCODERS) + if (!encoder) throw new Error('no H.264 encoder in this build') + const output = new File(directory, 'transcoded.mp4') + await runCommand( + `Transcoding with ${encoder}`, + [ + '-y', + '-hide_banner', + '-i', + input.uri, + // Even dimensions keep every encoder happy; 480p is quick on a phone. + '-vf', + 'scale=-2:480', + ...encoderArguments(encoder), + '-b:v', + '1500k', + '-c:a', + 'aac', + '-movflags', + '+faststart', + output.uri, + ], + duration + ) + addOutput(output, `H.264 via ${encoder}`) + }) + + const embedSubtitles = guard('Soft subtitles', async () => { + if (!input) return + const srt = writeFile(directory, 'captions.srt', SAMPLE_SRT) + const output = new File(directory, 'soft-subtitles.mkv') + // Streams are copied, so this is a remux: it finishes in a moment and + // the video is never re-encoded. MP4 would want `-c:s mov_text` instead. + await runCommand( + 'Embedding soft subtitles', + [ + '-y', + '-hide_banner', + '-i', + input.uri, + '-i', + srt.uri, + '-map', + '0', + '-map', + '1:0', + '-c', + 'copy', + '-c:s', + 'srt', + '-metadata:s:s:0', + 'language=eng', + '-metadata:s:s:0', + 'title=English', + output.uri, + ], + 0 + ) + const information = (await getMediaInformation(output.uri)) as MediaInfo + const subtitles = (information.streams ?? []).filter( + (s) => s.codec_type === 'subtitle' + ) + addOutput(output, `${subtitles.length} toggleable subtitle track(s) in MKV`) + }) + + const burnSubtitles = guard('Burn-in', async () => { + if (!input) return + const srt = writeFile(directory, 'captions.srt', SAMPLE_SRT) + const encoder = await pickEncoder(H264_ENCODERS) + if (!encoder) throw new Error('no H.264 encoder in this build') + const output = new File(directory, 'burned-in.mp4') + await runCommand( + 'Burning in subtitles with libass', + [ + '-y', + '-hide_banner', + '-i', + input.uri, + '-vf', + `subtitles=${filterPath(srt.uri)}:force_style='FontSize=28,PrimaryColour=&H0000FFFF,Outline=2'`, + ...encoderArguments(encoder), + '-c:a', + 'copy', + output.uri, + ], + duration + ) + addOutput(output, `libass rendered into pixels via ${encoder}`) + }) + + const avifStill = guard('AVIF', async () => { + if (!input) return + const output = new File(directory, 'still.avif') + await runCommand( + 'Encoding AVIF with libaom', + [ + '-y', + '-hide_banner', + '-ss', + String(Math.min(0.5, duration / 2)), + '-i', + input.uri, + '-frames:v', + '1', + '-vf', + 'scale=-2:360', + '-c:v', + 'libaom-av1', + '-still-picture', + '1', + '-cpu-used', + '6', + '-crf', + '28', + '-pix_fmt', + 'yuv420p', + '-f', + 'avif', + output.uri, + ], + 0 + ) + addOutput(output, 'AVIF still (libaom encodes, dav1d decodes)') + }) + + const cancelCurrent = () => { + if (session.current !== undefined) cancel(session.current) + } + + const disabled = busy !== undefined + const needsInput = disabled || !input + + return ( + + 1 Β· Input + +