From 613945ff36535a171e8291700d7c666d8a009a92 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 25 Jul 2026 17:07:07 -0400 Subject: [PATCH] fix(gif): stop emitting GIFs Discord can't decode; stop dropping 44% of frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- gif/scripts/gifsmith.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/gif/scripts/gifsmith.py b/gif/scripts/gifsmith.py index a7ecc10..b64fd0a 100644 --- a/gif/scripts/gifsmith.py +++ b/gif/scripts/gifsmith.py @@ -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),