Skip to content

feat(export): add native GIF export pipeline (slice 1, behind flag) - #200

Open
EtienneLescot wants to merge 2 commits into
release/v1.8.0from
ponytail/native-gif-export
Open

feat(export): add native GIF export pipeline (slice 1, behind flag)#200
EtienneLescot wants to merge 2 commits into
release/v1.8.0from
ponytail/native-gif-export

Conversation

@EtienneLescot

@EtienneLescot EtienneLescot commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

What

Slice 1 of the D3D ↔ Pixi cleanup roadmap: lands the native GIF export path end-to-end, behind the NATIVE_GIF_EXPORT_ENABLED feature flag (off for this PR). The legacy src/lib/exporter/gifExporter.ts (gif.js) stays as the default — the renderer doesn't call the native path yet. Subsequent slices will swap the documentExporter and 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:

  • ffmpeg palettegen + paletteuse: the compositor's ffmpeg bindings are avformat / avcodec / avutil / swscale / swresample only — no libavfilter (crates/compositor/Cargo.toml, crates/compositor/build.rs), so palettegen / paletteuse are not buildable. The brief's "halt and report" case.
  • 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.

Changes

  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 crates/compositor-view-napi/src/lib.rs, mirroring exportMulti's shape. Same PreviewPause + throttled_progress pattern. GifStats and GifParamsInput mirror the MP4 types. electron/native/compositor-view/addon.d.ts updated.

  3. TS bridge: compositorViewService.exportGif (returns null when the addon is absent — the renderer falls back to gif.js). src/native/contracts.ts gains the compositor action "exportGif" with CompositorExportGifParams / CompositorExportGifResult types.

  4. Feature flag: NATIVE_GIF_EXPORT_ENABLED = false in src/lib/exporter/featureFlags.ts, with a 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. technical-documentation/engineering/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

Run from the worktree:

# TypeScript
npx tsc --noEmit          # exit 0
npm run test              # 1144/1144 — matches baseline
npm run lint              # 0 errors, 7 pre-existing warnings (none new)

# Rust (worktree/.cargo/config.toml pins FFMPEG_DIR)
cargo check -p openscreen-compositor                                    # exit 0
cargo test  -p openscreen-compositor --lib gif_export::tests             # 9/9 pass
cargo check -p compositor-view-napi                                     # exit 0
cargo check -p poc-d3d                                                  # exit 0

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 1

On macOS, the equivalent runs the bench binary directly (no x.bat shim).

Platform-specific

  • Not platform-specific for the TS/contract changes — the renderer falls back to gif.js whenever the native addon is absent (compositorViewService.exportGif returns null), so type-checking and unit tests cover the same code on every OS.
  • Platform-specific for the native part: the napi-rs addon (compositor_view.node) requires the platform-specific ffmpeg toolchain vendored in crates/thirdparty/ffmpeg-n8.1.2-win64-lgpl-shared. CI does not build the addon (per the napi-rs setup), so a real Windows + macOS smoke test is required before merge.

@coderabbitai

coderabbitai Bot commented Jul 28, 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2684f3be-9ffa-4cd4-9a4b-3b1049186d8f

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
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ponytail/native-gif-export

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.

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
EtienneLescot force-pushed the ponytail/native-gif-export branch from 2d14d4e to 3586469 Compare July 28, 2026 17:17
…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.
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.

2 participants