The MediaRecorder export fallback introduced in #797 always emits a
WebM container (most browsers don't expose an MP4 muxer through
MediaRecorder). WebM plays in Chrome / Firefox / VLC but not
natively in QuickTime or Safari, so users on browsers without
WebCodecs who picked the MP4 format end up with a file they can't
open in those players.
This adds an opt-in escape hatch in the export popover:
- When `isWebCodecsExportSupported()` is false AND the user picked
MP4, the popover surfaces a "Browser compatibility" section that
explains the situation and offers a "Transcode to H.264 MP4 on
the server" checkbox (defaulted to on, since the user did ask
for MP4).
- When the checkbox is on, the post-export flow wraps the produced
WebM buffer in a `File` and runs it through `convertVideoToH264`,
which already routes to the existing `/api/convert-video` ffmpeg
endpoint. The downloaded file is a real H.264 MP4.
- If the user opts out, the popover keeps the WebM extension and
mime type so the saved file matches its real container.
- If the server transcode fails, the export is preserved as WebM
and the user is notified via a toast.
A small "Transcoding to MP4 on server…" section is shown in the
popover between the export finishing and the download starting,
since that step can take a noticeable amount of time on large
clips.
The WebCodecs-capable code path is unchanged: the warning section
is only rendered when `!isWebCodecsExportSupported() && format ===
"mp4"`.
The existing exporter uses mediabunny, which delegates to the
WebCodecs VideoEncoder and AudioEncoder. On browsers where those
APIs are not exposed (the same environments that needed the decode
fallback in #794), export currently fails with
"AudioEncoder is not supported by this browser" before producing
any output.
This change adds a parallel fallback path in SceneExporter:
- New helpers \`isWebCodecsExportSupported()\` and
\`detectMediaRecorderSupport()\` live in a tiny dependency-free
module so they can be unit-tested without dragging in WASM.
- New \`exportWithMediaRecorder\` drives the existing CanvasRenderer
at real-time wall-clock, captures the output canvas with
\`HTMLCanvasElement.captureStream(fps)\`, mixes the project audio
buffer through a \`MediaStreamAudioDestinationNode\`, and records
the combined MediaStream with MediaRecorder, producing a WebM
blob the same browser can play back natively.
- \`SceneExporter.export()\` detects \`!isWebCodecsExportSupported()\`
at entry and delegates to the new path. The WebCodecs path is
unchanged for capable browsers.
Trade-offs of the fallback path:
- Output is always WebM (most browsers don't expose an MP4 muxer
via MediaRecorder). The user's requested format is honoured
best-effort.
- Rendering runs at real time, so a 60-second timeline takes
roughly 60 seconds to export. mediabunny's WebCodecs path
continues to render faster than real-time on capable browsers.
- Bitrate is mapped per quality preset to a numeric value
compatible with \`MediaRecorder.videoBitsPerSecond\`.
Unit tests cover the feature-detection helpers across the four
states of \`VideoEncoder\` / \`AudioEncoder\` / \`MediaRecorder\`
availability.
OpenCut's video preview and audio playback both rely on mediabunny, which
in turn requires the WebCodecs API (VideoDecoder / AudioDecoder). On
browsers where WebCodecs is not exposed (e.g. Linux Chromium builds
without proprietary codec support), import and playback both throw
"codec not supported" errors and the editor becomes unusable.
This change makes the editor work on those browsers without sacrificing
the WebCodecs fast-path elsewhere:
- Add a server-side /api/convert-video route that transcodes incoming
videos to H.264 baseline 3.1 yuv420p via the system ffmpeg binary.
processMediaAssets uses it automatically when readVideoFile reports
canDecode=false so the asset stored is always something the browser
can play.
- Add HTMLVideoElementSink, a CanvasSink-shaped sink backed by a
hidden <video> element + canvas.drawImage. VideoCache.initializeSink
selects it only when WebCodecs VideoDecoder is unavailable; otherwise
it keeps using mediabunny's CanvasSink unchanged.
- Add FallbackAudioBufferSink, an AudioBufferSink-shaped sink backed by
AudioContext.decodeAudioData (which uses the platform's native audio
decoders, not WebCodecs). AudioManager and the import-time decode
path swap to it on canDecode=false. runClipIterator is wrapped in a
try/catch so a sink failure no longer leaves an unhandled rejection.
Tests cover frame timing, dispose semantics, FPS clamping, the
WebCodecs/AudioDecoder availability probes, AudioBuffer slicing, and
the fallback iterator contract.
The freeform mask was assembled on every getMaskDefinition("freeform") call,
re-spreading params and injecting a wrong SquareIcon placeholder, with a type
cast to suppress the structural mismatch. Root cause: MasksRegistry was keyed
on BuiltinMaskType, so freeform had no entry.
- Re-key MasksRegistry from BuiltinMaskType to MaskType
- Register freeform alongside builtins in registerDefaultMasks, with
PenToolAddIcon as its menu icon
- getMaskDefinition and getMaskDefinitionsForMenu become plain registry
lookups with no branching, no manual spreading, no cast
- Rename RegisteredBuiltinMaskDefinition to RegisteredMaskDefinition and
tighten computeParamUpdate return type to Partial<BaseMaskParams>
Co-authored-by: Cursor <cursoragent@cursor.com>
The { kind: "tangent" } MaskHandleId variant was never constructed — neither
getFreeformDisplayHandles nor any other code ever produced a tangent handle
object. The in/out control-arm branches in computeFreeformParamUpdate, the
tangent case in maskHandleIdKey, and the CUSTOM_MASK_TANGENT_SIZE rendering
path in mask-handles.tsx were all unreachable. Removed all four dead paths
and removed "tangent" from MaskHandleKind.
Co-authored-by: Cursor <cursoragent@cursor.com>
- Remove dead ternary in getPreferredEdges (always returned undefined; the
all-false object was never reached by Object.values().some(Boolean))
- Document why drawWithFeather descriptors zero the feather field in the
compositor (analytical renderer handles softness itself; zeroing prevents
a second JFA feather pass)
- Remove legacyType from V30→V31 migration (not consumed anywhere; a
down-migration can map type:"freeform" back to "custom" directly)
- Add V30→V31 test coverage: skip-guard branches, freeform passthrough,
elements without a masks array
- Add snap tests for vertical-edge (bottom) and uniform-scale handle paths
Co-authored-by: Cursor <cursoragent@cursor.com>
Move timeline and preview gestures into dedicated controllers so hooks stay thin around editor state and DOM events.
This also routes drag and playback synchronization through manager APIs and reuses persistent wasm surfaces so scrubbing, seeking, and preview rendering stay in sync.
Made-with: Cursor