207 lines
11 KiB
Markdown
207 lines
11 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.
|
||
# The COMPLETE importable list (there is nothing else — don't guess, and don't
|
||
# spend a call on dir(gifsmith)):
|
||
# Animation
|
||
# Arc Circle Ellipse FancyBboxPatch PathPatch Path Polygon Rectangle Wedge
|
||
# Image ImageDraw ImageFont (PIL, for mode="pil")
|
||
# np plt (numpy / pyplot, for anything else)
|
||
# GIF_MAX_SECONDS GIF_MAX_BYTES
|
||
from gifsmith import Animation, Circle, Ellipse, Rectangle, Polygon, Wedge, np
|
||
anim = Animation(width=640, height=480, fps=15) # canvas locked here
|
||
# Animation() takes ONLY width, height, fps, dpi, bg (background= also works).
|
||
# Everything about the output — format, size, audio — is set on render().
|
||
|
||
# ---- 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 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:
|
||
|
||
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.
|