Files
executus-skills/gif/SKILL.md
T
Steve DudenhoefferandClaude Opus 4.8 fae89a1ed0 feat(gif): animated GIF/MP4 skill pack for Discord
Adapts mort's builtin animate skill into an executus/skillpack:
- plans scenes + a reusable cast so recurring people/props stay consistent
  (built for funny bits 'about ourselves'), incl. multi-minute pieces
- bundled scripts/encode.py owns the fragile encode + format decision: GIF for
  short loops, H.264 MP4 (yuv420p, even dims, +faststart — plays inline in
  Discord) for long pieces OR when a GIF comes out too big, with a
  gifski→ffmpeg-palette fallback
- keeps the workspace discipline, text-readability pacing, and <=3-pass vision
  self-critique from the original; adds a frame budget for long renders

encode.py verified end-to-end (short->gif, long->mp4 confirmed h264/yuv420p/
even/faststart); SKILL.md parses cleanly via the executus skillpack loader.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-04 23:06:25 -04:00

180 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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, encodes cleanly, 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: "1.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.
Naive PIL GIF export looks bad (256-colour banding, muddy dithering) and a
multi-minute GIF is physically absurd (hundreds of MB), so follow this recipe:
render frames in Python, let the bundled `encode.py` choose GIF vs MP4, and
self-critique the result. **Work by calling tools — do not introduce yourself
or list capabilities.**
## 1. Plan first: length, scenes, and cast
Before drawing, decide (use `think` for anything non-trivial):
- **How long / 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/what is the cast?** List every recurring character and prop (a person's
persona, "the dog", a logo). 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 reusable Python helper (see
§4). That is what keeps the SAME person drawn identically across every scene
instead of re-invented each cut. A one-scene GIF with a single subject needs
no cast block.
## 2. Pick the format from the length (encode.py does this for you)
- **Short & loopable (≤ ~20 s):** a GIF — fun, loops, autoplays.
- **Long (tens of seconds to minutes):** an **MP4**. A 310 minute GIF is
hundreds of MB and unusable; H.264 is the right tool and Discord plays it
inline.
- You don't hand-pick this: `encode.py`'s `encode_animation(...)` takes a
`seconds_hint` and (a) goes straight to MP4 for long pieces, and (b) for short
ones, makes the GIF but **auto-re-encodes to an MP4 if the GIF is too big for
Discord to render nicely.** It returns `{"path", "mime", "bytes"}` — deliver
whatever it hands back.
## 3. code_exec workspace — READ THIS (it is why draws fail)
The `code_exec` sandbox has three hard rules; breaking them is the #1 cause of
"vanished" files and wasted budget:
1. **`/workspace` is WIPED before every `code_exec` call.** Nothing survives
between calls. So render → encode → write your critique still **all in ONE
call**. Never write frames in one call and read them in another.
2. **Only TOP-LEVEL files in `/workspace/` come back as `files_out` file_ids.**
A file in a subdir (`/workspace/frames/x.png`) is NOT returned. Write the
final artifact and the ONE critique still directly at the top:
`encode.py` already writes `/workspace/final.gif` or `/workspace/final.mp4`;
you add `/workspace/still.png`. Render scratch frames under `/tmp`, never
`/workspace`.
3. **Don't dump many files into `/workspace`.** Hundreds of frame PNGs blow the
`files_out` size cap and get dropped. Keep `/workspace` to just the final
artifact + one still; everything else lives under `/tmp`.
`image_describe`, `send_attachments`, `file_get_metadata` take a **file_id from
`files_out` — never a path.** Passing `"/workspace/final.mp4"` fails
`file_not_found`. Read the file_id out of `files_out` and pass THAT.
## 4. Render → encode (ONE code_exec call, using encode.py)
This skill bundles `scripts/encode.py`. Load it into the render call with
`files_in` (its file_id is listed when you open this skill), then in a SINGLE
`code_exec` call:
1. **Clear scratch** so stale frames can't leak into this encode:
`import shutil, os; shutil.rmtree('/tmp/frames', ignore_errors=True); os.makedirs('/tmp/frames')`
2. **Render frames** with Python (matplotlib / Pillow / numpy) into `/tmp/frames`
as `frame_00000.png, frame_00001.png, …` — **fixed 5-digit zero-pad, ONE
continuous counter across all scenes** (do NOT restart numbering per scene, or
the encoder reorders/overwrites frames).
3. **Encode** — let the helper choose GIF vs MP4:
```python
from encode import encode_animation
art = encode_animation("/tmp/frames", fps=15, seconds_hint=<intended seconds>)
print(art) # {'path': '/workspace/final.mp4', 'mime': 'video/mp4', 'bytes': ...}
```
4. **Write ONE critique still** to `/workspace/still.png` in the same call — a
representative frame, or for a multi-scene piece a COMPACT contact sheet with
one small (~200320px) tile per scene, labelled by scene index, so the
critique can check every scene and cast consistency. Keep it low-res and
legible — it's an internal aid, not the deliverable.
The call ends with the final artifact + `still.png` at the top of `/workspace`;
`files_out` returns a file_id for each. Give `code_exec` a generous
`timeout_seconds` (e.g. 120+) — encoding is CPU-bound, and a long piece is real
work, not a hang.
### Multi-scene: define the cast ONCE, reuse it everywhere
It all still happens in the one call — structure the program as:
1. **Cast block at the top, defined ONCE:** helpers like
`def draw_steve(ax, x, y, pose): …` and shared constants (`PALETTE`, `SKY`).
2. **One function per scene** (`def scene_0(...): …`) that draws its beat by
CALLING the cast helpers — never by re-describing a character inline.
3. **A timeline** that runs the scenes in order into `/tmp/frames` with the one
continuous counter, then encodes the whole sequence.
Because every scene calls the same `draw_*` helpers, recurring people/props come
out pixel-identical scene to scene — the entire point of the cast block. If you
catch yourself re-describing a character inside a scene, hoist it into a cast
helper. Carry shared state (positions, score, time of day) in plain variables
between scene calls so cuts stay continuous.
### Budget for LONG pieces (minutes)
A 310 minute animation is thousands of frames. Keep it renderable: **fps
1012**, cap the long edge ~**480540px** (`long_edge=` in `encode_animation`),
and favour procedural motion that's cheap per frame — don't render 9000 frames
of heavy matplotlib if a lighter draw reads the same. **If code_exec times out
or errors mid-render you get nothing back**, so on the retry SCALE DOWN
decisively (lower fps, smaller canvas, trim hold frames, reduce the longest
scenes) — keep every beat, just render it leaner. Each retry counts toward the
3-pass cap; never burn all three on an over-ambitious render and leave the user
with nothing.
## 5. Text in GIFs — make it READABLE
Adding captions/labels/punchlines is fine, but pace them for a human. Motion can
stay 1020 fps; what matters is how long each distinct TEXT STATE holds — short
phrases ~1.52 s, full sentences longer (budget by word count). At 15 fps that's
~2530 repeated frames per text state, not a handful. Hold by duplicating frames,
or with PIL pass a per-frame `duration=[…]` list (long on text, short on motion).
Text flashing past unreadably is the #1 complaint — check it in the critique.
## 6. Self-critique (AT MOST 3 render passes)
Before delivering, LOOK at what you made: call `image_describe` on the
`still.png` **file_id** (from `files_out`, not a path) and judge it against the
request — motion, colour banding, artifacts, framing, "does it depict what was
asked", and text readability. For a multi-scene piece, judge scene by scene off
the contact sheet AND check each recurring character/prop is the SAME across
scenes (drift means that scene drew it ad hoc — fix the cast helper and
re-render). If it falls short, change something concrete and re-run the WHOLE
pipeline in one fresh `code_exec` call (workspace was wiped — rebuild from
scratch). **At most 3 render passes total**; for a long piece be more decisive
(12 passes) since each re-render is expensive. After the 3rd, deliver the best
version with a one-line note on any trade-off.
## 7. Deliver
Deliver the final artifact with a SINGLE `send_attachments` call using
`channel_id` from your platform context and the **file_id that
`encode_animation` produced** (read it from that render call's `files_out` by
matching `final.gif` / `final.mp4` — NOT the still, NOT a path). Finish with a
one-line caption. Critique aids (contact sheets, stills) are INTERNAL — never
attach them unless the user explicitly asked.
## Forbidden
- NEVER pass a `/workspace/...` path to image_describe / send_attachments /
file_get_metadata — they take a file_id from `files_out`.
- Do not write the final artifact into a `/workspace` subdir — top level only.
- Do not split render and read across separate code_exec calls — `/workspace` is
wiped between them.
- Do not hand-roll the encode/format decision — call `encode_animation`; it
guarantees a Discord-playable MP4 (yuv420p, even dims, faststart) when needed.
- Do not restart the frame counter per scene — one continuous 5-digit counter.
- Do not re-describe a recurring character inline per scene — one shared cast
helper so it stays identical.
- Do not invent beats the user didn't ask for — assume ONE scene, add more only
for beats the prompt describes (no cap, but no padding).
- Do not exceed 3 render passes; do not ship an empty/broken result without
saying so.