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.
191 lines
10 KiB
Markdown
191 lines
10 KiB
Markdown
---
|
||
name: gif
|
||
description: 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.
|
||
license: MIT
|
||
allowed-tools: [code_exec, image_describe, send_attachments, file_get_metadata, think]
|
||
metadata:
|
||
version: "3.0.0"
|
||
adapted-from: mort/skills/animate
|
||
---
|
||
|
||
# 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
|
||
<someone>", 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:
|
||
|
||
```python
|
||
# 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)
|
||
```
|
||
|
||
- `ax` is a fresh matplotlib Axes at exactly the locked pixel size, with
|
||
`x in [0, 100]` and `y 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 labelled contact
|
||
sheet, one tile per scene. Ask specifically about subject size, caption
|
||
readability and whether the depicted action matches the request. Then answer:
|
||
|
||
1. Does it depict what was asked — every named character, prop and action?
|
||
2. Is the subject **big enough to read** (≥ ⅓ frame height)?
|
||
3. Is any caption legible, and does it hold long enough?
|
||
4. Does every recurring character look the SAME across scenes?
|
||
5. 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 from `files_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 `/workspace` itself.
|
||
- Do not split render and read across separate code_exec calls, and do not
|
||
assume `gifsmith.py` is still in `/workspace` from the last call — pass it in
|
||
`files_in` every 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.
|