sink: merge identical consecutive GIF frames into one longer frame #3

Merged
natsukium merged 3 commits from gif-merge into main 2026-09-26 00:55:43 +09:00
Owner

Stacked on #2 (base poc); retarget to main once #2 merges.

Why

The design gives a GIF one frame per run of identical output frames. Piping constant-rate raw video wrote every repeat, so an idle stretch cost a full GIF frame per output tick. MP4/WebM encoders already absorb repeats, so only the GIF path changes.

What

  • The GIF sink compares each frame's RGBA bytes with the previous one, keeps each distinct frame as a PNG in a temp dir with its run length, and on finish hands ffmpeg an ffconcat list (concat demuxer, palettegen/paletteuse, -fps_mode vfr).
  • Each image is read at --fps (option framerate), since the image demuxer's 25 fps default rounds start times to 40 ms. The last run ends with its frame listed once more for one output frame, because the demuxer ignores the final entry's duration.
  • Durations are derived from rounded cumulative boundaries (µs), so there is no per-frame drift.
  • ffmpeg's mpdecimate was rejected: its thresholds drop slightly-different frames, and it compares after colorspace conversion.

Verification

  • scroll sample: 101 output frames → 24 GIF frames, 3.36 s against 101/30 s (GIF centisecond resolution); synthetic runs [15,3,36] at 30 fps give pts 0/50/60 cs and 1.80 s total.
  • Unit tests for the concat list, the last-run handling, cumulative durations, and run merging in the sink.
  • pi review (luna): round 1 blocked on the 25 fps quantization and the extra final frame (fixed); round 2 passed, and its non-blocking rounding note is addressed by the second commit.
Stacked on #2 (base `poc`); retarget to `main` once #2 merges. ## Why The design gives a GIF one frame per run of identical output frames. Piping constant-rate raw video wrote every repeat, so an idle stretch cost a full GIF frame per output tick. MP4/WebM encoders already absorb repeats, so only the GIF path changes. ## What - The GIF sink compares each frame's RGBA bytes with the previous one, keeps each distinct frame as a PNG in a temp dir with its run length, and on `finish` hands ffmpeg an ffconcat list (concat demuxer, palettegen/paletteuse, `-fps_mode vfr`). - Each image is read at `--fps` (`option framerate`), since the image demuxer's 25 fps default rounds start times to 40 ms. The last run ends with its frame listed once more for one output frame, because the demuxer ignores the final entry's duration. - Durations are derived from rounded cumulative boundaries (µs), so there is no per-frame drift. - ffmpeg's `mpdecimate` was rejected: its thresholds drop slightly-different frames, and it compares after colorspace conversion. ## Verification - scroll sample: 101 output frames → 24 GIF frames, 3.36 s against 101/30 s (GIF centisecond resolution); synthetic runs [15,3,36] at 30 fps give pts 0/50/60 cs and 1.80 s total. - Unit tests for the concat list, the last-run handling, cumulative durations, and run merging in the sink. - pi review (luna): round 1 blocked on the 25 fps quantization and the extra final frame (fixed); round 2 passed, and its non-blocking rounding note is addressed by the second commit.
The design gives a GIF one frame per run of identical output frames,
with a delay covering the run; piping constant-rate raw video wrote
every repeat, so an idle stretch in a recording cost a full frame per
output tick. MP4 and WebM encoders already absorb repeats, so only the
GIF path changes.

The GIF sink keeps each distinct frame as a PNG in a temporary directory
and hands ffmpeg an ffconcat list with each frame's duration, encoded
variable-rate. Comparing the RGBA bytes ourselves keeps "identical" an
exact test; ffmpeg's mpdecimate was rejected because its thresholds
drop frames that differ slightly and it compares after a colorspace
conversion.

Assisted-by: Claude Code
ffconcat takes each duration on its own, so rounding every run to
microseconds independently would drift one rounding step per distinct
frame; deriving each from rounded cumulative boundaries keeps the error
under a microsecond however long the recording.

Assisted-by: Claude Code
The encoding section still said every format reads a raw rgba pipe; the
GIF sink now feeds ffmpeg distinct PNGs with their durations.

Assisted-by: Claude Code
natsukium changed target branch from poc to main 2026-09-26 00:53:28 +09:00
natsukium force-pushed gif-merge from bba383a81b to 8554ac866d
All checks were successful
ci / cargo fmt / clippy / test / deny (pull_request) Successful in 48s
2026-09-26 00:53:37 +09:00
Compare
natsukium deleted branch gif-merge 2026-09-26 00:55:43 +09:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
natsukium/felis-reel!3
No description provided.