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]>
116 lines
4.5 KiB
Python
116 lines
4.5 KiB
Python
"""
|
|
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}
|