refactor(export): drive GIF from the video exporter's frame walk - #202
Merged
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 |
EtienneLescot
marked this pull request as ready for review
July 29, 2026 09:36
GIF had its own loop over the LIVE PREVIEW Player, stepping one SOURCE frame
per OUTPUT frame. Consequences, both proven by the new test:
- a 4 s / 60 fps source at 12 fps produced 48 frames taken from the first
0.8 s — right frame count, 5x slow motion, 80% of the recording missing;
- it could not decode files run_composited_multi handles (Player::open ->
'frame sans texture D3D11'), so it was not merely mistimed.
Extract the format-agnostic half of run_multi_inner as walk_composited_timeline
(clip iteration, decoder reuse, availability clamping, scene windowing,
keyframe seeks, cursor binding, speed segments, output-time advancement) and
drive both exporters through it. MP4 keeps its encoder/mux/audio; GIF supplies
readback + quantize. 'Which source frame belongs at output frame N' now has
exactly one definition.
Also fixes the dither, which was a measurable no-op: it diffused error against
round() of an already-integer channel, so err stayed 0 for every pixel by
induction. Fused into the index mapping, where the error against the CHOSEN
palette entry actually exists.
tests/export_timing.rs pins both: MP4 frame count vs output fps, and a GIF
whose per-frame palettes must span a source that changes colour every second —
the assertion frame count alone cannot make. MP4 verified unchanged (120
frames) before and after the extraction.
…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.
Correcting my own earlier claim that cursor and zoom still needed Pixi. They do
not: the native compositor owns both. NativeCompositorOverlay hands the native
view a cursorPath, buildSceneDescription sends cursor {show,size,smoothing} into
the scene, crates/compositor/src/cursor.rs renders it, and regions.rs owns zoom
scale/focus/rotation. D3D compositing is the pixel SSOT, exactly as designed.
What was left was the same situation as the Pixi screen compositor before it was
removed — code that outlived its mount:
- CursorPreviewLayer was rendered by nothing but its own test, and it was the
only path to pixiCursorRenderer and nativeCursor (all 15 exports of which
had zero consumers outside that cluster);
- zoomTransform's Pixi half (applyZoomTransform, createMotionBlurState,
computeFocusFromTransform) had zero consumers. Only computeZoomTransform is
live, and it is arithmetic — it just happened to share a module with the
Pixi code, which is what kept pixi.js in the bundle at all. 249 lines -> 56.
pixi.js and pixi-filters drop out of package.json, the lockfile and the vite
manual-chunk config; the 484 kB pixi chunk is gone from the build. Test-file
typecheck errors 74 -> 71, baseline lowered to match.
EtienneLescot
force-pushed
the
feat/gif-on-shared-pipeline
branch
from
July 29, 2026 09:49
911a491 to
cc5af1d
Compare
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.
Stacked on #200. Base is `ponytail/native-gif-export`, so this diff is only the unification.
Why
#200 landed native GIF as a third rendering path: it drove the live preview `Player` with its own loop, separate from `pipeline::run_composited_multi`. Two consequences, both now proven by tests:
The bug was a symptom of not sharing, so patching the timing in place would have left the duplication that caused it.
What
Extract the format-agnostic half of `run_multi_inner` as `walk_composited_timeline`: clip iteration, decoder reuse, availability clamping, per-clip scene windowing, keyframe seeks, cursor binding, speed segments, and output-time decoder advancement. Both exporters drive it; only the per-frame sink differs (hardware NV12 encode vs CPU readback + palette quantize). MP4 keeps its encoder, mux and audio.
GIF also becomes multiclip — the old path was single-file by construction.
Also fixes the dither, which was a measurable no-op: it diffused error against `round()` of an already-integer channel, so the error was 0 for every pixel by induction. Now fused into the index mapping, against the chosen palette entry.
Verification
`crates/compositor/tests/export_timing.rs`, opt-in via `OPENSCREEN_TEST_MEDIA` (no CI builds this crate — see below).
Not in this PR
Risk
Touches the primary export path. Mitigated by the before/after MP4 assertion, but worth noting no workflow builds the Rust workspace, so none of this is covered by CI.