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]>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
encode.py — turn a directory of PNG frames into a Discord-friendly GIF or MP4.
|
||||
|
||||
Use it INSIDE a single code_exec call. Render your frames to a scratch dir as
|
||||
frame_00000.png, frame_00001.png, … (fixed 5-digit width, ONE continuous
|
||||
counter across all scenes), then:
|
||||
|
||||
from encode import encode_animation
|
||||
art = encode_animation("/tmp/frames", fps=15, seconds_hint=8)
|
||||
# art == {"path": "/workspace/final.mp4", "mime": "video/mp4", "bytes": 1234567}
|
||||
|
||||
It writes the finished artifact to the TOP of /workspace (so code_exec returns a
|
||||
file_id for it) and picks the format for you:
|
||||
|
||||
* short & light -> animated GIF (fun, loops, autoplays)
|
||||
* long (many seconds+),
|
||||
OR a GIF too big to post -> H.264 MP4: yuv420p, even dimensions, +faststart
|
||||
— the combination Discord needs to PLAY it inline
|
||||
(a video whose dimensions Discord can't read is
|
||||
shown as a dead download, not a player).
|
||||
|
||||
Requires ffmpeg (always present in the sandbox). Uses gifski + gifsicle for a
|
||||
cleaner GIF when they're installed, falling back to ffmpeg's palette pipeline.
|
||||
This helper only ENCODES — you draw the frames.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
# A GIF longer than this balloons past what's worth shipping as a GIF — encode
|
||||
# straight to MP4 instead. Tune down for chattier channels.
|
||||
GIF_MAX_SECONDS = 20
|
||||
# Keep a GIF comfortably under Discord's ~10 MiB inline limit; over this we
|
||||
# re-encode to an MP4 and drop the GIF.
|
||||
GIF_MAX_BYTES = 9 * 1024 * 1024
|
||||
|
||||
|
||||
def _which(prog):
|
||||
return shutil.which(prog) is not None
|
||||
|
||||
|
||||
def _run(cmd):
|
||||
p = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
raise RuntimeError(f"{cmd[0]} failed:\n{p.stderr[-2000:]}")
|
||||
|
||||
|
||||
def _frames(frames_dir):
|
||||
fs = sorted(glob.glob(os.path.join(frames_dir, "frame_*.png")))
|
||||
if not fs:
|
||||
raise FileNotFoundError(
|
||||
f"no frame_*.png in {frames_dir} — render frames as "
|
||||
f"{frames_dir}/frame_00000.png, frame_00001.png, … first"
|
||||
)
|
||||
return fs
|
||||
|
||||
|
||||
def _encode_gif(frames_dir, fps, out, long_edge):
|
||||
fs = _frames(frames_dir)
|
||||
if _which("gifski"):
|
||||
# gifski takes an explicit, sorted file list — no numbering-gap surprises.
|
||||
_run(["gifski", "-o", out, "--fps", str(fps), "--quality", "90"] + fs)
|
||||
else:
|
||||
vf = (f"fps={fps},scale='min({long_edge},iw)':-1:flags=lanczos,"
|
||||
f"split[s0][s1];[s0]palettegen=stats_mode=diff[p];"
|
||||
f"[s1][p]paletteuse=dither=bayer:bayer_scale=3")
|
||||
_run(["ffmpeg", "-y", "-pattern_type", "glob",
|
||||
"-i", os.path.join(frames_dir, "frame_*.png"), "-vf", vf, out])
|
||||
if _which("gifsicle"):
|
||||
_run(["gifsicle", "-O3", "--lossy=60", out, "-o", out])
|
||||
|
||||
|
||||
def _encode_mp4(frames_dir, fps, out, long_edge):
|
||||
_frames(frames_dir) # validate presence
|
||||
# -2 forces EVEN width/height (H.264 requires it); yuv420p + faststart make
|
||||
# Discord/browsers play it inline instead of offering a download.
|
||||
vf = f"scale='min({long_edge},iw)':-2:flags=lanczos"
|
||||
_run(["ffmpeg", "-y", "-framerate", str(fps), "-pattern_type", "glob",
|
||||
"-i", os.path.join(frames_dir, "frame_*.png"),
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "23",
|
||||
"-vf", vf, "-movflags", "+faststart", out])
|
||||
|
||||
|
||||
def encode_animation(frames_dir, fps=15, seconds_hint=0, long_edge=640):
|
||||
"""Encode frames_dir → /workspace/final.{gif,mp4}. Returns {path, mime, bytes}.
|
||||
|
||||
seconds_hint: your intended runtime in seconds (falls back to frame_count/fps
|
||||
when 0). Pass it for long pieces so the format decision doesn't depend on how
|
||||
many frames you happened to render.
|
||||
long_edge: cap for the output's long side in px (keeps size/encode sane).
|
||||
"""
|
||||
n = len(_frames(frames_dir))
|
||||
seconds = seconds_hint or (n / max(fps, 1))
|
||||
gif, mp4 = "/workspace/final.gif", "/workspace/final.mp4"
|
||||
|
||||
if seconds > GIF_MAX_SECONDS:
|
||||
_encode_mp4(frames_dir, fps, mp4, long_edge)
|
||||
return _art(mp4, "video/mp4")
|
||||
|
||||
_encode_gif(frames_dir, fps, gif, long_edge)
|
||||
if os.path.getsize(gif) <= GIF_MAX_BYTES:
|
||||
return _art(gif, "image/gif")
|
||||
|
||||
# Too heavy as a GIF — hand Discord an MP4 it can actually render.
|
||||
_encode_mp4(frames_dir, fps, mp4, long_edge)
|
||||
os.remove(gif)
|
||||
return _art(mp4, "video/mp4")
|
||||
|
||||
|
||||
def _art(path, mime):
|
||||
b = os.path.getsize(path)
|
||||
print(f"[encode] {path} {mime} {b / 1024 / 1024:.2f} MiB")
|
||||
return {"path": path, "mime": mime, "bytes": b}
|
||||
Reference in New Issue
Block a user