abf8a514ba859e2b8520c68cfafb3f4e73e50bb3
6
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
abf8a514ba | Merge pull request 'fix(gif): stop making agents discover the API by failing (closes mort#1522)' (#4) from fix/1522-gifsmith-api-discoverable into main | ||
|
|
9eb3ec49dd |
fix(gif): stop making agents discover the API by failing
Closes mort#1522. 23% of all code_exec calls across two production runs (9 of 39) failed on gifsmith API misuse, hitting 7 of 14 workers. The recovery was always the same shape: another round trip spent printing gifsmith's own source — or, in one case, running `dir(gifsmith)` — to learn what the module contains. Three distinct causes, three fixes. **Unguessable re-exports → `__all__`, plus the two that were missing.** The module exported SOME matplotlib artists and not others, and the only way to find out which was to fail: two workers wrote `from gifsmith import PathPatch` and got an ImportError while `FancyBboxPatch` beside it worked. `PathPatch` and `Path` are now exported, on the rule "the drawing vocabulary, whole". `__all__` states the surface — and it also fixes what made self-service expensive: `dir(gifsmith)` returned 24 names of which nine were incidental imports (glob, os, shutil, subprocess, sys, traceback...), several being things a scene author should never touch. SKILL.md now carries the same list, so the question should not arise. **Near-miss constructor kwargs → an error that names the miss.** `background=` is accepted as an alias for `bg` (it is the obvious synonym, and an agent reached for it). `force=` is rejected with the reason it was tempting — `render(force="mp4")` really does take it, which makes it read like an Animation-level setting. Anything else gets a difflib near-match and the valid list. Both failures were one round trip each, and both are the kind a message fixes for free. **Scenes fail at render time → dry-run every scene at t=0 first.** A NameError in scene 4 used to surface only after scenes 0-3 were fully rendered, and with a streaming pipe open, after frames had gone into the encoder. Two workers lost a whole render each to `RuntimeError: every scene failed`, learning one broken name per attempt. One frame per scene now reports EVERY broken scene in a single pass, before any scratch dir or ffmpeg pipe exists. Per-scene isolation is preserved exactly: a scene that fails the dry run is marked and skipped rather than re-run to fail identically, the others still ship, and only an all-scenes-broken program raises early — which is precisely the case that was paying for a full setup to learn nothing. Verified against the production failures: `Animation(background=…)` now works, `Animation(force=…)` explains itself, `Animation(backgrnd=…)` suggests `background`, every name in `__all__` resolves, no incidental import leaks into it, and the dry run marks exactly the broken scenes while leaving the good ones untouched. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> |
||
|
|
c1b2d41cc5 |
fix(gif): make the contact sheet an actual contact sheet
Closes mort#1519.
`_contact_sheet` built one tile per SCENE, and a single-scene animation — the
common case — short-circuited to `tiles[0][1].first_frame.save(path)`, a bare
frame. The harness then printed "still: … (contact sheet)" next to it.
Every single-scene worker across two production runs corrected that claim
unprompted, in the vision model's own words: "this is a single frame (not a
contact sheet)", "there is exactly 1 distinct panel/tile", "a single static
illustration, not an animation contact sheet" — ten of them. One worked out why
its critic could not see the brine tear it had animated, then proved it with a
pixel count, having already spent three full re-renders and 690 seconds on it.
It was the last of nine parallel workers to finish, holding the whole fan-out
open.
The sheet is now sampled across TIME: nine tiles by default, spread over the
whole animation, each labelled with its `t`. One scene gets all nine time
points; many scenes still get one tile each, as before, so nothing regresses
for the multi-scene case that already worked.
Two details that matter more than they look:
**Never sample t=1.0.** A loop is built to return to its starting state, so its
closing frame is the one instant guaranteed to show none of the motion. That is
exactly the frame the tear had faded out of.
**Label every tile with its t.** Without it the critic cannot tell "the tear is
missing" from "the tear is missing at t=0.75", and only the second is
actionable. The SKILL.md critique step now says so directly: check the other
tiles before re-rendering.
Reconciling the discrepancy the issue raised: the body said the still is the
t≈1.0 frame (backed by a pixel count on a live render), the code reads
`first_frame`. At HEAD it is NEITHER — `if sc.first_frame is None and
k >= n // 2` captures the MIDDLE frame, and that line arrived with the harness
in
|
||
|
|
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). |
||
|
|
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. |
||
|
|
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. |