Skip to content

perf: export is extremely slow — optimise FFmpeg args and show elapsed export time #694

@magic-peach

Description

@magic-peach

Problem

Exporting a video takes far too long — even short clips can sit at single-digit progress for minutes. The screenshot below shows the overlay stuck at 4% with no indication of how long has elapsed or how long remains.

The target is: any export ≤ 5 minutes of video should complete in under 5 minutes on a modern laptop.

Additionally, when export finishes there is no record of how long it took — users have no way to benchmark their settings or know if something went wrong silently.


Root Causes in src/lib/ffmpeg.ts

1. -preset medium (the biggest offender)

Both MP4 and MKV use libx264 with -preset medium:

// current — slow
args.push("-c:v", "libx264", "-crf", String(recipe.quality), "-preset", "medium");

medium is one of the slowest x264 presets. In a single-threaded WebAssembly environment it is extremely costly. Switching to ultrafast or veryfast gives a 3–8× speed improvement with minimal visual quality loss at typical CRF values (18–28).

Fix:

args.push("-c:v", "libx264", "-crf", String(recipe.quality), "-preset", "ultrafast");

Optionally expose veryfast / ultrafast / medium as a user-selectable "Encode speed" toggle (Fast = ultrafast, Balanced = veryfast, Quality = medium).


2. VP9 has no speed flag

WebM exports use libvpx-vp9 with no -cpu-used flag, defaulting to the slowest quality mode:

// current — no speed hint, defaults to -cpu-used 1 (very slow)
args.push("-c:v", "libvpx-vp9", "-b:v", "0", "-crf", String(recipe.quality));

VP9 with -cpu-used 8 is 10× faster than the default, with acceptable quality for web use. -cpu-used 4 is a good balanced default.

Fix:

args.push("-c:v", "libvpx-vp9", "-b:v", "0", "-crf", String(recipe.quality), "-cpu-used", "4", "-deadline", "realtime");

3. eq filter fires even at neutral defaults

The video filter always appends the eq filter:

filters.push(
  `eq=brightness=${recipe.brightness}:contrast=${recipe.contrast}:saturation=${recipe.saturation}`
);

When all values are at their defaults (brightness=0, contrast=0, saturation=0) this still adds a full processing pass. Skip it when nothing has changed:

Fix:

const needsEq =
  recipe.brightness !== 0 ||
  recipe.contrast !== 0 ||
  recipe.saturation !== 0;

if (needsEq) {
  filters.push(`eq=brightness=${recipe.brightness}:contrast=${recipe.contrast}:saturation=${recipe.saturation}`);
}

Note (separate bug): FFmpeg's eq filter uses contrast=1.0 and saturation=1.0 as neutral, not 0. The current DEFAULT_RECIPE sets both to 0, which produces a black, grayscale image when the filter runs. This should also be fixed: change DEFAULT_RECIPE.contrast and DEFAULT_RECIPE.saturation to 1 in src/lib/types.ts, or remap the slider range so the UI "0" maps to FFmpeg "1".


4. Single-threaded WebAssembly (stretch goal)

FFmpeg.wasm defaults to @ffmpeg/core (single-threaded). The multi-threaded core (@ffmpeg/core-mt) uses SharedArrayBuffer and can use all available CPU cores, giving 2–4× additional speedup on multi-core machines.

This requires COOP/COEP headers, which are already partially set in vercel.json. Check whether Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp are both set, then switch the core URL to @ffmpeg/core-mt.

Reference: https://ffmpegwasm.netlify.app/docs/getting-started/usage/#multi-thread


Feature: Show Export Elapsed Time

During export

Show a live elapsed time counter in the export overlay:

Processing your video locally.                      0:42 elapsed
████████░░░░░░░░░░░░  42%
  • Start the timer when status transitions to "exporting"
  • Display as M:SS (e.g. 1:23 elapsed)
  • Use a setInterval in ExportOverlay.tsx or derive from a startTime ref in useVideoEditor.ts

On the success screen

Show total export duration on the download result card:

✓ Export complete
video-reframed.mp4 · 42 MB · 1920×1080
⏱ Exported in 1 min 23 sec
  • Store exportStartTime in a ref when export begins
  • On success, compute Date.now() - exportStartTime and include it in the result state or display it in DownloadResult.tsx

Files to Modify

  • src/lib/ffmpeg.ts — change -preset, add -cpu-used for VP9, skip eq at defaults
  • src/lib/types.ts — fix DEFAULT_RECIPE.contrast and DEFAULT_RECIPE.saturation (see note above)
  • src/components/ExportOverlay.tsx — add live elapsed time counter
  • src/components/DownloadResult.tsx — show total export duration
  • src/hooks/useVideoEditor.ts — track exportStartTime ref, pass duration to result state
  • vercel.json — verify COOP/COEP headers for potential multi-thread upgrade

Acceptance Criteria

  • A 2-minute, 1080p MP4 export completes in under 2 minutes on a modern laptop (M1/M2 Mac or equivalent Intel)
  • -preset ultrafast (or veryfast) is used for libx264
  • -cpu-used 4 -deadline realtime is used for libvpx-vp9
  • The eq filter is skipped when brightness/contrast/saturation are all at neutral defaults
  • A live elapsed time counter is visible during export (0:42 elapsed)
  • The download result card shows total export time (Exported in 1 min 23 sec)
  • Existing export behaviour (trim, rotate, audio, speed, overlay) is unaffected
  • bun run lint and bunx tsc --noEmit pass with no errors

Screen Recording Required

Your PR must include two screen recordings:

  1. Before: record an export of a 1–2 minute video with the current code, showing how long it takes (the elapsed time on your system clock or a timer app visible in the recording)
  2. After: record the same export with your fix applied, showing the reduced time

This before/after recording is how maintainers will verify the performance improvement is real.

See CONTRIBUTING.md for how to record on your OS.

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions