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]>
11 KiB
name, description, license, allowed-tools, metadata
| name | description | license | allowed-tools | metadata | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| gif | Make a funny animated GIF (or MP4) for Discord from a description — plans scenes, keeps recurring people/props consistent via a reusable cast, renders frames in Python through a bundled harness that owns the scratch/encode plumbing, and automatically hands Discord an H.264 MP4 it can play when the piece is long or a GIF comes out too big. Use whenever someone asks to draw/animate/gif something, including multi-minute bits about people or things that happened. | MIT |
|
|
Make an animated GIF / MP4 for Discord
You make HIGH-QUALITY, funny animations from a description — often caricatures of the people in the channel or a bit about something that just happened. Work by calling tools — do not introduce yourself or list capabilities.
This skill bundles scripts/gifsmith.py, a render harness that owns everything
that used to break these renders: the scratch directory and its disk budget,
frame numbering, a locked canvas size, per-scene crash isolation, the GIF-vs-MP4
decision, and the critique contact sheet. You write only the drawing.
1. Plan first: length, scenes, and cast
Decide before drawing (use think for anything non-trivial):
- How many scenes? Count the distinct beats the prompt actually describes and render exactly that many. ASSUME ONE. A single continuous action ("a bouncing ball", "Steve slipping on a banana") is ONE scene — don't chop it into cuts. Start a new scene only when the prompt moves to a new beat (new action, place, or time jump). There is no upper limit — a bit that genuinely describes twelve beats gets twelve scenes and runs for minutes — but never pad a simple request with invented beats, and never compress beats the user clearly separated. When in doubt, fewer scenes.
- Who is the cast? Every recurring character and prop. For a bit "about
", pick a simple recognizable caricature — one or two signature
features (glasses, hair colour, a hat) — and pin each cast member's look ONCE
as a
draw_<name>(ax, ...)helper. Every scene CALLS that helper; never re-describe a character inline, or they drift scene to scene.
2. STAGING — this is what separates a good GIF from a bad one
The harness guarantees a working animation. What makes it good is staging, and that is where these renders most often fail. Four rules, all checked in §5:
- FILL THE FRAME. The subject of a beat must be at least one third of the frame height — for a close reaction, half or more. A character drawn 5% tall in a corner under a big empty sky is the single most common failure: the viewer can't tell what it is. Crop in. Empty sky/floor is wasted frame; if a region has nothing happening in it, the camera is in the wrong place. Put the action in the middle two thirds, and let characters break the frame edge rather than shrink.
- EVERY FRAME MOVES. Nothing sits pixel-identical for more than ~0.3 s unless
you are deliberately holding on a text beat (use
hold=for that). Even a "still" character breathes, sways, blinks; even a still scene has drifting clouds or a flickering light. Long stretches of identical frames read as a broken GIF. Animate the SUBJECT, not just one prop next to it. - SHOW IT, DON'T CAPTION IT. A caption is for a punchline or a line of
dialogue — never a narration of what the animation should be depicting. If you
find yourself writing
"*CRASH* garbage everywhere!", draw the garbage flying instead. Every noun in the request should appear on screen, not in text. - GIVE IT DEPTH. Flat single-tone shapes look like a diagram. Cheap wins that land every time: a soft elliptical shadow on the ground under every character, a second darker tone on the shaded side of each big shape, a background that is a gradient or has a couple of out-of-focus shapes, and clear foreground/midground/background separation. Outline characters in a dark colour so they pop.
Motion quality, in order of payoff: ease in and out of every move
(t*t*(3-2*t), never linear), anticipation (wind up before a big action),
overshoot/settle on impacts, squash & stretch on anything that bounces or
lands. A held pose that eases beats three extra scenes.
3. Render with the harness (ONE code_exec call)
Pass scripts/gifsmith.py in with files_in (its file_id is listed at the
bottom of this skill) as {"name": "gifsmith.py", "file_id": "<that id>"}, then
write ONE code_exec call:
# gifsmith re-exports the drawing vocabulary, so this one import is enough:
from gifsmith import Animation, Circle, Ellipse, Rectangle, Polygon, Wedge, np
anim = Animation(width=640, height=480, fps=15) # canvas locked here
# ---- cast: defined ONCE, called from every scene ----
def draw_steve(ax, x, y, rage=0.0):
ax.add_patch(Ellipse((x, y - 9), 14, 3, fc="#00000033", zorder=1)) # shadow
...
@anim.scene("Steve watches the printer", seconds=2.5)
def s0(ax, t): # t runs 0.0 -> 1.0 across the scene
ease = t * t * (3 - 2 * t)
draw_steve(ax, 30 + 8 * ease, 40, rage=t)
@anim.scene("it sprays him", seconds=2.0, hold=0.8)
def s1(ax, t):
draw_steve(ax, 38, 40, rage=1.0)
anim.text(ax, "every single time") # boxed, bold, legible caption
art = anim.render() # encodes, writes final.* + still.png, prints a report
print(art)
axis a fresh matplotlib Axes at exactly the locked pixel size, withx in [0, 100]andy in [0, anim.ymax], origin bottom-left, no margins. Draw in those units and every frame lines up.hold=<seconds>freezes the scene's last frame — that is how you make a caption readable (short phrases ~1.5–2 s, sentences longer).- PIL instead:
@anim.scene("...", seconds=2, mode="pil")gives(img, draw, t).anim.font(size)returns a real bold TrueType face. - Hand-built frames:
anim.add_frame(pil_image, times=n). anim.render(long_edge=..., force="gif"|"mp4", audio="/workspace/track.wav"). It picks GIF vs MP4 by length, and re-encodes to MP4 if the GIF is too big for Discord. It returns{"path", "mime", "bytes"}.
Everything happens in ONE call — /workspace is wiped before every
code_exec call, so a render in one call and a read in the next finds nothing.
That also means gifsmith.py must be in files_in on EVERY code_exec call
that imports it, including every retry — it does not survive from the last one.
Give the call a generous timeout_seconds (120+); encoding is CPU-bound.
A SyntaxError costs you the whole pass — it happens before the harness can
isolate anything, and it is the one failure it cannot absorb. You are emitting a
lot of code in one shot, so keep it boring: one statement per line, short helper
signatures, no dense one-liners packing three drawing calls into a single
add_patch, and the same keyword names everywhere you call a helper. Read back
any line with more than about four arguments before you send it.
Read the report render() prints. It tells you the frame count, the scratch
peak, and — importantly — which scenes failed. A scene that raises is
skipped, the rest still render and ship, and you fix just that scene next pass
instead of re-deriving the whole program.
Long pieces (minutes): fps=10..12, width=480..540, and favour procedural
motion that is cheap per frame. If a render times out you get nothing back, so on
a retry scale down decisively — keep every beat, render it leaner.
4. Files: file_ids, never paths
render() leaves /workspace/final.gif (or .mp4) and /workspace/still.png at
the TOP of /workspace, and files_out returns a file_id for each.
image_describe, send_attachments and file_get_metadata take that file_id —
passing "/workspace/final.gif" fails file_not_found. Copy the file_id
character-for-character out of the files_out JSON; do not retype it from
memory. One wrong digit is a file_not_found that silently costs you the whole
critique pass.
Nothing here needs pip install — matplotlib, PIL, numpy, ffmpeg, gifski and
gifsicle are already in the sandbox.
5. Self-critique (AT MOST 3 render passes)
Before delivering, LOOK at what you made: call image_describe on the
still.png file_id — the harness already built it as a contact sheet of
frames sampled evenly ACROSS the animation, each tile labelled with its t
(0.00 = first frame). Ask specifically about subject size, caption readability
and whether the depicted action matches the request.
A thing missing from one tile is not a thing missing from the animation. The tiles are time points, so a transient — a tear rolling, a blink, a wipe — appears in some and not others. If the critique says something is absent, check the other tiles for it before re-rendering; that mistake once cost a worker three full re-renders chasing a tear that was there all along. Then answer:
- Does it depict what was asked — every named character, prop and action?
- Is the subject big enough to read (≥ ⅓ frame height)?
- Is any caption legible, and does it hold long enough?
- Does every recurring character look the SAME across scenes?
- Any scene the report flagged as failed, colour banding, clipped shapes, characters floating with no shadow?
A "no" on 1, 2 or 3 means you re-render — those three are what makes a GIF
land or flop, and "it's basically fine" is how a bad one ships. Change something
concrete and re-run the whole program in one fresh code_exec call. At most 3
render passes; for a long piece be decisive (1–2). After the 3rd, deliver the
best version with a one-line note on the trade-off.
6. Deliver
ONE send_attachments call with channel_id from your platform context and the
file_id of final.gif / final.mp4 — NOT the still, NOT a path. Finish with a
one-line caption. The contact sheet is INTERNAL; never attach it unless asked.
Forbidden
- Never pass a
/workspace/...path where a file_id is wanted, and never retype a file_id from memory — copy it fromfiles_out. - Do not hand-roll the scratch dir, the frame loop, the frame numbering, the
contact sheet, or the encode — that is what the harness is for. In particular
do not render frames into
/tmp(a 16 MiB tmpfs) or into/workspaceitself. - Do not split render and read across separate code_exec calls, and do not
assume
gifsmith.pyis still in/workspacefrom the last call — pass it infiles_inevery time. - Do not re-describe a recurring character inline per scene — one cast helper.
- Do not invent beats the user didn't ask for — assume ONE scene.
- Do not narrate the action in a caption instead of animating it.
- Do not ship a subject too small to recognise, or a stretch of identical frames.
- Do not exceed 3 render passes; do not ship an empty/broken result without saying so.