Skip to content

Render desktop exports with a bundled native ffmpeg - #108

Merged
wassgha merged 1 commit into
mainfrom
electron-native-ffmpeg-export
Oct 3, 2026
Merged

wassgha merged 1 commit into
mainfrom
electron-native-ffmpeg-export

Conversation

@wassgha

@wassgha wassgha commented Oct 3, 2026

Copy link
Copy Markdown
Owner

Why

Desktop users keep losing exports to wasm memory limits. Sentry has RESCRIPT-1W ("memory access out of bounds") and RESCRIPT-2A ("The media engine stopped responding…"), both tagged stage=export-video, surface=desktop, on 1.2.1. Switching to the growable wasm core (#101) wasn't enough:

  • that core still tops out at 2 GiB;
  • the input sits in MEMFS/WORKERFS;
  • the encoded output is held entirely in wasm memory, then copied into a Blob.

What

The Electron app now runs a bundled static ffmpeg in the main process for import audio extraction and for video/audio export. ffmpeg reads the source straight from disk and writes the render to a temp file. A native Save… dialog then moves the file wherever the user picks, and Show in Finder/Explorer works after that. The web build is unchanged.

Fallback and errors

  • Missing or unusable binary: if it's missing, fails to start, lacks an encoder, or crashes with SIGILL/SIGSEGV, the app quietly falls back to ffmpeg.wasm and reports native-media-fallback to Sentry.
  • Job failures: a non-zero ffmpeg exit is shown as an export error, with no fallback, since wasm would only fail the same way more slowly. The stderr tail is attached to the Sentry event as extra.detail, with paths scrubbed.
  • Disk full: shown as its own localized error.

Security

  • The renderer never sends a path or an argv. Media, PCM and rendered outputs are opaque IDs owned by the webContents that created them.
  • Main builds the ffmpeg command itself from a validated request (parseExportRequest: enums, finite sorted ranges, a count cap, unknown keys dropped).
  • IPC handlers return {ok, code, detail} instead of throwing, so the renderer can tell "fall back" apart from "failed".

Pieces

lib/exportArgs.ts Filtergraph and codec builder shared by both engines, plus the -progress parser. Also adds -pix_fmt yuv420p, so 10-bit / 4:2:2 / 4:4:4 sources export playable files (this applies to wasm too).
electron/ffmpegRunner.ts Spawns ffmpeg, supervises it, classifies failures and probes the binary. No electron import, so tests can drive it.
electron/media.ts The IPC surface (described below).
lib/nativeMedia.ts, lib/mediaEngine.ts Renderer client, plus a facade that picks the engine.
scripts/fetch-ffmpeg.mjs Downloads the pinned ffmpeg-static b6.1.1 binaries, checks them by SHA-256, and verifies encoders and filters (and otool -L on mac). Output goes to build/ffmpeg/<os>-<arch>/ and ships through extraResources.
scripts/after-pack.cjs Fails the build if a target is missing its binary.
lib/projects.ts / autosave.ts / store.ts Projects now store the original file's path, size and mtime. A restored project reads the original from disk when it's unchanged, and only copies the IndexedDB blob back out when the original has moved. exportUrl becomes exportResult (blob or file), which is freed on reset or project switch. This also fixes a Blob-URL leak.
ExportDialog / Editor Save → Saved as / Show in folder / Save again flow. Rendering copy changes when native ffmpeg is in use. Native extraction no longer loads the wasm core. New strings are added in all 9 locales.

electron/media.ts details:

  • Temp files live in a per-session directory. Stale sessions are swept at startup, and the current one is removed on quit.
  • When a renderer navigates or crashes, its jobs are killed and its temp files are deleted.
  • The filtergraph is passed with -filter_complex_script. At about 130 characters per kept range, Windows' 32K command-line limit is hit at roughly 250 cuts.
  • Moves fall back to copy+unlink on EXDEV/EPERM/EBUSY.

Binaries and packaging

  • Upstream builds per platform:

    Target Build
    darwin-arm64 6.0
    darwin-x64 6.1.1 (evermeet)
    win32-x64 6.1.1 (gyan essentials)
    linux-x64 7.0.2

    All of them include libx264, libvpx-vp9, libopus, aac and libmp3lame.

  • Windows arm64 uses the x64 binary under emulation, because upstream has no arm64 Windows build.

  • Installer size grows by about 20–30 MB compressed per platform.

  • macOS signing: osx-sign already signs every Mach-O file in the bundle with entitlementsInherit and hardened runtime, so no signing config changes are needed.

  • Licensing: these are GPL builds. They ship alongside the app as a separate executable, with their LICENSE and README next to them. The wasm core was already GPL (x264).

  • Docs: RELEASING.md has a new "Native ffmpeg" section.

Testing

  • tsc (web + electron), eslint, next build, build:electron, test:i18n, test:timeline all pass.
  • tests/export-args-test.ts: argv matches the old wasm commands exactly for every preset; request validation (including injection attempts and oversized range lists); progress parsing edge cases (N/A, negative values, chunks split mid-line).
  • tests/native-ffmpeg-test.ts, run against the real bundled binary:
    • Every preset (MP4 original/720p, WebM, M4A, MP3, WAV) with 3 cuts. The output duration matches the kept time.
    • Silent source.
    • A 300-cut graph (over 32K characters).
    • Audio extraction sample count.
    • No-audio and corrupt input both come back as failed, not unavailable.
    • A missing binary comes back as unavailable.
    • Cancellation is prompt.
    • Every arch in build.*.target has a pinned binary.
  • CI now runs fetch:ffmpeg --host before test:ffmpeg.
  • Also verified by hand: the darwin-x64 (6.1.1) binary under Rosetta handles the same argv, including -filter_complex_script.
  • Ran in Electron (electron:dev, macOS arm64), driven over CDP:
    • Imported a 30 s 1080p file. The audio was extracted natively, the source was registered by path (no staged copy), and no ffmpeg.wasm assets were fetched.
    • Exported MP4 at 1080p. Progress went 0→19→38→56→71→89%, then Save, then the native dialog. The temp file was moved into ~/Downloads, and the result is a playable 1920×1080 H.264 High yuv420p MP4. The dialog showed "Saved as… / Show in Finder".
    • A path-less File (the restored-project case) staged correctly. Junk input was classified no-audio, which matches wasm's behavior.
    • A page reload deleted that renderer's staged temp file.
    • Autosave stored sourcePath/sourceSize/sourceMtime on the project record.

Not yet verified, worth checking before release

  • A signed and notarized npm run dist on macOS (check codesign -dv on Contents/Resources/ffmpeg/ffmpeg).
  • Windows x64 and arm64 (emulated) installers, and the Linux AppImage, actually running the bundled binary.
  • Reopening a project from File › Recent end to end in the UI. The bridge calls behind it were verified, but the native menu couldn't be clicked over CDP.
  • Fallback path: RESCRIPT_FFMPEG_PATH=/nonexistent npm run electron:dev should export through wasm.
  • A long (≥1 h) 4K source with hundreds of cuts.

Follow-ups (not in this PR)

  • A Cancel button in the export UI (export:cancel IPC already exists).
  • Hardware encoders (h264_videotoolbox, etc.).
  • A single-branch select/aselect graph, which scales better than one trim branch per cut.

Fixes RESCRIPT-1W, RESCRIPT-2A

🤖 Generated with Claude Code

Desktop exports kept failing with wasm memory errors (RESCRIPT-1W "memory
access out of bounds", RESCRIPT-2A "media engine stopped responding", both
stage=export-video on desktop 1.2.1). Even the growable ffmpeg.wasm core
tops out at 2 GiB, and the whole encoded output is held in wasm memory and
then copied into a Blob.

The Electron app now extracts audio and renders exports with a static
ffmpeg run from the main process. ffmpeg reads the source from disk and
writes the render to a temp file; a native Save dialog then moves it where
the user wants it, with Show in Finder/Explorer afterwards. The web build
is unchanged.

- lib/exportArgs.ts: filtergraph/codec builder shared by both engines, plus
  request validation and a -progress parser. Adds -pix_fmt yuv420p so
  10-bit / 4:2:2 sources export playable files.
- electron/ffmpegRunner.ts + electron/media.ts: spawn/supervise ffmpeg and
  the IPC surface. The renderer only handles opaque ids; main builds the
  argv itself from a validated request. Filtergraph goes through
  -filter_complex_script (Windows command-line limit), temp files are swept
  per session and per renderer.
- lib/nativeMedia.ts + lib/mediaEngine.ts: renderer client and facade;
  falls back to ffmpeg.wasm (reported as native-media-fallback) only when
  the binary can't run, not when a job fails.
- Projects remember the original file's path/size/mtime so a restored
  project reads it from disk instead of copying the IndexedDB blob back out.
- scripts/fetch-ffmpeg.mjs downloads pinned ffmpeg-static b6.1.1 binaries
  (SHA-256 verified) per os/arch for extraResources; after-pack.cjs fails
  the build if one is missing.
- Tests: argv parity with the old wasm commands, request validation, and an
  end-to-end native render of every preset (CI fetches the host binary).

Fixes RESCRIPT-1W
Fixes RESCRIPT-2A

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
app.rescript Ready Ready Preview Oct 3, 2026 4:28pm UTC

@wassgha
wassgha merged commit c472b6e into main Oct 3, 2026
3 checks passed

This branch was successfully deployed

1 active deployment
Preview — f09ac304 Deployed Oct 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant