Skip to content

Export video on the web beta, and of cloud strategies anywhere - #230

Merged
SunkenInTime merged 8 commits into
icarus-cloudfrom
web-video-export
Sep 29, 2026
Merged

SunkenInTime merged 8 commits into
icarus-cloudfrom
web-video-export

Conversation

@SunkenInTime

@SunkenInTime SunkenInTime commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #229. This is the second half of "make record and screenshot work on the web".

Video export was blocked on the web for a real reason: it encodes by starting a bundled ffmpeg process, and a browser can't start processes. It was also broken for cloud strategies everywhere. The dialog listed pages from the local Hive box and rendered from it, where cloud strategies never are, so a cloud strategy showed no pages and exported nothing.

The web now encodes H.264 with WebCodecs and writes the MP4 itself. Desktop keeps ffmpeg.

A frame of a web export (Social, 1080p30) The video
frame web-social.mp4: 2 pages, 2 s holds and the transition, encoded in headless Edge

How

  • VideoFrameSink separates rendering from encoding. VideoExporter renders frames and hands each one, with its duration, to a sink:
    • FfmpegVideoSink (desktop) is the existing PNG-sequence, ffconcat and ffmpeg pipeline, moved out of the exporter unchanged.
    • The browser sink (browser_video_sink*.dart, a conditional export on dart.library.js_interop) uses WebCodecsMp4Encoder, which encodes as frames arrive, and Mp4H264Muxer, a pure-Dart faststart MP4 writer. It returns the bytes, and the dialog downloads them with FilePicker.saveFile(bytes:).
  • Encoder:
    • Output is constant frame rate. A held page reuses one uploaded frame.
    • Keyframes go at each held page and at least every 2 s.
    • The profile falls back High → Main → Constrained Baseline, because Chrome's software encoder only does Baseline.
    • The encode queue is bounded (it waits on dequeue), and 720p scaling goes through an OffscreenCanvas.
    • Presets keep their sizes and bitrates. The browser has no second pass, so there is no size retry.
    • A browser without H.264 WebCodecs is told so before anything renders.
  • Colour:
    • Edge's hardware encoder writes full-range BT.709 samples but no colour description. Players then assume limited range and crush the dark map to black; the background went from 15 to 0.
    • The muxer now writes the encoder's reported colour into a colr/nclx box.
    • Measured: frames decode within 2 levels of the screenshot's pixels, in ffmpeg and in browser playback.
  • What gets rendered (loadVideoExportSource):
    • Local: the strategy is saved and read back from the library, as before.
    • Cloud: the other pages live only on the server. The export flushes the open page, waits up to 20 s until the op and media queues have nothing left to send, then reads the whole strategy from the server. It reads the queues directly, not the save chip, because a cloud save with nothing to send leaves the chip pending (see Found along the way).
    • Refusals: if the work doesn't land, the export says so instead of exporting a video missing edits the user can see. Work held for review fails at once. A selected page a teammate deleted fails with a message and refreshes the list.
    • Signed-out readers of a share link read through their link. fetchFullSnapshot takes the shareToken the server already accepts (Let anyone with a strategy link view it without an account #225), so there's no convex/ change.
  • Images reuse the screenshot's resolution (fetched, decoded and held for the whole export).
  • Dialog: it shows progress from the start (saving and syncing are visible), and Cancel reaches every step, including a cancel that lands just as the video finishes. On the web it asks the user to keep the tab open, because a hidden tab stops painting frames.
  • The capture container leaves cloud sync alone. The exporter now uses Take screenshots on the web beta, and of cloud strategies anywhere #229's createCaptureContainer. Its hand-built container used to build a real auth provider, which tore down the app's Convex auth when disposed, so every video export signed the editor out of sync. That was already true on desktop for anyone signed in. A test requires an export to make no call to the Convex auth API.
  • Video export leaves PlatformPolicy.webBeta.desktopOnly.

Reproduction

  • Web beta, click Export video: "Video export is desktop-only for now."
  • Desktop, a cloud strategy, Export video: the dialog lists no pages.

Verification

  • Flutter: the full suite passes. New tests:
    • test/video_exporter_sink_test.dart: the exporter renders every page and transition frame, with its image, into a sink. Desktop encodes a real 1920x1080 H.264 MP4 of the planned length through the moved ffmpeg pipeline (skipped where ffmpeg lacks an H.264 encoder).
    • test/video_export_source_test.dart: waits for queued work; a strategy with nothing to send reads at once and leaves the save chip alone; timeout; held-for-review; cancel; a deleted page; the share token; local read-back with image files.
    • test/mp4_muxer_test.dart: box layout, the colr box, and a real libx264 stream muxed with variable durations that ffprobe and ffmpeg read back exactly (4.9 s, 20 frames, pc/bt709).
  • Real browser: the same headless-Edge harness ran the real exporter with the browser sink.
    • Social (1080p30) and Max (1080p60) exports took 2 to 3.5 s. They play back in Edge at 1920x1080 for 4.433 s and 4.417 s, matching the planned 2 s + transition + 2 s.
    • ffprobe reads h264 High, 1920x1080, 30/60 fps, 133/265 frames, and ffmpeg decodes both cleanly.
    • The encoder core alone passed 27 checks in Edge: keyframe placement, remainder carry-over, scaling, cancel.
  • Web build: flutter build web with CI's flags compiles, and the bundle contains the WebCodecs path.
  • Real app: Codex drove a local web build in Chrome. Export video (Social, 2 pages) showed "Saving your changes", "Rendering frames (6/15)" and the keep-the-tab-open line, then downloaded a 6.43 s 1080p30 H.264 MP4 and toasted "Video exported." It shows both pages, the transition, and the app's colours.
  • Review: Codex Astra reviewed statically three times. It found a blocker (waiting on the save chip) and should-fixes (cancellation, a late cancel still downloading, a deleted page, codec-aware test skips, media-outbox reliability), all fixed.

Found along the way (not changed here)

  • A cloud forceSaveNow with nothing to send marks the save state pending, and nothing clears it, so the chip can say "Syncing…" indefinitely. Export no longer goes through it; the root cause belongs in its own PR.
  • The .ica cloud export also calls fetchFullSnapshot without the share token, so it fails for signed-out link readers. It's a one-line follow-up, now that the parameter exists.

Not covered

  • The encoder is verified in Edge only (headless and hardware). Firefox and Safari support WebCodecs, but I didn't run them; if they lack H.264 encoding they get the "cannot export" message.
  • Very long Max exports keep every encoded sample in memory until the download. I measured seconds-long videos, not ten-minute ones.

🤖 Generated with Claude Code

RetriggerConfidence Score: 5/5

No outstanding findings block merging.

Summary

The PR adds browser video encoding and cloud-strategy video export. The latest changes give capture image downloads a caller-owned client, addressing the previously reported signed-URL refresh failure. No new findings were supplied, and neither previous review thread remains outstanding.

Reviews (3) · Last reviewed commit: "Download a video export's images through..."

@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 3f522047-8ed8-4a4b-b617-55bc0355c835

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@SunkenInTime
SunkenInTime force-pushed the web-video-export branch 6 times, most recently from fa30ce5 to 3b93ba2 Compare September 29, 2026 06:46
Comment thread lib/services/video_export/browser_video_sink_web.dart Outdated
Comment thread lib/widgets/dialogs/export_video_dialog.dart
@greptile-apps

This comment has been minimized.

SunkenInTime and others added 8 commits September 29, 2026 03:33
Browsers cannot spawn the bundled ffmpeg, so the web beta needs its own
encoder. Two building blocks, not wired into the exporter yet:

- Mp4H264Muxer: pure-Dart faststart MP4 writer for one H.264 track
  (ftyp, moov, mdat; run-length stts, stss, one chunk). Verified against
  ffprobe and an ffmpeg decode of a real libx264 stream.
- WebCodecsMp4Encoder: VideoEncoder bindings that turn RGBA frames into
  a constant-frame-rate MP4, repeating each frame for its duration,
  scaling through an OffscreenCanvas, and waiting on the encode queue.

The export exceptions move to video_export_errors.dart so the web file
avoids dart:io; ffmpeg_video_encoder.dart re-exports them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Video export was blocked on the web because it encodes with a bundled
ffmpeg process, which a browser cannot start. It was also broken for
cloud strategies everywhere: the dialog listed pages from, and rendered,
the local Hive box, where cloud strategies never are, so it showed no
pages and exported nothing.

Encoding moves behind VideoFrameSink. VideoExporter renders frames and
hands them to a sink:
- FfmpegVideoSink (desktop) is the existing PNG-sequence + ffconcat +
  ffmpeg pipeline, moved out of the exporter unchanged.
- The browser sink encodes with WebCodecs into an MP4 as frames arrive
  (previous commit) and hands the bytes back, which the dialog downloads
  through FilePicker.saveFile(bytes:). Presets keep their sizes and
  bitrates; the browser has no second pass, so there is no size retry.
  A browser without H.264 WebCodecs is told so before anything renders.

What gets rendered (loadVideoExportSource): a local strategy is saved and
read back from the library as before. A cloud strategy's other pages live
only on the server, so the export saves, waits (up to 20 s) for this
device's changes to land, i.e. for the strategy to show as synced, and
then reads the whole strategy from the server. If the work does not land
it stops and says so rather than export a video missing edits the user
can see. A signed-out reader who opened a share link reads through that
link (fetchFullSnapshot takes the shareToken the server already accepts).

Images go through the same resolution as screenshots: files as they are,
cloud URLs fetched and decoded before rendering, held for the whole
export and handed to the capture container through
captureImageSourcesProvider. The dialog's page list comes from wherever
the strategy lives, and it shows its progress dialog from the start, so
saving and syncing are visible and cancellable.

Video export leaves PlatformPolicy.webBeta.desktopOnly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review and browser-run follow-ups to web video export.

Colour: Edge's hardware H.264 encoder writes full-range BT.709 samples
with no colour description in the bitstream. Players then assume limited
range and crush Icarus's darks to black (the map background went from 15
to 0). The encoder reports the colour it wrote in its decoder config, and
the muxer now carries it in the sample entry's colr/nclx box, which
ffmpeg, Chromium and QuickTime read. Measured in Edge: frames decode
within 2 levels of the screenshot's pixels, in ffmpeg and in browser
playback.

Cloud source: forceSaveNow on a cloud strategy with nothing to send marks
the save state pending, and since no queue state changes, nothing clears
it, so waiting on canLeaveSafely could time out on a synced strategy and
leave the chip saying syncing. The export now flushes the open page
through the page session (which sets no chip state) and reads the op and
media queues directly: nothing queued, in flight or uploading means sent.
Work held for review fails at once rather than after 20 s.

Cancelling now reaches the sync wait and image loading, and a cancel
that arrives while the export wraps up no longer downloads the video. A
selected page a teammate deleted fails the export with a message and
refreshes the page list instead of being left out silently. The ffmpeg
integration tests skip when ffmpeg lacks the H.264 encoder they need.

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

Second review follow-up. Cancelling now also stops image loading between
images (and after the page flush), releasing what was already decoded,
instead of downloading and decoding the rest first. And the export only
counts this device's work as sent when the media outbox is readable too:
an upload record it could not read is not in its job list, so an empty
list alone cannot say the upload landed.

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

The exporter built its offscreen container by hand, so its strategy
provider built a real auth provider, which set or cleared auth on the
app's one Convex client and tore it down on dispose: every video export
signed the editor out of cloud sync. That was true on desktop before
this branch too, for anyone signed in. It now uses the screenshot's
createCaptureContainer, with its inert auth, and a test requires an
export to make no call to the Convex auth API.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Greptile follow-up: the preflight asked whether the browser could encode
1080p at 8 Mbps whatever the preset and length, so a browser that could
encode what the export actually needs (a long Potato video drops to 720p
at 250 kbps) was refused. The check and the encoder now share one
function for the output size and bitrate, and the dialog passes the
planned length.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The fetcher now receives the capture's client (#232); passing it to
downloadCloudImageBytes keeps one finished download from closing it for
the rest.

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

Copy link
Copy Markdown
Owner Author

@greptileai review

@SunkenInTime
SunkenInTime merged commit 3cbd0f7 into icarus-cloud Sep 29, 2026
14 checks passed
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