598 lines
27 KiB
Python
598 lines
27 KiB
Python
"""
|
|
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 difflib
|
|
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.
|
|
#
|
|
# PathPatch and Path are here because agents assumed they already were: two
|
|
# separate workers wrote `from gifsmith import PathPatch` and got an ImportError,
|
|
# because the module exported SOME matplotlib artists and not others with no way
|
|
# to tell which from outside. The rule now is "the drawing vocabulary, whole".
|
|
from matplotlib.patches import ( # noqa: E402,F401
|
|
Arc, Circle, Ellipse, FancyBboxPatch, PathPatch, Polygon, Rectangle, Wedge)
|
|
from matplotlib.path import Path # noqa: E402,F401
|
|
import numpy as np # noqa: E402,F401
|
|
|
|
# The public surface, stated rather than discovered. Without this, `dir(gifsmith)`
|
|
# returned 24 names of which nine were incidental imports (glob, os, shutil,
|
|
# subprocess, sys, traceback...) — several of them things a scene author should
|
|
# never touch — and a worker spent a whole code_exec call running exactly that
|
|
# to find out what it could import. Keep in sync when adding a re-export.
|
|
__all__ = [
|
|
"Animation",
|
|
# matplotlib artists (mode="mpl")
|
|
"Arc", "Circle", "Ellipse", "FancyBboxPatch", "PathPatch", "Path",
|
|
"Polygon", "Rectangle", "Wedge",
|
|
# PIL (mode="pil")
|
|
"Image", "ImageDraw", "ImageFont",
|
|
# the two libraries themselves, for anything not re-exported above
|
|
"np", "plt",
|
|
# caps a scene author may want to read
|
|
"GIF_MAX_SECONDS", "GIF_MAX_BYTES",
|
|
]
|
|
|
|
# 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
|
|
# Measured on busy 640x480 matplotlib frames in this sandbox: ~86 KiB per PNG at
|
|
# compress_level=1, i.e. 88064 / 307200 px. This decides whether a render streams
|
|
# instead of staging frames on disk, so it must not read low — erring low
|
|
# reintroduces the ENOSPC this harness exists to prevent, and erring high only
|
|
# turns a borderline short piece into an MP4.
|
|
PNG_BYTES_PER_PIXEL = 0.29
|
|
|
|
# Contact-sheet budget. Nine tiles is a 3x3 grid that vision models read
|
|
# reliably; spread across TIME for one scene, across scenes for many.
|
|
CONTACT_SHEET_MAX_TILES = 9
|
|
CONTACT_TILE_W = 300
|
|
|
|
_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
|
|
# (t, thumbnail) pairs for the contact sheet. Thumbnails, not full
|
|
# frames: nine 640x480 RGB copies is ~8 MiB of RAM held for the whole
|
|
# render, and the sheet downscales them anyway.
|
|
self.samples = []
|
|
|
|
|
|
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", **kwargs):
|
|
# `background` is accepted as an alias for `bg` because it is the obvious
|
|
# synonym and agents reached for it; `force` is rejected loudly because
|
|
# `render(force="mp4")` DOES take it, which makes it read like an
|
|
# Animation-level setting. Both were real failures, one code_exec round
|
|
# trip each, and both are the kind a better message fixes for free.
|
|
if "background" in kwargs:
|
|
bg = kwargs.pop("background")
|
|
if kwargs:
|
|
valid = ["width", "height", "fps", "dpi", "bg (or background)"]
|
|
bad = sorted(kwargs)
|
|
hints = []
|
|
for k in bad:
|
|
near = difflib.get_close_matches(
|
|
k, ["width", "height", "fps", "dpi", "bg", "background"], n=1, cutoff=0.6)
|
|
if near:
|
|
hints.append(f"{k!r} — did you mean {near[0]!r}?")
|
|
elif k == "force":
|
|
hints.append("'force' belongs to render(force='mp4'/'gif'), "
|
|
"not to Animation()")
|
|
else:
|
|
hints.append(repr(k))
|
|
raise TypeError(
|
|
"Animation() got unexpected keyword argument(s): "
|
|
+ "; ".join(hints)
|
|
+ ". Valid: " + ", ".join(valid)
|
|
+ ". Everything about the OUTPUT (format, size, audio) is set on "
|
|
"render(), not here.")
|
|
|
|
# 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 _dry_run_scenes(self):
|
|
"""Render every scene ONCE at t=0 before the real pass.
|
|
|
|
Scene functions fail at render time, not definition time, so a NameError
|
|
in scene 4 used to surface only after scenes 0-3 had been fully rendered
|
|
— and, when the pipe was open, after frames had already gone into the
|
|
encoder. Two production workers lost a whole render each to
|
|
`RuntimeError: every scene failed`, learning about one broken name per
|
|
attempt.
|
|
|
|
One frame per scene costs almost nothing and reports EVERY broken scene
|
|
in a single pass. A scene that fails here is marked and skipped by the
|
|
real loop rather than re-run to fail identically; if they ALL fail there
|
|
is nothing to encode, so say so now instead of after the setup.
|
|
"""
|
|
for i, sc in enumerate(self._scenes):
|
|
try:
|
|
(self._render_pil if sc.mode == "pil" else self._render_mpl)(sc, 0.0)
|
|
except Exception: # noqa: BLE001
|
|
sc.error = traceback.format_exc(limit=6)
|
|
print(f"[gifsmith] SCENE {i} ({sc.title!r}) FAILED its t=0 dry "
|
|
f"run — skipped, other scenes continue:\n{sc.error}",
|
|
file=sys.stderr, flush=True)
|
|
broken = [i for i, sc in enumerate(self._scenes) if sc.error]
|
|
if broken and len(broken) == len(self._scenes):
|
|
raise RuntimeError(
|
|
"every scene failed before rendering started — see the "
|
|
f"{len(broken)} traceback(s) above. They usually share ONE "
|
|
"cause: a helper called with a keyword it does not take, or a "
|
|
"name that differs by a character. Fix it and re-run the whole "
|
|
"program in a fresh code_exec call, remembering gifsmith.py in "
|
|
"files_in. Nothing was encoded and no GPU time was spent.")
|
|
return broken
|
|
|
|
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(...)")
|
|
# Before ANY setup: no scratch dir, no ffmpeg pipe, no frames.
|
|
self._dry_run_scenes()
|
|
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)
|
|
est_mb = est_frames * self.width * self.height * PNG_BYTES_PER_PIXEL / 1048576
|
|
|
|
# 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)
|
|
|
|
# Tile budget for the contact sheet, spread across the scenes. One scene
|
|
# gets the whole budget in TIME, which is the case that was broken: a
|
|
# loop's interesting content is by construction not at any single
|
|
# instant.
|
|
per_scene = max(1, CONTACT_SHEET_MAX_TILES // len(self._scenes))
|
|
|
|
for i, sc in enumerate(self._scenes):
|
|
if sc.error:
|
|
continue # already reported by the dry run; don't fail it twice
|
|
start = self._count
|
|
n = max(1, int(round(sc.seconds * self.fps)))
|
|
try:
|
|
want = self._sample_frames(n, per_scene)
|
|
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 k in want:
|
|
sc.samples.append((t, img.resize(
|
|
(CONTACT_TILE_W, self._tile_h()), Image.LANCZOS)))
|
|
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)
|
|
# Its partial frames are dropped below; its samples must go too,
|
|
# or the sheet advertises a scene that never shipped.
|
|
sc.samples = []
|
|
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.")
|
|
|
|
try:
|
|
art = self._encode(total_s, long_edge, force, audio)
|
|
except Exception: # noqa: BLE001
|
|
# A half-written final.* at the top of /workspace still comes back as
|
|
# a files_out file_id, and a broken artifact that LOOKS deliverable is
|
|
# worse than none — the agent would ship it. Take it with us.
|
|
for leftover in ("/workspace/final.mp4", "/workspace/final.gif"):
|
|
if os.path.exists(leftover):
|
|
os.remove(leftover)
|
|
shutil.rmtree(self._scratch, ignore_errors=True)
|
|
raise
|
|
sheet_tiles = self._contact_sheet() or 0
|
|
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}")
|
|
ts = ", ".join(f"{t:.2f}" for sc in self._scenes for (t, _th) in sc.samples)
|
|
print(f"[gifsmith] still: /workspace/still.png — {sheet_tiles} frames "
|
|
f"sampled across the animation at t={ts} (each tile is labelled "
|
|
"with its t). Call image_describe on ITS file_id from files_out; "
|
|
"if something is missing from one tile, check whether it is "
|
|
"present in another before re-rendering.")
|
|
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.
|
|
|
|
stderr goes to a FILE, not a pipe: nobody drains a stderr pipe until the
|
|
encode is done, so a chatty ffmpeg would fill the 64 KiB pipe buffer,
|
|
block, stop reading stdin, and deadlock the render halfway through."""
|
|
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]
|
|
self._pipe_err = open(os.path.join(self._scratch, "ffmpeg.log"), "w+b")
|
|
return subprocess.Popen(cmd, stdin=subprocess.PIPE,
|
|
stdout=subprocess.DEVNULL, stderr=self._pipe_err)
|
|
|
|
def _close_pipe(self):
|
|
try:
|
|
self._pipe.stdin.close()
|
|
except OSError:
|
|
# ffmpeg already died; its exit code + log below say why.
|
|
pass
|
|
rc = self._pipe.wait()
|
|
self._pipe_err.seek(0)
|
|
err = self._pipe_err.read().decode(errors="replace")
|
|
self._pipe_err.close()
|
|
self._pipe = None
|
|
if rc != 0:
|
|
raise RuntimeError(f"ffmpeg (streaming) exited {rc}:\n{err[-2000:]}")
|
|
|
|
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:
|
|
# -framerate applies to the INPUT. Without it ffmpeg reads a PNG
|
|
# sequence at its default 25fps, and a fps= filter then resamples
|
|
# 25 -> self.fps: on the #1510 render that silently dropped 91 of
|
|
# 207 frames (44%) and played the rest too fast. _mp4 below always
|
|
# passed -framerate; this path never did. With the input rate set
|
|
# correctly the fps= filter is redundant — and removing it is what
|
|
# takes the output from 205 frames back to all 207.
|
|
vf = (f"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",
|
|
"-framerate", str(self.fps), "-pattern_type", "glob",
|
|
"-i", os.path.join(self._scratch, "frame_*.png"), "-vf", vf, out])
|
|
if _which("gifsicle"):
|
|
# --colors 256 is load-bearing for DISCORD, not for size.
|
|
#
|
|
# gifski gives every frame its OWN local colour table (that is where
|
|
# its quality comes from). Stack gifsicle's cross-frame transparency
|
|
# on top and the result is a GIF that gifsicle itself refuses to
|
|
# reopen: "too complex to unoptimize — local colour tables or complex
|
|
# transparency". PIL, ffmpeg and macOS Preview all render it fine;
|
|
# Discord's decoder does not, and paints later scenes with stale rows,
|
|
# ghosted captions and holes punched in solid shapes (#1510).
|
|
#
|
|
# Collapsing to ONE global palette costs ~6/255 mean colour error
|
|
# (visible as slight banding in gradients, since one palette now
|
|
# spans every scene) and is the price of playing in the client that
|
|
# actually matters. It also came out SMALLER on the reported file:
|
|
# 1210 KB -> 915 KB.
|
|
_run(["gifsicle", "--colors", "256", "-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 _tile_h(self):
|
|
return max(1, round(self.height * CONTACT_TILE_W / self.width))
|
|
|
|
@staticmethod
|
|
def _sample_frames(n, count):
|
|
"""Frame indices to keep for the contact sheet, spread evenly over the
|
|
scene: t = 0, 1/count, ... (count-1)/count.
|
|
|
|
A scene with very few frames yields fewer samples than asked for, since
|
|
distinct t values collide onto the same frame index; the sheet simply
|
|
has fewer tiles, which is the honest outcome.
|
|
|
|
Deliberately NOT ending at t=1.0. A loop is built to return to its
|
|
starting state, so its last frame is the one instant guaranteed to show
|
|
none of the motion — which is exactly how a worker spent three
|
|
re-renders chasing a tear its critic could not see, because the still
|
|
was the frame after the tear had faded."""
|
|
if n <= 1:
|
|
return {0}
|
|
count = max(1, min(count, n))
|
|
return {min(n - 1, round(j / count * (n - 1))) for j in range(count)}
|
|
|
|
def _contact_sheet(self, path="/workspace/still.png"):
|
|
"""A grid sampled across TIME, one labelled tile per sample.
|
|
|
|
It used to be one frame per SCENE, and a single-scene animation — the
|
|
common case — short-circuited to a bare frame save. Every single-scene
|
|
worker in two production runs told us so unprompted, in the words of
|
|
the vision model: "this is a single frame (not a contact sheet)". The
|
|
critique loop was being fed one time point out of ~84 and asked to
|
|
judge motion.
|
|
"""
|
|
tiles = [(i, sc, t, thumb)
|
|
for i, sc in enumerate(self._scenes)
|
|
for (t, thumb) in sc.samples]
|
|
if not tiles:
|
|
return
|
|
cols = min(3, len(tiles))
|
|
rows = math.ceil(len(tiles) / cols)
|
|
tw, th = CONTACT_TILE_W, self._tile_h()
|
|
sheet = Image.new("RGB", (cols * tw, rows * (th + 20)), (20, 20, 24))
|
|
d = ImageDraw.Draw(sheet)
|
|
f = self.font(14)
|
|
multi = len(self._scenes) > 1
|
|
for k, (i, sc, t, thumb) in enumerate(tiles):
|
|
x, y = (k % cols) * tw, (k // cols) * (th + 20)
|
|
sheet.paste(thumb, (x, y + 20))
|
|
# The t label is the point: it tells the critic WHEN it is looking,
|
|
# so "the tear is missing" can be read as "missing at t=0.75"
|
|
# rather than as a fault in the drawing.
|
|
label = f"t={t:.2f}"
|
|
if multi:
|
|
label = f"scene {i} {label}: {sc.title[:26]}"
|
|
elif sc.title:
|
|
label = f"{label} {sc.title[:30]}"
|
|
d.text((x + 4, y + 3), label, fill=(235, 235, 240), font=f)
|
|
sheet.save(path)
|
|
return len(tiles)
|
|
|
|
|
|
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}
|