fix(gif): stop emitting GIFs Discord can't decode; stop dropping 44% of frames #2

Merged
steve merged 1 commits from fix/gif-discord-local-palettes into main 2026-07-25 21:10:54 +00:00
Owner

Fixes mort#1510 (black flashing / smeared later scenes), plus a frame-drop found next to it.

The reported bug: Discord can't decode our GIFs

The file renders perfectly in PIL, in ffmpeg, and in macOS Preview. It renders wrecked in Discord — stale rows, ghosted captions, holes punched in solid shapes in the later scenes. Steve confirmed both halves of that.

The reason is structural. gifski gives every frame its own local colour table — that is where its quality comes from — and gifsicle then stacks cross-frame transparency on top. The result is a GIF that gifsicle itself refuses to reopen:

gifsicle: final.gif: warning: GIF too complex to unoptimize
  (The reason was local color tables or complex transparency.)

199 of 207 frames in the reported file carried their own palette. --colors 256 collapses them into one:

frames local palettes size
original (broken in Discord) 207 199 1210 KB
--colors 256 -O3 --lossy=60 207 0 918 KB

Verified end-to-end: the rebuilt file plays correctly in Discord where the original flashes.

Cost: ~6/255 mean colour error, since one palette now spans every scene — visible as slight banding in gradients. That is the price of playing in the client that actually matters, and the file got smaller.

This is not gifsicle's fault. gifski's raw output has the same 207/207 local tables and draws the same warning with gifsicle nowhere in the pipeline — "drop the gifsicle pass" would have fixed nothing. Worth recording, because that was my first diagnosis and it was wrong.

Found alongside: the ffmpeg fallback dropped 44% of every render

fps= was set in the filter chain, but -framerate was never passed on the input — so ffmpeg read the PNG sequence at its default 25fps and resampled down:

before:  116 of 207 frames  (44% discarded, remainder plays too fast)
after:   207 of 207

_mp4() in the same file always passed -framerate; this path never did, which is what makes it an oversight rather than a choice. With the input rate correct the fps= filter is redundant, and removing it is what recovers the last 2 frames (205 → 207).

This path is live whenever gifski is missing — and mort's image lets that happen silently, since the gifski install is wrapped in || echo "gifski unavailable" and never fails the build.

Verification

Driven against the 207 real frames of the reported render, through the patched _gif():

  • gifski path: 207 frames, 0 local palettes, 918 KB
  • ffmpeg path: 0 local palettes, total duration preserved (14.78s vs 14.79s source — gifsicle -O3 merges two identical frames and extends their delays rather than losing content)

Note

gifsmith.py reaches production only via .skillpack apply gif, so this needs that step to take effect.

Fixes mort#1510 (black flashing / smeared later scenes), plus a frame-drop found next to it. ## The reported bug: Discord can't decode our GIFs The file renders **perfectly** in PIL, in ffmpeg, and in macOS Preview. It renders **wrecked** in Discord — stale rows, ghosted captions, holes punched in solid shapes in the later scenes. Steve confirmed both halves of that. The reason is structural. gifski gives every frame its **own local colour table** — that is where its quality comes from — and gifsicle then stacks cross-frame transparency on top. The result is a GIF that **gifsicle itself refuses to reopen**: ``` gifsicle: final.gif: warning: GIF too complex to unoptimize (The reason was local color tables or complex transparency.) ``` 199 of 207 frames in the reported file carried their own palette. `--colors 256` collapses them into one: | | frames | local palettes | size | |---|---|---|---| | original (broken in Discord) | 207 | **199** | 1210 KB | | `--colors 256 -O3 --lossy=60` | 207 | **0** | **918 KB** | **Verified end-to-end:** the rebuilt file plays correctly in Discord where the original flashes. Cost: ~6/255 mean colour error, since one palette now spans every scene — visible as slight banding in gradients. That is the price of playing in the client that actually matters, and the file got *smaller*. **This is not gifsicle's fault.** gifski's raw output has the same 207/207 local tables and draws the same warning with gifsicle nowhere in the pipeline — "drop the gifsicle pass" would have fixed nothing. Worth recording, because that was my first diagnosis and it was wrong. ## Found alongside: the ffmpeg fallback dropped 44% of every render `fps=` was set in the filter chain, but `-framerate` was never passed on the **input** — so ffmpeg read the PNG sequence at its default 25fps and resampled down: ``` before: 116 of 207 frames (44% discarded, remainder plays too fast) after: 207 of 207 ``` `_mp4()` in the same file always passed `-framerate`; this path never did, which is what makes it an oversight rather than a choice. With the input rate correct the `fps=` filter is redundant, and removing it is what recovers the last 2 frames (205 → 207). This path is live whenever gifski is missing — and mort's image lets that happen **silently**, since the gifski install is wrapped in `|| echo "gifski unavailable"` and never fails the build. ## Verification Driven against the 207 real frames of the reported render, through the patched `_gif()`: - **gifski path:** 207 frames, 0 local palettes, 918 KB - **ffmpeg path:** 0 local palettes, total duration preserved (14.78s vs 14.79s source — `gifsicle -O3` merges two identical frames and extends their delays rather than losing content) ## Note `gifsmith.py` reaches production only via `.skillpack apply gif`, so this needs that step to take effect.
steve added 1 commit 2026-07-25 21:07:28 +00:00
Two bugs in the encode path, one reported (#1510), one found next to it.

DISCORD. gifski gives every frame its own LOCAL colour table — that is where
its quality comes from. gifsicle then stacks cross-frame transparency on top,
and the result is a GIF that gifsicle itself refuses to reopen: "too complex to
unoptimize — local colour tables or complex transparency". PIL, ffmpeg and
macOS Preview all render it perfectly. Discord's decoder does not: later scenes
come out with stale rows, ghosted captions and holes punched in solid shapes.
The reported file had 199 of 207 frames carrying their own palette.

`--colors 256` collapses them to one global palette. Confirmed on the reported
file: rebuilt, it plays correctly in Discord where the original flashes. It
costs ~6/255 mean colour error — one palette now spans every scene, so
gradients band slightly — and it came out SMALLER, 1210 KB -> 918 KB.

Worth stating plainly because it nearly sent me the wrong way: this is NOT
gifsicle's fault. gifski's raw output has the same 207/207 local tables and
draws the same warning with gifsicle nowhere in the pipeline. "Drop the
gifsicle pass" would have fixed nothing.

FRAME DROP. The ffmpeg fallback set fps= in the filter chain but never passed
-framerate on the INPUT, so ffmpeg read the PNG sequence at its default 25fps
and resampled down — silently discarding 91 of 207 frames (44%) and playing the
rest too fast. _mp4() always passed -framerate; this path never did. With the
input rate correct the fps= filter is redundant, and dropping it is what takes
the output from 205 frames back to all 207. This path runs whenever gifski is
missing, which the mort image allows to happen silently (its install is wrapped
in `|| echo "gifski unavailable"`).

Verified by driving the patched _gif() against the 207 real frames of the
reported render: gifski path 207 frames / 0 local palettes / 918 KB; ffmpeg
path 0 local palettes with total duration preserved (14.78s vs 14.79s source —
gifsicle merges two identical frames and extends their delays).
steve merged commit 7dee907d44 into main 2026-07-25 21:10:54 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: steve/executus-skills#2