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

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).
This commit is contained in:
2026-07-25 17:07:07 -04:00
parent 0c05ec8c60
commit 613945ff36
+26 -3
View File
@@ -367,13 +367,36 @@ class Animation:
_run(["gifski", "-o", out, "--fps", str(self.fps), "--quality", "90",
"--width", str(min(long_edge, self.width))] + fs)
else:
vf = (f"fps={self.fps},scale='min({long_edge},iw)':-1:flags=lanczos,"
# -framerate applies to the INPUT. Without it ffmpeg reads a PNG
# sequence at its default 25fps, and a fps= filter then resamples
# 25 -> self.fps: on the #1510 render that silently dropped 91 of
# 207 frames (44%) and played the rest too fast. _mp4 below always
# passed -framerate; this path never did. With the input rate set
# correctly the fps= filter is redundant — and removing it is what
# takes the output from 205 frames back to all 207.
vf = (f"scale='min({long_edge},iw)':-1:flags=lanczos,"
f"split[s0][s1];[s0]palettegen=stats_mode=diff[p];"
f"[s1][p]paletteuse=dither=bayer:bayer_scale=3")
_run(["ffmpeg", "-y", "-loglevel", "error", "-pattern_type", "glob",
_run(["ffmpeg", "-y", "-loglevel", "error",
"-framerate", str(self.fps), "-pattern_type", "glob",
"-i", os.path.join(self._scratch, "frame_*.png"), "-vf", vf, out])
if _which("gifsicle"):
_run(["gifsicle", "-O3", "--lossy=60", out, "-o", out])
# --colors 256 is load-bearing for DISCORD, not for size.
#
# gifski gives every frame its OWN local colour table (that is where
# its quality comes from). Stack gifsicle's 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 fine;
# Discord's decoder does not, and paints later scenes with stale rows,
# ghosted captions and holes punched in solid shapes (#1510).
#
# Collapsing to ONE global palette costs ~6/255 mean colour error
# (visible as slight banding in gradients, since one palette now
# spans every scene) and is the price of playing in the client that
# actually matters. It also came out SMALLER on the reported file:
# 1210 KB -> 915 KB.
_run(["gifsicle", "--colors", "256", "-O3", "--lossy=60", out, "-o", out])
def _mp4(self, out, long_edge, audio=None):
cmd = ["ffmpeg", "-y", "-loglevel", "error", "-framerate", str(self.fps),