feat(gif): staging rails + bundled render harness
Two changes, both driven by measurement against 128 production gifsmith runs and a local replica of the agent loop (docs/audits/2026-07-24-gifsmith-quality.md in mort). Staging rules. The renders that "worked" still read badly, always the same four ways: the subject drawn 5-10% of the frame height under an empty sky, long runs of pixel-identical frames, captions narrating action that was never drawn, and flat untextured shapes. The recipe never asked for anything else. SKILL.md now states four checkable rules — fill the frame, every frame moves, show it don't caption it, give it depth — plus easing/anticipation/squash-and-stretch, and the self-critique makes a "no" on depiction, subject size or caption legibility a mandatory re-render instead of a suggestion. Render harness. 26% of production code_exec calls end in a traceback, and every crash class lives in plumbing the agent re-derives each run: scratch dir, frame loop, numbering, canvas size, contact sheet, encode. scripts/gifsmith.py owns all of it and the agent writes only @anim.scene drawing functions. A scene that raises is now skipped while the others still render and ship, the canvas is size-locked so ffmpeg cannot fail on jittering frames, and the drawing vocabulary is re-exported so a missing import cannot NameError. The harness also unblocks the multi-minute pieces this skill advertises but could never produce: /tmp is a 16 MiB tmpfs (~13s of frames) and /workspace 64 MiB (~51s), which is why "No space left on device" is 15% of all production crashes. Anything past the GIF length threshold is an MP4, and an MP4 encodes in one streaming pass, so those frames now go straight into ffmpeg's stdin and never touch disk — verified at 1350 frames / 90 s with 0 MiB on disk. encode.py is removed; the harness supersedes it.
This commit is contained in:
@@ -1,115 +0,0 @@
|
||||
"""
|
||||
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}
|
||||
@@ -0,0 +1,394 @@
|
||||
"""
|
||||
gifsmith.py — the render harness for the `gif` skill.
|
||||
|
||||
You write ONLY the drawing. This module owns every part that has historically
|
||||
broken renders: the scratch directory and its disk budget, frame naming and the
|
||||
continuous counter, a LOCKED canvas size (frames that differ by one pixel break
|
||||
the encoder), per-scene crash isolation, the GIF-vs-MP4 decision, and the
|
||||
critique contact sheet.
|
||||
|
||||
from gifsmith import Animation
|
||||
|
||||
anim = Animation(width=640, height=480, fps=15)
|
||||
|
||||
# --- cast: define each recurring character ONCE ---
|
||||
def draw_steve(ax, x, y, angry=0.0):
|
||||
...
|
||||
|
||||
@anim.scene("Steve watches the printer", seconds=2.5)
|
||||
def s0(ax, t): # t goes 0.0 -> 1.0 across the scene
|
||||
draw_steve(ax, 2, 1, angry=t)
|
||||
|
||||
art = anim.render() # -> {"path": "/workspace/final.gif", "mime": ..., "bytes": ...}
|
||||
|
||||
`ax` is a fresh matplotlib Axes at exactly (width, height) pixels, x in [0,100],
|
||||
y in [0, 100*height/width], origin bottom-left, no margins. Draw in those units
|
||||
and every frame lines up.
|
||||
|
||||
PIL instead of matplotlib:
|
||||
|
||||
@anim.scene("title card", seconds=1.5, mode="pil")
|
||||
def s1(img, draw, t): # img: RGB Image at the locked size; draw: ImageDraw
|
||||
draw.text((40, 40), "THE END", fill=(255, 255, 255), font=anim.font(48))
|
||||
|
||||
render() prints a report and returns the artifact dict. If one scene raises, the
|
||||
OTHER scenes still render and ship — the report names the broken one so you can
|
||||
fix just that scene. It also writes /workspace/still.png: a labelled contact
|
||||
sheet, one tile per scene, for the critique pass.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt # noqa: E402
|
||||
from PIL import Image, ImageDraw, ImageFont # noqa: E402
|
||||
|
||||
# Re-exported so one import line covers the whole drawing vocabulary — a missing
|
||||
# `from matplotlib.patches import ...` is otherwise a NameError that costs a pass.
|
||||
from matplotlib.patches import ( # noqa: E402,F401
|
||||
Arc, Circle, Ellipse, FancyBboxPatch, Polygon, Rectangle, Wedge)
|
||||
import numpy as np # noqa: E402,F401
|
||||
|
||||
# A GIF longer than this balloons past what's worth shipping as a GIF.
|
||||
GIF_MAX_SECONDS = 20
|
||||
# Keep a GIF comfortably under Discord's ~10 MiB inline limit.
|
||||
GIF_MAX_BYTES = 9 * 1024 * 1024
|
||||
|
||||
_FONT_CANDIDATES = [
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
]
|
||||
|
||||
|
||||
def _which(prog):
|
||||
return shutil.which(prog) is not None
|
||||
|
||||
|
||||
def _free_mb(path):
|
||||
try:
|
||||
st = os.statvfs(path)
|
||||
return st.f_bavail * st.f_frsize / 1024 / 1024
|
||||
except OSError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _pick_scratch():
|
||||
"""Largest writable scratch dir. /tmp is a small tmpfs in this sandbox; a
|
||||
SUBDIR of /workspace is bigger and is not returned in files_out (only
|
||||
TOP-LEVEL /workspace files are), so it is safe to fill with frames."""
|
||||
best, best_free = None, -1.0
|
||||
for base in ("/workspace/.frames", "/tmp/frames", "/var/tmp/frames"):
|
||||
parent = os.path.dirname(base)
|
||||
if not os.path.isdir(parent) or not os.access(parent, os.W_OK):
|
||||
continue
|
||||
free = _free_mb(parent)
|
||||
if free > best_free:
|
||||
best, best_free = base, free
|
||||
if best is None:
|
||||
raise RuntimeError("no writable scratch directory found")
|
||||
shutil.rmtree(best, ignore_errors=True)
|
||||
os.makedirs(best, exist_ok=True)
|
||||
return best, best_free
|
||||
|
||||
|
||||
class _Scene:
|
||||
def __init__(self, fn, title, seconds, mode, hold):
|
||||
self.fn, self.title, self.seconds = fn, title, float(seconds),
|
||||
self.mode, self.hold = mode, float(hold)
|
||||
self.frames = 0
|
||||
self.error = None
|
||||
self.first_frame = None
|
||||
|
||||
|
||||
class Animation:
|
||||
"""Collects scenes, renders them to a locked-size frame sequence, encodes."""
|
||||
|
||||
def __init__(self, width=640, height=480, fps=15, dpi=100, bg="white"):
|
||||
# H.264 needs even dimensions; lock them here so the encoder never fails.
|
||||
self.width = int(width) // 2 * 2
|
||||
self.height = int(height) // 2 * 2
|
||||
self.fps = int(fps)
|
||||
self.dpi = int(dpi)
|
||||
self.bg = bg
|
||||
self.xmax = 100.0
|
||||
self.ymax = 100.0 * self.height / self.width
|
||||
self._scenes = []
|
||||
self._count = 0
|
||||
self._scratch = None
|
||||
self._peak_mb = 0.0
|
||||
self._streaming = False
|
||||
self._pipe = None
|
||||
|
||||
# ---------------------------------------------------------------- authoring
|
||||
|
||||
def scene(self, title, seconds=2.0, mode="mpl", hold=0.0):
|
||||
"""Decorator: register a scene. `hold` freezes the last frame for that
|
||||
many extra seconds — use it to let a caption stay readable."""
|
||||
def deco(fn):
|
||||
self._scenes.append(_Scene(fn, title, seconds, mode, hold))
|
||||
return fn
|
||||
return deco
|
||||
|
||||
def font(self, size):
|
||||
for p in _FONT_CANDIDATES:
|
||||
if os.path.exists(p):
|
||||
return ImageFont.truetype(p, size)
|
||||
return ImageFont.load_default()
|
||||
|
||||
def text(self, ax, s, size=18, y=None, color="white", box="black"):
|
||||
"""Caption helper: centred, boxed, legible at GIF resolution."""
|
||||
ax.text(self.xmax / 2, self.ymax * 0.06 if y is None else y, s,
|
||||
ha="center", va="center", fontsize=size, color=color, zorder=999,
|
||||
bbox=dict(boxstyle="round,pad=0.4", fc=box, ec="none", alpha=0.75))
|
||||
|
||||
def add_frame(self, img, times=1):
|
||||
"""Escape hatch for hand-built frames (PIL compositing, pasted photos,
|
||||
anything the scene decorators can't express). Still size-locked."""
|
||||
for _ in range(max(1, int(times))):
|
||||
self._emit(img)
|
||||
|
||||
# ------------------------------------------------------------------ drawing
|
||||
|
||||
def _new_axes(self):
|
||||
fig = plt.figure(figsize=(self.width / self.dpi, self.height / self.dpi),
|
||||
dpi=self.dpi)
|
||||
ax = fig.add_axes([0, 0, 1, 1])
|
||||
ax.set_xlim(0, self.xmax)
|
||||
ax.set_ylim(0, self.ymax)
|
||||
ax.set_axis_off()
|
||||
ax.set_facecolor(self.bg)
|
||||
fig.patch.set_facecolor(self.bg)
|
||||
return fig, ax
|
||||
|
||||
def _emit(self, img):
|
||||
"""Emit one frame at exactly the locked size. Never bbox_inches='tight'.
|
||||
|
||||
Short pieces go to PNG files (gifski wants a file list). Long pieces are
|
||||
piped straight into ffmpeg so a multi-minute render never touches disk —
|
||||
the sandbox scratch would otherwise cap you at well under a minute."""
|
||||
if img.size != (self.width, self.height):
|
||||
img = img.resize((self.width, self.height), Image.LANCZOS)
|
||||
img = img.convert("RGB")
|
||||
if self._pipe is not None:
|
||||
img.save(self._pipe.stdin, format="PNG", compress_level=1)
|
||||
else:
|
||||
img.save(os.path.join(self._scratch, f"frame_{self._count:05d}.png"),
|
||||
compress_level=1)
|
||||
self._count += 1
|
||||
|
||||
def _render_mpl(self, sc, t):
|
||||
fig, ax = self._new_axes()
|
||||
try:
|
||||
sc.fn(ax, t)
|
||||
fig.canvas.draw()
|
||||
img = Image.frombuffer(
|
||||
"RGBA", fig.canvas.get_width_height(),
|
||||
fig.canvas.buffer_rgba(), "raw", "RGBA", 0, 1).convert("RGB")
|
||||
finally:
|
||||
plt.close(fig)
|
||||
return img
|
||||
|
||||
def _render_pil(self, sc, t):
|
||||
img = Image.new("RGB", (self.width, self.height),
|
||||
self.bg if isinstance(self.bg, (str, tuple)) else "white")
|
||||
sc.fn(img, ImageDraw.Draw(img), t)
|
||||
return img
|
||||
|
||||
# ------------------------------------------------------------------- render
|
||||
|
||||
def render(self, long_edge=None, force=None, audio=None):
|
||||
if not self._scenes:
|
||||
raise RuntimeError("no scenes registered — decorate at least one "
|
||||
"function with @anim.scene(...)")
|
||||
self._scratch, free_mb = _pick_scratch()
|
||||
long_edge = long_edge or max(self.width, self.height)
|
||||
|
||||
total_s = sum(s.seconds + s.hold for s in self._scenes)
|
||||
est_frames = int(total_s * self.fps)
|
||||
# ~90 KiB per 640x480 PNG at compress_level=1; budget 60% of free space.
|
||||
est_mb = est_frames * (self.width * self.height * 9e-5)
|
||||
|
||||
# A piece too long for a GIF is an MP4 anyway, and an MP4 can be encoded
|
||||
# in one streaming pass — so pipe the frames into ffmpeg and keep NOTHING
|
||||
# on disk. That is what makes multi-minute pieces possible at all: the
|
||||
# sandbox scratch tops out around a minute of busy frames.
|
||||
self._streaming = (force == "mp4" or audio is not None
|
||||
or (force != "gif" and (total_s > GIF_MAX_SECONDS
|
||||
or est_mb > free_mb * 0.6)))
|
||||
self._pipe = None
|
||||
if self._streaming:
|
||||
self._pipe = self._open_pipe("/workspace/final.mp4", long_edge, audio)
|
||||
print(f"[gifsmith] streaming to MP4 (projected {est_mb:.0f} MiB of "
|
||||
f"frames vs {free_mb:.0f} MiB scratch) — nothing hits disk",
|
||||
flush=True)
|
||||
elif est_mb > free_mb * 0.6:
|
||||
print(f"[gifsmith] WARNING projected frames ~{est_mb:.0f} MiB vs "
|
||||
f"{free_mb:.0f} MiB free in {os.path.dirname(self._scratch)} — "
|
||||
f"dropping fps or canvas would be safer", file=sys.stderr)
|
||||
|
||||
print(f"[gifsmith] {len(self._scenes)} scene(s), {total_s:.1f}s at "
|
||||
f"{self.fps}fps, canvas {self.width}x{self.height}", flush=True)
|
||||
|
||||
for i, sc in enumerate(self._scenes):
|
||||
start = self._count
|
||||
n = max(1, int(round(sc.seconds * self.fps)))
|
||||
try:
|
||||
for k in range(n):
|
||||
t = k / max(n - 1, 1)
|
||||
img = (self._render_pil if sc.mode == "pil" else self._render_mpl)(sc, t)
|
||||
if sc.first_frame is None and k >= n // 2:
|
||||
sc.first_frame = img.copy()
|
||||
self._emit(img)
|
||||
for _ in range(int(round(sc.hold * self.fps))):
|
||||
self._emit(img)
|
||||
except Exception: # noqa: BLE001
|
||||
sc.error = traceback.format_exc(limit=6)
|
||||
if not self._streaming:
|
||||
# Drop this scene's partial frames so it can't ship half-drawn.
|
||||
# (Streamed frames are already in the encoder — a broken scene
|
||||
# leaves its partial beat in the MP4; the report says so.)
|
||||
for p in sorted(glob.glob(
|
||||
os.path.join(self._scratch, "frame_*.png")))[start:]:
|
||||
os.remove(p)
|
||||
self._count = start
|
||||
print(f"[gifsmith] SCENE {i} ({sc.title!r}) FAILED — skipped, "
|
||||
f"other scenes continue:\n{sc.error}", file=sys.stderr, flush=True)
|
||||
continue
|
||||
sc.frames = self._count - start
|
||||
print(f"[gifsmith] scene {i}: {sc.frames} frames {sc.title!r}", flush=True)
|
||||
self._renumber_if_needed()
|
||||
|
||||
used = sum(os.path.getsize(p) for p in
|
||||
glob.glob(os.path.join(self._scratch, "frame_*.png")))
|
||||
self._peak_mb = used / 1024 / 1024
|
||||
if self._count == 0:
|
||||
raise RuntimeError(
|
||||
"every scene failed — see the per-scene tracebacks above; "
|
||||
"nothing was encoded. Fix the error they all share (usually one "
|
||||
"cast helper called with a keyword it does not take, or a name "
|
||||
"that differs by a character) and re-run the whole program in a "
|
||||
"fresh code_exec call, remembering gifsmith.py in files_in.")
|
||||
|
||||
art = self._encode(total_s, long_edge, force, audio)
|
||||
self._contact_sheet()
|
||||
broken = [f"{i}:{s.title!r}" for i, s in enumerate(self._scenes) if s.error]
|
||||
print(f"[gifsmith] {self._count} frames, scratch "
|
||||
+ ("streamed (0 MiB on disk)" if self._streaming
|
||||
else f"peak {self._peak_mb:.1f} MiB"))
|
||||
print(f"[gifsmith] scene errors: {', '.join(broken) if broken else 'none'}")
|
||||
print(f"[gifsmith] ART {art}")
|
||||
print("[gifsmith] still: /workspace/still.png (contact sheet — call "
|
||||
"image_describe on ITS file_id from files_out)")
|
||||
shutil.rmtree(self._scratch, ignore_errors=True)
|
||||
return art
|
||||
|
||||
def _renumber_if_needed(self):
|
||||
"""A skipped scene leaves no gaps because _count rewinds; kept as a
|
||||
no-op hook so the counter contract is explicit."""
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------------ encoding
|
||||
|
||||
def _open_pipe(self, out, long_edge, audio):
|
||||
"""ffmpeg reading a PNG stream on stdin — the streaming MP4 path."""
|
||||
cmd = ["ffmpeg", "-y", "-loglevel", "error",
|
||||
"-f", "image2pipe", "-framerate", str(self.fps), "-i", "-"]
|
||||
if audio:
|
||||
cmd += ["-i", audio, "-c:a", "aac", "-b:a", "128k", "-shortest"]
|
||||
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "23",
|
||||
"-vf", f"scale='min({long_edge},iw)':-2:flags=lanczos",
|
||||
"-movflags", "+faststart", out]
|
||||
return subprocess.Popen(cmd, stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||
|
||||
def _close_pipe(self):
|
||||
self._pipe.stdin.close()
|
||||
err = self._pipe.stderr.read().decode(errors="replace")
|
||||
if self._pipe.wait() != 0:
|
||||
raise RuntimeError(f"ffmpeg (streaming) failed:\n{err[-2000:]}")
|
||||
self._pipe = None
|
||||
|
||||
def _encode(self, seconds, long_edge, force, audio=None):
|
||||
gif, mp4 = "/workspace/final.gif", "/workspace/final.mp4"
|
||||
if self._streaming:
|
||||
self._close_pipe()
|
||||
return _art(mp4, "video/mp4")
|
||||
fs = sorted(glob.glob(os.path.join(self._scratch, "frame_*.png")))
|
||||
want_mp4 = (force == "mp4" or audio is not None
|
||||
or (force != "gif" and seconds > GIF_MAX_SECONDS))
|
||||
if want_mp4:
|
||||
self._mp4(mp4, long_edge, audio)
|
||||
return _art(mp4, "video/mp4")
|
||||
self._gif(fs, gif, long_edge)
|
||||
if os.path.getsize(gif) <= GIF_MAX_BYTES:
|
||||
return _art(gif, "image/gif")
|
||||
print(f"[gifsmith] GIF {os.path.getsize(gif)/1048576:.1f} MiB is too big "
|
||||
f"for Discord — re-encoding as MP4", flush=True)
|
||||
self._mp4(mp4, long_edge, audio)
|
||||
os.remove(gif)
|
||||
return _art(mp4, "video/mp4")
|
||||
|
||||
def _gif(self, fs, out, long_edge):
|
||||
if _which("gifski"):
|
||||
_run(["gifski", "-o", out, "--fps", str(self.fps), "--quality", "90",
|
||||
"--width", str(min(long_edge, self.width))] + fs)
|
||||
else:
|
||||
vf = (f"fps={self.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", "-loglevel", "error", "-pattern_type", "glob",
|
||||
"-i", os.path.join(self._scratch, "frame_*.png"), "-vf", vf, out])
|
||||
if _which("gifsicle"):
|
||||
_run(["gifsicle", "-O3", "--lossy=60", out, "-o", out])
|
||||
|
||||
def _mp4(self, out, long_edge, audio=None):
|
||||
cmd = ["ffmpeg", "-y", "-loglevel", "error", "-framerate", str(self.fps),
|
||||
"-pattern_type", "glob",
|
||||
"-i", os.path.join(self._scratch, "frame_*.png")]
|
||||
if audio:
|
||||
cmd += ["-i", audio, "-c:a", "aac", "-b:a", "128k", "-shortest"]
|
||||
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "23",
|
||||
"-vf", f"scale='min({long_edge},iw)':-2:flags=lanczos",
|
||||
"-movflags", "+faststart", out]
|
||||
_run(cmd)
|
||||
|
||||
# ------------------------------------------------------------- critique aid
|
||||
|
||||
def _contact_sheet(self, path="/workspace/still.png"):
|
||||
tiles = [(i, s) for i, s in enumerate(self._scenes) if s.first_frame is not None]
|
||||
if not tiles:
|
||||
return
|
||||
if len(tiles) == 1:
|
||||
tiles[0][1].first_frame.save(path)
|
||||
return
|
||||
cols = min(3, len(tiles))
|
||||
rows = math.ceil(len(tiles) / cols)
|
||||
tw = 300
|
||||
th = max(1, round(self.height * tw / self.width))
|
||||
sheet = Image.new("RGB", (cols * tw, rows * (th + 20)), (20, 20, 24))
|
||||
d = ImageDraw.Draw(sheet)
|
||||
f = self.font(14)
|
||||
for k, (i, sc) in enumerate(tiles):
|
||||
x, y = (k % cols) * tw, (k // cols) * (th + 20)
|
||||
sheet.paste(sc.first_frame.resize((tw, th), Image.LANCZOS), (x, y + 20))
|
||||
d.text((x + 4, y + 3), f"scene {i}: {sc.title[:38]}", fill=(235, 235, 240), font=f)
|
||||
sheet.save(path)
|
||||
|
||||
|
||||
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 _art(path, mime):
|
||||
b = os.path.getsize(path)
|
||||
print(f"[gifsmith] encoded {path} {mime} {b / 1024 / 1024:.2f} MiB")
|
||||
return {"path": path, "mime": mime, "bytes": b}
|
||||
Reference in New Issue
Block a user