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

10 KiB
Raw Blame History

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, 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. MIT
code_exec
image_describe
send_attachments
file_get_metadata
think
version adapted-from
1.0.0 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 ", 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:
    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.