Commit Graph
6 Commits
Author SHA1 Message Date
steve 7dee907d44 fix(gif): stop emitting GIFs Discord can't decode; stop dropping 44% of frames (#2)
Fixes mort#1510. Verified end-to-end: the rebuilt file plays correctly in Discord where the original flashes, and is smaller (1210 KB -> 918 KB).
2026-07-25 21:10:54 +00:00
steve 613945ff36 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).
2026-07-25 17:07:07 -04:00
steve 0c05ec8c60 Merge pull request 'feat(gif): staging rails + bundled render harness' (#1) from feat/gif-staging-rails-and-harness into main 2026-07-24 23:12:20 +00:00
steve f16ac83790 fix(gif): correct the streaming threshold and three encode-path hazards
This repo has no CI and no adversarial reviewer, so these come from reading the
harness back against the production sandbox contract rather than from a bot.

The size estimate was wrong by ~1000x in the wrong direction. `width * height *
9e-5` was never MiB — for a 640x480 frame it yields 27.6, so a trivial 2-second
GIF "projected" 800+ MiB and every render would have tripped the streaming
branch and come back as an MP4. The bench missed it because it bind-mounted
/workspace from the host disk, where statvfs reports hundreds of GB and the
threshold never fires; production mounts a 64 MiB tmpfs, where it always would
have. Replaced with a named PNG_BYTES_PER_PIXEL = 0.29, measured (86 KiB per
busy 640x480 frame). Verified against the real tmpfs: a 6s piece now estimates
7.6 MiB against a 38 MiB budget and stays a GIF, while a 90s piece streams.

Three smaller ones on the streaming path:

- ffmpeg's stderr was a pipe nobody drained until the encode finished, so a
  chatty encoder could fill the 64 KiB buffer, block, stop reading stdin, and
  deadlock a long render halfway. It writes to a file now.
- _close_pipe assumed a live pipe; if ffmpeg had already died, closing stdin
  raised BrokenPipeError and buried the real exit code and log.
- A failed encode could leave a half-written /workspace/final.* behind, which
  still comes back as a files_out file_id — a broken artifact that looks
  deliverable is worse than none, so it's removed on the way out.
2026-07-24 19:05:19 -04:00
steve 7906a1ab4c feat(gif): staging rails + bundled render harness
Two changes, both driven by measurement against 128 production gifsmith runs
and a local replica of the agent loop (docs/audits/2026-07-24-gifsmith-quality.md
in mort).

Staging rules. The renders that "worked" still read badly, always the same four
ways: the subject drawn 5-10% of the frame height under an empty sky, long runs
of pixel-identical frames, captions narrating action that was never drawn, and
flat untextured shapes. The recipe never asked for anything else. SKILL.md now
states four checkable rules — fill the frame, every frame moves, show it don't
caption it, give it depth — plus easing/anticipation/squash-and-stretch, and the
self-critique makes a "no" on depiction, subject size or caption legibility a
mandatory re-render instead of a suggestion.

Render harness. 26% of production code_exec calls end in a traceback, and every
crash class lives in plumbing the agent re-derives each run: scratch dir, frame
loop, numbering, canvas size, contact sheet, encode. scripts/gifsmith.py owns all
of it and the agent writes only @anim.scene drawing functions. A scene that
raises is now skipped while the others still render and ship, the canvas is
size-locked so ffmpeg cannot fail on jittering frames, and the drawing vocabulary
is re-exported so a missing import cannot NameError.

The harness also unblocks the multi-minute pieces this skill advertises but could
never produce: /tmp is a 16 MiB tmpfs (~13s of frames) and /workspace 64 MiB
(~51s), which is why "No space left on device" is 15% of all production crashes.
Anything past the GIF length threshold is an MP4, and an MP4 encodes in one
streaming pass, so those frames now go straight into ffmpeg's stdin and never
touch disk — verified at 1350 frames / 90 s with 0 MiB on disk.

encode.py is removed; the harness supersedes it.
2026-07-24 18:49:11 -04:00
Steve DudenhoefferandClaude Opus 4.8 fae89a1ed0 feat(gif): animated GIF/MP4 skill pack for Discord
Adapts mort's builtin animate skill into an executus/skillpack:
- plans scenes + a reusable cast so recurring people/props stay consistent
  (built for funny bits 'about ourselves'), incl. multi-minute pieces
- bundled scripts/encode.py owns the fragile encode + format decision: GIF for
  short loops, H.264 MP4 (yuv420p, even dims, +faststart — plays inline in
  Discord) for long pieces OR when a GIF comes out too big, with a
  gifski→ffmpeg-palette fallback
- keeps the workspace discipline, text-readability pacing, and <=3-pass vision
  self-critique from the original; adds a frame budget for long renders

encode.py verified end-to-end (short->gif, long->mp4 confirmed h264/yuv420p/
even/faststart); SKILL.md parses cleanly via the executus skillpack loader.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-04 23:06:25 -04:00