feat(export): add native GIF export pipeline (slice 1, behind flag) - #200
Open
EtienneLescot wants to merge 2 commits into
Open
feat(export): add native GIF export pipeline (slice 1, behind flag)#200EtienneLescot wants to merge 2 commits into
EtienneLescot wants to merge 2 commits into
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Lands the foundation of the native GIF export path end-to-end, behind
the NATIVE_GIF_EXPORT_ENABLED feature flag (false for this PR — the
legacy gif.js pipeline in src/lib/exporter/gifExporter.ts stays as the
default).
This is slice 1 of the D3D ↔ Pixi cleanup roadmap per
technical-documentation/engineering/export-pipeline.md. Subsequent
slices will swap the documentExporter (which currently routes through
GifExporter) and delete the legacy Group A files (frameRenderer.ts,
threeDPass.ts, gifExporter.ts) and the pixi.js / pixi-filters deps.
## Path chosen
Hand-rolled pure-Rust GIF89a writer in
crates/compositor/src/gif_export.rs — no new crate deps, no swscale
round-trip, no GPL pull-ins. The rejected alternatives:
- ffmpeg palettegen + paletteuse: the compositor's ffmpeg bindings are
avformat / avcodec / avutil / swscale / swresample only, no
libavfilter (see crates/compositor/Cargo.toml and
crates/compositor/build.rs), so palettegen / paletteuse are not
buildable.
- ffmpeg's gif muxer / codec: it expects pre-quantized PAL8 frames
and refuses to do the quantize step itself. The ffmpeg GIF path
would still need a hand-rolled palette and LZW — pure Rust keeps
it auditable in one file with no swscale detour.
## What's in the PR
1. New module crates/compositor/src/gif_export.rs (~1000 lines):
- openscreen_compositor::gif_export::export_gif: single-clip GIF
export driving Player (the same component the live preview uses)
and Compositor::readback_direct, with the same cursor path the
run_composited MP4 arm uses.
- Hand-rolled GIF89a writer (GifWriter): header + Graphics Control
Extension + Image Descriptor + LZW (GIF's 8-bit variant with
clear=256, EOI=257, LSB-first code packing) + trailer, drop
auto-flushes the trailer byte.
- Hand-rolled LZW encoder (lzw_compress): code table starts at
codes 0-255 (palette) + 256 clear + 257 EOI, grows to 4096
entries, resets with a clear code at table-full.
- Median-cut palette builder (build_palette_median_cut): standard
Heckbert algorithm on a colour histogram, count-weighted split at
the median of the longest channel axis. Re-quantized every 30
frames; cached palette stays close to optimal within a 2.5 s
window at 12 fps.
- Optional Floyd-Steinberg dithering (dither=true), with two
row-buffers so the working set is O(width) per row.
- 9 unit tests: GIF89a structure, LZW clear/EOI, median-cut
(two-color + uniform), nearest-color mapping, FS dither on 1×1,
sub-block chopping, zero-dim rejection.
- Defaults: 854×480, 12 fps, infinite loop, no dithering.
2. napi binding: export_gif in compositor-view-napi/src/lib.rs,
mirroring exportMulti's shape. Same previews-paused-for-the-render
pattern (PreviewPause), same throttled_progress callback.
GifStats and GifParamsInput mirror the MP4 types. addon.d.ts
updated.
3. TS bridge: compositorViewService.exportGif (returns null when the
addon is absent — the renderer falls back to gif.js). contracts.ts
gains the compositor action: 'exportGif' with the
CompositorExportGifParams / CompositorExportGifResult types.
4. Feature flag: NATIVE_GIF_EXPORT_ENABLED = false in
src/lib/exporter/featureFlags.ts, ponytail-style comment naming the
bench as the honest signal that decides the swap.
5. Bench: --cfg GIF in crates/poc-d3d/src/bench.rs drives export_gif
end-to-end on the fixture and reports wall time, frames, fps, file
size, ms/frame, spread across --repeat runs.
rendering-performance.md gains a 'Native GIF export — initial bench'
section that records the chosen path and the rejected alternative.
## Out of scope (next slices)
- The renderer-side documentExporter still routes through GifExporter.
- frameRenderer.ts, threeDPass.ts, gifExporter.ts are NOT deleted.
- pixi.js and pixi-filters are NOT dropped from package.json.
## Test plan
- npx tsc --noEmit (passes, exit 0)
- npm run test (1144/1144 — matches baseline)
- npm run lint (0 errors, 7 pre-existing warnings)
- cargo check -p openscreen-compositor (passes, exit 0)
- cargo test -p openscreen-compositor --lib gif_export::tests (9/9 pass)
- Manual bench: see the 'Native GIF export — initial bench' section
of technical-documentation/engineering/rendering-performance.md for
the command. Requires the gitignored fixture media
(crates/fixture/{screen,webcam}.mp4 + screen.cursor.json) cut per
crates/fixture/fixture.json — the bench is a one-shot measurement,
not a CI thing.
## Native-helper build (CI gap)
CI does NOT build the compositor_view.node addon — that requires the
platform-specific ffmpeg toolchain vendored in
crates/thirdparty/ffmpeg-n8.1.2-win64-lgpl-shared. Manual smoke test
on real Windows + macOS is required per
technical-documentation/engineering/release-and-secrets.md.
EtienneLescot
force-pushed
the
ponytail/native-gif-export
branch
from
July 28, 2026 17:17
2d14d4e to
3586469
Compare
…file churn - finish() wrote 0x3B and Drop wrote it again, so every GIF ended 3B 3B. Strict decoders reject that. Guard Drop with a finished flag; tests cover both the finish() path and the bail-out path that relies on Drop. - A mid-export error left a truncated .gif at the user's destination. The MP4 path removes it (discard_partial_output); do the same here. - Cargo.lock carried ~15 unrelated transitive bumps plus a new syn 3.0.3. The feature adds no direct dependency, so restore the lockfile to base.
EtienneLescot
pushed a commit
that referenced
this pull request
Jul 28, 2026
…path Finishes the wiring #200 left open (the IPC route did not exist, so nothing could reach the native path even with the flag on) and removes the renderer-side GIF renderer it replaces. Wiring: exportGif takes the same clips + scene as exportMulti, gains the missing 'exportGif' dispatcher case, a client method, and resolveSceneAssetPaths (the GIF path never had it). ExportDialog now has one native branch for both formats — GIF differs only in output size preset, frame rate and loop count. Deleted, all consumer-less once GIF stopped using them: gifExporter, its documentExporter adapter, frameRenderer, the WGSL shaders it owned (composite/shadowCascade/evaluate), threeDPass, cropSchedule, frameExtract, timestampedVideoFrameQueue, and the NATIVE_GIF_EXPORT_ENABLED flag. gif.js and @types/gif.js drop out of package.json. This ends the second graphical SSOT: preview and export were both native already, but GIF still rasterised through a parallel WGSL implementation that had to be hand-synced with shaders.hlsl — and silently degraded when its 3D pass failed to init ('rotation fields will be ignored'), producing different pixels from the preview with no error. pixi.js stays: cursor rendering (pixiCursorRenderer, nativeCursor, CursorPreviewLayer) and zoomTransform still use it. That is the remaining half. The browser test suite went with it — its only test was the pixi GIF exporter, and the config existed to give Pixi software WebGL in headless CI. Test-file typecheck errors drop 80 -> 74; baseline lowered to match.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Slice 1 of the D3D ↔ Pixi cleanup roadmap: lands the native GIF export path end-to-end, behind the
NATIVE_GIF_EXPORT_ENABLEDfeature flag (off for this PR). The legacysrc/lib/exporter/gifExporter.ts(gif.js) stays as the default — the renderer doesn't call the native path yet. Subsequent slices will swap thedocumentExporterand delete the legacy Group A files.Path chosen
Hand-rolled pure-Rust GIF89a writer in
crates/compositor/src/gif_export.rs— no new crate deps, no swscale round-trip, no GPL pull-ins. The rejected alternatives:palettegen+paletteuse: the compositor's ffmpeg bindings areavformat/avcodec/avutil/swscale/swresampleonly — nolibavfilter(crates/compositor/Cargo.toml,crates/compositor/build.rs), sopalettegen/paletteuseare not buildable. The brief's "halt and report" case.gifmuxer / codec: it expects pre-quantizedPAL8frames and refuses to do the quantize step itself. The ffmpeg GIF path would still need a hand-rolled palette and LZW — pure Rust keeps it auditable in one file with no swscale detour.Changes
New module
crates/compositor/src/gif_export.rs(~1000 lines):openscreen_compositor::gif_export::export_gif: single-clip GIF export drivingPlayer(the same component the live preview uses) andCompositor::readback_direct, with the same cursor path therun_compositedMP4 arm uses.GifWriter): header + Graphics Control Extension + Image Descriptor + LZW (GIF's 8-bit variant with clear=256, EOI=257, LSB-first code packing) + trailer;Dropauto-flushes the trailer byte.lzw_compress): code table starts at codes 0–255 (palette) + 256 clear + 257 EOI, grows to 4096 entries, resets with a clear code at table-full.build_palette_median_cut): standard Heckbert algorithm on a colour histogram, count-weighted split at the median of the longest channel axis. Re-quantized every 30 frames; cached palette stays close to optimal within a 2.5 s window at 12 fps.dither=true), with two row-buffers so the working set is O(width) per row.napi binding:
export_gifincrates/compositor-view-napi/src/lib.rs, mirroringexportMulti's shape. SamePreviewPause+throttled_progresspattern.GifStatsandGifParamsInputmirror the MP4 types.electron/native/compositor-view/addon.d.tsupdated.TS bridge:
compositorViewService.exportGif(returnsnullwhen the addon is absent — the renderer falls back to gif.js).src/native/contracts.tsgains thecompositoraction"exportGif"withCompositorExportGifParams/CompositorExportGifResulttypes.Feature flag:
NATIVE_GIF_EXPORT_ENABLED = falseinsrc/lib/exporter/featureFlags.ts, with aponytail:-style comment naming the bench as the honest signal that decides the swap.Bench:
--cfg GIFincrates/poc-d3d/src/bench.rsdrivesexport_gifend-to-end on the fixture and reports wall time, frames, fps, file size, ms/frame, spread across--repeatruns.technical-documentation/engineering/rendering-performance.mdgains a "Native GIF export — initial bench" section that records the chosen path and the rejected alternative.Out of scope (next slices)
documentExporterstill routes throughGifExporter.frameRenderer.ts,threeDPass.ts,gifExporter.tsare not deleted.pixi.jsandpixi-filtersare not dropped frompackage.json.Test plan
Run from the worktree:
Manual bench (Windows + macOS)
The bench is a one-shot measurement, not a CI thing. The fixture media is gitignored and the bench needs the real ffmpeg-pinned toolchain. From
crates/:x.bat run --release -- --cfg GIF --repeat 3 --out out/ # optional overrides: --gif-width 1920 --gif-height 1080 --gif-fps 24 --gif-dither 1On macOS, the equivalent runs the bench binary directly (no
x.batshim).Platform-specific
compositorViewService.exportGifreturnsnull), so type-checking and unit tests cover the same code on every OS.compositor_view.node) requires the platform-specific ffmpeg toolchain vendored incrates/thirdparty/ffmpeg-n8.1.2-win64-lgpl-shared. CI does not build the addon (per thenapi-rssetup), so a real Windows + macOS smoke test is required before merge.