Files
executus-skills/gif/scripts/gifsmith.py
T
steveandClaude Opus 5 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 7906a1a. The t≈1.0 observation therefore came from an older deployed pack.
It does not change the fix: one time point is one time point, whichever one it
is, and the single-scene short-circuit was the real defect.

Also: a failed scene now drops its samples along with its partial frames, so
the sheet can't advertise a scene that never shipped. Thumbnails are downscaled
at capture rather than held full-size — nine 640x480 RGB copies is ~8 MiB held
for the whole render, and the sheet shrinks them anyway.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-26 12:09:28 -04:00

507 lines
23 KiB
Python

"""
gifsmith.py — the render harness for the `gif` skill.
You write ONLY the drawing. This module owns every part that has historically
broken renders: the scratch directory and its disk budget, frame naming and the
continuous counter, a LOCKED canvas size (frames that differ by one pixel break
the encoder), per-scene crash isolation, the GIF-vs-MP4 decision, and the
critique contact sheet.
from gifsmith import Animation
anim = Animation(width=640, height=480, fps=15)
# --- cast: define each recurring character ONCE ---
def draw_steve(ax, x, y, angry=0.0):
...
@anim.scene("Steve watches the printer", seconds=2.5)
def s0(ax, t): # t goes 0.0 -> 1.0 across the scene
draw_steve(ax, 2, 1, angry=t)
art = anim.render() # -> {"path": "/workspace/final.gif", "mime": ..., "bytes": ...}
`ax` is a fresh matplotlib Axes at exactly (width, height) pixels, x in [0,100],
y in [0, 100*height/width], origin bottom-left, no margins. Draw in those units
and every frame lines up.
PIL instead of matplotlib:
@anim.scene("title card", seconds=1.5, mode="pil")
def s1(img, draw, t): # img: RGB Image at the locked size; draw: ImageDraw
draw.text((40, 40), "THE END", fill=(255, 255, 255), font=anim.font(48))
render() prints a report and returns the artifact dict. If one scene raises, the
OTHER scenes still render and ship — the report names the broken one so you can
fix just that scene. It also writes /workspace/still.png: a labelled contact
sheet, one tile per scene, for the critique pass.
"""
import glob
import math
import os
import shutil
import subprocess
import sys
import traceback
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from PIL import Image, ImageDraw, ImageFont # noqa: E402
# Re-exported so one import line covers the whole drawing vocabulary — a missing
# `from matplotlib.patches import ...` is otherwise a NameError that costs a pass.
from matplotlib.patches import ( # noqa: E402,F401
Arc, Circle, Ellipse, FancyBboxPatch, Polygon, Rectangle, Wedge)
import numpy as np # noqa: E402,F401
# A GIF longer than this balloons past what's worth shipping as a GIF.
GIF_MAX_SECONDS = 20
# Keep a GIF comfortably under Discord's ~10 MiB inline limit.
GIF_MAX_BYTES = 9 * 1024 * 1024
# Measured on busy 640x480 matplotlib frames in this sandbox: ~86 KiB per PNG at
# compress_level=1, i.e. 88064 / 307200 px. This decides whether a render streams
# instead of staging frames on disk, so it must not read low — erring low
# reintroduces the ENOSPC this harness exists to prevent, and erring high only
# turns a borderline short piece into an MP4.
PNG_BYTES_PER_PIXEL = 0.29
# Contact-sheet budget. Nine tiles is a 3x3 grid that vision models read
# reliably; spread across TIME for one scene, across scenes for many.
CONTACT_SHEET_MAX_TILES = 9
CONTACT_TILE_W = 300
_FONT_CANDIDATES = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]
def _which(prog):
return shutil.which(prog) is not None
def _free_mb(path):
try:
st = os.statvfs(path)
return st.f_bavail * st.f_frsize / 1024 / 1024
except OSError:
return 0.0
def _pick_scratch():
"""Largest writable scratch dir. /tmp is a small tmpfs in this sandbox; a
SUBDIR of /workspace is bigger and is not returned in files_out (only
TOP-LEVEL /workspace files are), so it is safe to fill with frames."""
best, best_free = None, -1.0
for base in ("/workspace/.frames", "/tmp/frames", "/var/tmp/frames"):
parent = os.path.dirname(base)
if not os.path.isdir(parent) or not os.access(parent, os.W_OK):
continue
free = _free_mb(parent)
if free > best_free:
best, best_free = base, free
if best is None:
raise RuntimeError("no writable scratch directory found")
shutil.rmtree(best, ignore_errors=True)
os.makedirs(best, exist_ok=True)
return best, best_free
class _Scene:
def __init__(self, fn, title, seconds, mode, hold):
self.fn, self.title, self.seconds = fn, title, float(seconds),
self.mode, self.hold = mode, float(hold)
self.frames = 0
self.error = None
# (t, thumbnail) pairs for the contact sheet. Thumbnails, not full
# frames: nine 640x480 RGB copies is ~8 MiB of RAM held for the whole
# render, and the sheet downscales them anyway.
self.samples = []
class Animation:
"""Collects scenes, renders them to a locked-size frame sequence, encodes."""
def __init__(self, width=640, height=480, fps=15, dpi=100, bg="white"):
# H.264 needs even dimensions; lock them here so the encoder never fails.
self.width = int(width) // 2 * 2
self.height = int(height) // 2 * 2
self.fps = int(fps)
self.dpi = int(dpi)
self.bg = bg
self.xmax = 100.0
self.ymax = 100.0 * self.height / self.width
self._scenes = []
self._count = 0
self._scratch = None
self._peak_mb = 0.0
self._streaming = False
self._pipe = None
# ---------------------------------------------------------------- authoring
def scene(self, title, seconds=2.0, mode="mpl", hold=0.0):
"""Decorator: register a scene. `hold` freezes the last frame for that
many extra seconds — use it to let a caption stay readable."""
def deco(fn):
self._scenes.append(_Scene(fn, title, seconds, mode, hold))
return fn
return deco
def font(self, size):
for p in _FONT_CANDIDATES:
if os.path.exists(p):
return ImageFont.truetype(p, size)
return ImageFont.load_default()
def text(self, ax, s, size=18, y=None, color="white", box="black"):
"""Caption helper: centred, boxed, legible at GIF resolution."""
ax.text(self.xmax / 2, self.ymax * 0.06 if y is None else y, s,
ha="center", va="center", fontsize=size, color=color, zorder=999,
bbox=dict(boxstyle="round,pad=0.4", fc=box, ec="none", alpha=0.75))
def add_frame(self, img, times=1):
"""Escape hatch for hand-built frames (PIL compositing, pasted photos,
anything the scene decorators can't express). Still size-locked."""
for _ in range(max(1, int(times))):
self._emit(img)
# ------------------------------------------------------------------ drawing
def _new_axes(self):
fig = plt.figure(figsize=(self.width / self.dpi, self.height / self.dpi),
dpi=self.dpi)
ax = fig.add_axes([0, 0, 1, 1])
ax.set_xlim(0, self.xmax)
ax.set_ylim(0, self.ymax)
ax.set_axis_off()
ax.set_facecolor(self.bg)
fig.patch.set_facecolor(self.bg)
return fig, ax
def _emit(self, img):
"""Emit one frame at exactly the locked size. Never bbox_inches='tight'.
Short pieces go to PNG files (gifski wants a file list). Long pieces are
piped straight into ffmpeg so a multi-minute render never touches disk —
the sandbox scratch would otherwise cap you at well under a minute."""
if img.size != (self.width, self.height):
img = img.resize((self.width, self.height), Image.LANCZOS)
img = img.convert("RGB")
if self._pipe is not None:
img.save(self._pipe.stdin, format="PNG", compress_level=1)
else:
img.save(os.path.join(self._scratch, f"frame_{self._count:05d}.png"),
compress_level=1)
self._count += 1
def _render_mpl(self, sc, t):
fig, ax = self._new_axes()
try:
sc.fn(ax, t)
fig.canvas.draw()
img = Image.frombuffer(
"RGBA", fig.canvas.get_width_height(),
fig.canvas.buffer_rgba(), "raw", "RGBA", 0, 1).convert("RGB")
finally:
plt.close(fig)
return img
def _render_pil(self, sc, t):
img = Image.new("RGB", (self.width, self.height),
self.bg if isinstance(self.bg, (str, tuple)) else "white")
sc.fn(img, ImageDraw.Draw(img), t)
return img
# ------------------------------------------------------------------- render
def render(self, long_edge=None, force=None, audio=None):
if not self._scenes:
raise RuntimeError("no scenes registered — decorate at least one "
"function with @anim.scene(...)")
self._scratch, free_mb = _pick_scratch()
long_edge = long_edge or max(self.width, self.height)
total_s = sum(s.seconds + s.hold for s in self._scenes)
est_frames = int(total_s * self.fps)
est_mb = est_frames * self.width * self.height * PNG_BYTES_PER_PIXEL / 1048576
# A piece too long for a GIF is an MP4 anyway, and an MP4 can be encoded
# in one streaming pass — so pipe the frames into ffmpeg and keep NOTHING
# on disk. That is what makes multi-minute pieces possible at all: the
# sandbox scratch tops out around a minute of busy frames.
self._streaming = (force == "mp4" or audio is not None
or (force != "gif" and (total_s > GIF_MAX_SECONDS
or est_mb > free_mb * 0.6)))
self._pipe = None
if self._streaming:
self._pipe = self._open_pipe("/workspace/final.mp4", long_edge, audio)
print(f"[gifsmith] streaming to MP4 (projected {est_mb:.0f} MiB of "
f"frames vs {free_mb:.0f} MiB scratch) — nothing hits disk",
flush=True)
elif est_mb > free_mb * 0.6:
print(f"[gifsmith] WARNING projected frames ~{est_mb:.0f} MiB vs "
f"{free_mb:.0f} MiB free in {os.path.dirname(self._scratch)} — "
f"dropping fps or canvas would be safer", file=sys.stderr)
print(f"[gifsmith] {len(self._scenes)} scene(s), {total_s:.1f}s at "
f"{self.fps}fps, canvas {self.width}x{self.height}", flush=True)
# Tile budget for the contact sheet, spread across the scenes. One scene
# gets the whole budget in TIME, which is the case that was broken: a
# loop's interesting content is by construction not at any single
# instant.
per_scene = max(1, CONTACT_SHEET_MAX_TILES // len(self._scenes))
for i, sc in enumerate(self._scenes):
start = self._count
n = max(1, int(round(sc.seconds * self.fps)))
try:
want = self._sample_frames(n, per_scene)
for k in range(n):
t = k / max(n - 1, 1)
img = (self._render_pil if sc.mode == "pil" else self._render_mpl)(sc, t)
if k in want:
sc.samples.append((t, img.resize(
(CONTACT_TILE_W, self._tile_h()), Image.LANCZOS)))
self._emit(img)
for _ in range(int(round(sc.hold * self.fps))):
self._emit(img)
except Exception: # noqa: BLE001
sc.error = traceback.format_exc(limit=6)
# Its partial frames are dropped below; its samples must go too,
# or the sheet advertises a scene that never shipped.
sc.samples = []
if not self._streaming:
# Drop this scene's partial frames so it can't ship half-drawn.
# (Streamed frames are already in the encoder — a broken scene
# leaves its partial beat in the MP4; the report says so.)
for p in sorted(glob.glob(
os.path.join(self._scratch, "frame_*.png")))[start:]:
os.remove(p)
self._count = start
print(f"[gifsmith] SCENE {i} ({sc.title!r}) FAILED — skipped, "
f"other scenes continue:\n{sc.error}", file=sys.stderr, flush=True)
continue
sc.frames = self._count - start
print(f"[gifsmith] scene {i}: {sc.frames} frames {sc.title!r}", flush=True)
self._renumber_if_needed()
used = sum(os.path.getsize(p) for p in
glob.glob(os.path.join(self._scratch, "frame_*.png")))
self._peak_mb = used / 1024 / 1024
if self._count == 0:
raise RuntimeError(
"every scene failed — see the per-scene tracebacks above; "
"nothing was encoded. Fix the error they all share (usually one "
"cast helper called with a keyword it does not take, or a name "
"that differs by a character) and re-run the whole program in a "
"fresh code_exec call, remembering gifsmith.py in files_in.")
try:
art = self._encode(total_s, long_edge, force, audio)
except Exception: # noqa: BLE001
# A half-written final.* at the top of /workspace still comes back as
# a files_out file_id, and a broken artifact that LOOKS deliverable is
# worse than none — the agent would ship it. Take it with us.
for leftover in ("/workspace/final.mp4", "/workspace/final.gif"):
if os.path.exists(leftover):
os.remove(leftover)
shutil.rmtree(self._scratch, ignore_errors=True)
raise
sheet_tiles = self._contact_sheet() or 0
broken = [f"{i}:{s.title!r}" for i, s in enumerate(self._scenes) if s.error]
print(f"[gifsmith] {self._count} frames, scratch "
+ ("streamed (0 MiB on disk)" if self._streaming
else f"peak {self._peak_mb:.1f} MiB"))
print(f"[gifsmith] scene errors: {', '.join(broken) if broken else 'none'}")
print(f"[gifsmith] ART {art}")
ts = ", ".join(f"{t:.2f}" for sc in self._scenes for (t, _th) in sc.samples)
print(f"[gifsmith] still: /workspace/still.png — {sheet_tiles} frames "
f"sampled across the animation at t={ts} (each tile is labelled "
"with its t). Call image_describe on ITS file_id from files_out; "
"if something is missing from one tile, check whether it is "
"present in another before re-rendering.")
shutil.rmtree(self._scratch, ignore_errors=True)
return art
def _renumber_if_needed(self):
"""A skipped scene leaves no gaps because _count rewinds; kept as a
no-op hook so the counter contract is explicit."""
return
# ------------------------------------------------------------------ encoding
def _open_pipe(self, out, long_edge, audio):
"""ffmpeg reading a PNG stream on stdin — the streaming MP4 path.
stderr goes to a FILE, not a pipe: nobody drains a stderr pipe until the
encode is done, so a chatty ffmpeg would fill the 64 KiB pipe buffer,
block, stop reading stdin, and deadlock the render halfway through."""
cmd = ["ffmpeg", "-y", "-loglevel", "error",
"-f", "image2pipe", "-framerate", str(self.fps), "-i", "-"]
if audio:
cmd += ["-i", audio, "-c:a", "aac", "-b:a", "128k", "-shortest"]
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "23",
"-vf", f"scale='min({long_edge},iw)':-2:flags=lanczos",
"-movflags", "+faststart", out]
self._pipe_err = open(os.path.join(self._scratch, "ffmpeg.log"), "w+b")
return subprocess.Popen(cmd, stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL, stderr=self._pipe_err)
def _close_pipe(self):
try:
self._pipe.stdin.close()
except OSError:
# ffmpeg already died; its exit code + log below say why.
pass
rc = self._pipe.wait()
self._pipe_err.seek(0)
err = self._pipe_err.read().decode(errors="replace")
self._pipe_err.close()
self._pipe = None
if rc != 0:
raise RuntimeError(f"ffmpeg (streaming) exited {rc}:\n{err[-2000:]}")
def _encode(self, seconds, long_edge, force, audio=None):
gif, mp4 = "/workspace/final.gif", "/workspace/final.mp4"
if self._streaming:
self._close_pipe()
return _art(mp4, "video/mp4")
fs = sorted(glob.glob(os.path.join(self._scratch, "frame_*.png")))
want_mp4 = (force == "mp4" or audio is not None
or (force != "gif" and seconds > GIF_MAX_SECONDS))
if want_mp4:
self._mp4(mp4, long_edge, audio)
return _art(mp4, "video/mp4")
self._gif(fs, gif, long_edge)
if os.path.getsize(gif) <= GIF_MAX_BYTES:
return _art(gif, "image/gif")
print(f"[gifsmith] GIF {os.path.getsize(gif)/1048576:.1f} MiB is too big "
f"for Discord — re-encoding as MP4", flush=True)
self._mp4(mp4, long_edge, audio)
os.remove(gif)
return _art(mp4, "video/mp4")
def _gif(self, fs, out, long_edge):
if _which("gifski"):
_run(["gifski", "-o", out, "--fps", str(self.fps), "--quality", "90",
"--width", str(min(long_edge, self.width))] + fs)
else:
# -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",
"-framerate", str(self.fps), "-pattern_type", "glob",
"-i", os.path.join(self._scratch, "frame_*.png"), "-vf", vf, out])
if _which("gifsicle"):
# --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),
"-pattern_type", "glob",
"-i", os.path.join(self._scratch, "frame_*.png")]
if audio:
cmd += ["-i", audio, "-c:a", "aac", "-b:a", "128k", "-shortest"]
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "23",
"-vf", f"scale='min({long_edge},iw)':-2:flags=lanczos",
"-movflags", "+faststart", out]
_run(cmd)
# ------------------------------------------------------------- critique aid
def _tile_h(self):
return max(1, round(self.height * CONTACT_TILE_W / self.width))
@staticmethod
def _sample_frames(n, count):
"""Frame indices to keep for the contact sheet, spread evenly over the
scene: t = 0, 1/count, ... (count-1)/count.
A scene with very few frames yields fewer samples than asked for, since
distinct t values collide onto the same frame index; the sheet simply
has fewer tiles, which is the honest outcome.
Deliberately NOT ending at t=1.0. A loop is built to return to its
starting state, so its last frame is the one instant guaranteed to show
none of the motion — which is exactly how a worker spent three
re-renders chasing a tear its critic could not see, because the still
was the frame after the tear had faded."""
if n <= 1:
return {0}
count = max(1, min(count, n))
return {min(n - 1, round(j / count * (n - 1))) for j in range(count)}
def _contact_sheet(self, path="/workspace/still.png"):
"""A grid sampled across TIME, one labelled tile per sample.
It used to be one frame per SCENE, and a single-scene animation — the
common case — short-circuited to a bare frame save. Every single-scene
worker in two production runs told us so unprompted, in the words of
the vision model: "this is a single frame (not a contact sheet)". The
critique loop was being fed one time point out of ~84 and asked to
judge motion.
"""
tiles = [(i, sc, t, thumb)
for i, sc in enumerate(self._scenes)
for (t, thumb) in sc.samples]
if not tiles:
return
cols = min(3, len(tiles))
rows = math.ceil(len(tiles) / cols)
tw, th = CONTACT_TILE_W, self._tile_h()
sheet = Image.new("RGB", (cols * tw, rows * (th + 20)), (20, 20, 24))
d = ImageDraw.Draw(sheet)
f = self.font(14)
multi = len(self._scenes) > 1
for k, (i, sc, t, thumb) in enumerate(tiles):
x, y = (k % cols) * tw, (k // cols) * (th + 20)
sheet.paste(thumb, (x, y + 20))
# The t label is the point: it tells the critic WHEN it is looking,
# so "the tear is missing" can be read as "missing at t=0.75"
# rather than as a fault in the drawing.
label = f"t={t:.2f}"
if multi:
label = f"scene {i} {label}: {sc.title[:26]}"
elif sc.title:
label = f"{label} {sc.title[:30]}"
d.text((x + 4, y + 3), label, fill=(235, 235, 240), font=f)
sheet.save(path)
return len(tiles)
def _run(cmd):
p = subprocess.run(cmd, capture_output=True, text=True)
if p.returncode != 0:
raise RuntimeError(f"{cmd[0]} failed:\n{p.stderr[-2000:]}")
def _art(path, mime):
b = os.path.getsize(path)
print(f"[gifsmith] encoded {path} {mime} {b / 1024 / 1024:.2f} MiB")
return {"path": path, "mime": mime, "bytes": b}