Files
manga-recap-pipeline/worker_render.py
T
kami ff6a512630 Reconstruct repo from Claude Code + codex transcripts
Working tree (including .git) was lost to an rm. Rebuilt by replaying Write/Edit/
Read/attachment events from 25 Claude sessions and 22 successful codex apply_patch
blocks into one timestamp-ordered timeline.

Verified against ground truth recorded in the transcripts: wc -l on 10 files and
ls -l on 5 files at 2026-07-18T13:13:44Z both match exactly; 18 files are
byte-identical to their newest ~/.claude/file-history blob.

See HANDOFF.md for sources, gaps, and how to rebuild .venv.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 02:42:41 +04:00

997 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# worker_render.py — stage 9 part 2 rendering. FastAPI :8008.
# per-scene: motion (ken burns, or parallax when layers present) + burned subtitles -> mp4 clip.
# chapter assembly: concat clips with crossfades -> chapter.mp4. all outputs to minio.
# ponytail: ken burns for both cases for now; layer-parallax is the upgrade path when the
# depth renderer is dialed in — the layer_uris are already threaded through the input.
import os, re, uuid, subprocess
from concurrent.futures import ThreadPoolExecutor
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import transport
from collage import plan_layout
app = FastAPI()
transport.install_logging(app, "render")
SHM = "/dev/shm"
W, H = 1080, 1920 # vertical 9:16
PAD_S = float(os.environ.get("PANEL_PAD_S", "0.4")) # #13 trailing silence per panel for pacing
MUSIC_BED = os.environ.get("MUSIC_BED", "") # #13 path/uri of a music track; empty -> no bed
MUSIC_GAIN = os.environ.get("MUSIC_GAIN", "0.18") # bed level before ducking
def _mc_from_uri(uri: str):
"""(manga_id, chapter_id) from s3://<bucket>/<manga_id>/<chapter_id>/...
the orchestrator doesn't pass ids to render/layers, but every input uri encodes them."""
parts = uri.replace("s3://", "").split("/")
return parts[1], parts[2]
def _ts(s):
h = int(s // 3600); m = int(s % 3600 // 60); sec = s % 60
return f"{h}:{m:02d}:{sec:05.2f}"
# 188: readable narration subtitles. Modes: off (burn nothing), minimal (small text, thin outline +
# soft shadow, NO box — least art occlusion), boxed (small text on a restrained ~50% box). Presets carry
# portrait/landscape safe-areas. ASS colour is &HAABBGGRR (alpha 00=opaque..FF=clear).
SUB_MODE = os.environ.get("SUB_MODE", "minimal").lower() # off | minimal | boxed
SUB_PRESETS = {
# (mode, orientation): fontsize, borderstyle(1=outline,3=box), outline/pad, shadow, marginV, side
("minimal", "portrait"): dict(fs=40, bs=1, outline=3, shadow=2, mv=0.055, side=110),
("minimal", "landscape"): dict(fs=32, bs=1, outline=3, shadow=2, mv=0.09, side=260),
("boxed", "portrait"): dict(fs=40, bs=3, outline=6, shadow=0, mv=0.055, side=110),
("boxed", "landscape"): dict(fs=32, bs=3, outline=6, shadow=0, mv=0.09, side=260),
}
def _wrap2(text: str, width: int) -> str:
"""188: keep a cue to at most 2 short lines. Greedy word-wrap to `width`; if it still needs a 3rd
line, truncate the 2nd with an ellipsis so a long cue never grows back into a paragraph block."""
words, lines, cur = (text or "").split(), [], ""
for w in words:
if cur and len(cur) + 1 + len(w) > width:
lines.append(cur); cur = w
if len(lines) == 2:
break
else:
cur = f"{cur} {w}".strip()
if len(lines) < 2:
lines.append(cur)
elif cur or len(words) > sum(len(l.split()) for l in lines):
lines[1] = lines[1].rstrip(".,") + "" # more words remained than fit two lines
return "\\N".join(l for l in lines if l)
def _ass_multi(events, path, mode=None, orientation="portrait"):
"""Burn caption events. events: [(text, start, end)] or [(text, start, end, align)] where align is an
ASS numpad alignment (2=bottom-center default, 8=top-center to dodge a low subject). mode off -> no
Dialogue lines (nothing burned). Style comes from SUB_PRESETS (188)."""
mode = (mode or SUB_MODE)
if mode == "off":
events = []
p = SUB_PRESETS.get((mode, orientation), SUB_PRESETS[("minimal", "portrait")])
fill = "&H80000000" if p["bs"] == 3 else "&H00000000" # boxed: ~50% box; minimal: opaque outline
fmt = ("Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, "
"Bold, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV")
mv = int(p["mv"] * (W if orientation == "landscape" else H))
style = ("Def,DejaVu Sans,%d,&H00FFFFFF,%s,&H80000000,0,%d,%d,%d,2,%d,%d,%d"
% (p["fs"], fill, p["bs"], p["outline"], p["shadow"], p["side"], p["side"], mv))
width = 34 if orientation == "portrait" else 46
lines = ""
for ev in events:
t, s, e = ev[0], ev[1], ev[2]
align = ev[3] if len(ev) > 3 else 2
# {\anN} overrides alignment per line so a cue can jump to the top over a low subject.
lines += "Dialogue: 0,%s,%s,Def,{\\an%d}%s\n" % (_ts(s), _ts(e), align, _wrap2(t, width))
with open(path, "w") as f:
f.write(
"[Script Info]\nScriptType: v4.00+\nPlayResX: %d\nPlayResY: %d\nWrapStyle: 0\n\n"
"[V4+ Styles]\nFormat: %s\nStyle: %s\n\n"
"[Events]\nFormat: Layer, Start, End, Style, Text\n%s" % (W, H, fmt, style, lines)
)
def _sub_align(camera: dict) -> int:
"""188: dodge the subject where we can. The director's focus point (camera.to=[x,y], y normalized
top->bottom) is the one subject location we already know — if it sits low in frame, put the caption
at the TOP (an8) so it doesn't cover the face; otherwise keep it bottom (an2).
ponytail: no face/bubble detector yet — upgrade to real bbox avoidance when one is wired in."""
to = (camera or {}).get("to")
if to and len(to) >= 2 and float(to[1]) > 0.6:
return 8
return 2
def _ass(text: str, dur: float, path: str):
_ass_multi([(text, 0.0, dur)], path)
def _audio_dur(path: str) -> float:
"""clip length = narration length. scene_timing arrives empty, so probe the audio."""
r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nk=1:nw=1", path], capture_output=True, text=True)
try:
return float(r.stdout.strip())
except ValueError:
return 0.0
ZMAX, ZPAN = 1.15, 1.18 # ken-burns zoom ceiling; constant zoom that gives pans room to travel
def _motion(camera: dict, frames: int) -> str:
"""#8 content-aware motion: map the vision `camera` block to a zoompan z/x/y expression.
vocab (spec-v3 direction schema): static|hold, zoom_in|zoom_out, pan_left/right/up/down,
dolly_to_subject (uses camera.to=[x,y] normalized), orbit, shake. default = gentle zoom_in
(the old ken-burns look) so panels without direction are unchanged.
x/y reference `zoom` for the live crop-window size; z is driven linearly by `on` (frame index)
over T frames so the move completes across the whole clip regardless of length."""
T = max(1, frames - 1)
eff = (camera or {}).get("effect", "zoom_in")
xc, yc = "iw/2-(iw/zoom/2)", "ih/2-(ih/zoom/2)" # centered crop
mx, my = "(iw-iw/zoom)", "(ih-ih/zoom)" # pan travel margin
if eff in ("static", "hold"):
z, x, y = "1.0", xc, yc
elif eff == "zoom_out":
z, x, y = f"{ZMAX}-{ZMAX-1:.3f}*on/{T}", xc, yc
elif eff == "pan_left":
z, x, y = f"{ZPAN}", f"{mx}*(1-on/{T})", yc
elif eff == "pan_right":
z, x, y = f"{ZPAN}", f"{mx}*on/{T}", yc
elif eff == "pan_up":
z, x, y = f"{ZPAN}", xc, f"{my}*(1-on/{T})"
elif eff == "pan_down":
z, x, y = f"{ZPAN}", xc, f"{my}*on/{T}"
elif eff == "dolly_to_subject":
to = (camera or {}).get("to") or [0.5, 0.35]
tx, ty = min(max(float(to[0]), 0.0), 1.0), min(max(float(to[1]), 0.0), 1.0)
z = f"1+{ZMAX-1:.3f}*on/{T}"
x = f"(iw/2+({tx}*iw-iw/2)*on/{T})-(iw/zoom/2)" # crop center eases toward subject
y = f"(ih/2+({ty}*ih-ih/2)*on/{T})-(ih/zoom/2)"
elif eff == "shake":
z, x, y = "1.06", f"{xc}+8*sin(on*1.5)", f"{yc}+8*cos(on*1.3)"
elif eff == "orbit":
z = "1.12"
x, y = f"{xc}+(iw*0.03)*sin(6.283*on/{T})", f"{yc}+(ih*0.03)*cos(6.283*on/{T})"
else: # zoom_in (default ken burns)
z, x, y = f"1+{ZMAX-1:.3f}*on/{T}", xc, yc
return f"zoompan=z='{z}':x='{x}':y='{y}':d={frames}:s={W}x{H}:fps=25"
def scene_cmd(img: str, audio: str, ass: str, out: str, dur: float, camera: dict = None,
pad: float = PAD_S) -> list:
"""ffmpeg: still panel over a blurred fill of itself + content-aware motion, burned subs, 9:16.
#5 blurred bg replaces black bars: one copy scaled to COVER + blurred, the fitted panel on top.
#13 pad seconds of trailing silence (last frame held) give the panel a beat before the next."""
fps = 25
frames = max(1, int((dur + pad) * fps)) # hold the last frame through the pad
# overlay's W/H/w/h are ffmpeg's main/overlay dims -- kept literal (no f-string braces).
fc = (
f"[0:v]split=2[bg][fg];"
f"[bg]scale={W}:{H}:force_original_aspect_ratio=increase,crop={W}:{H},boxblur=20:2[bgb];"
f"[fg]scale={W}:{H}:force_original_aspect_ratio=decrease[fgs];"
f"[bgb][fgs]overlay=(W-w)/2:(H-h)/2,"
f"{_motion(camera, frames)},"
f"ass={ass}[v];"
f"[1:a]apad=pad_dur={pad:.3f}[a]" # trailing silence to match the held frames
)
return ["ffmpeg", "-y", "-loop", "1", "-i", img, "-i", audio,
"-filter_complex", fc, "-map", "[v]", "-map", "[a]",
"-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "192k", "-shortest", out]
class SceneInput(BaseModel):
panel_uri: str
layer_uris: list = []
audio_uri: str = ""
narration_text: str = ""
panel_id: str = ""
scene_timing: dict = {}
camera: dict = {} # #8 direction block from vision: {"effect": "...", "to": [x,y]}
manga_id: str = ""
chapter_id: str = ""
@app.post("/render/scene")
async def render_scene(data: SceneInput):
tag = uuid.uuid4().hex[:8]
img = transport.get(data.panel_uri, f"{SHM}/rnd_{tag}.png")
audio = transport.get(data.audio_uri, f"{SHM}/rnd_{tag}.wav")
# #1: real duration = narration length; scene_timing is empty in practice, 4.0 last-resort.
dur = (_audio_dur(audio)
or float(data.scene_timing.get("end", 0)) - float(data.scene_timing.get("start", 0))
or 4.0)
ass = f"{SHM}/rnd_{tag}.ass"
_ass(data.narration_text, dur, ass)
out = f"{SHM}/rnd_{tag}.mp4"
subprocess.run(scene_cmd(img, audio, ass, out, dur, data.camera), check=True, capture_output=True)
manga_id, chapter_id = _mc_from_uri(data.panel_uri)
uri = f"s3://manga/{manga_id}/{chapter_id}/clips/{data.panel_id or 'p'}.mp4"
transport.put(out, uri)
for p in (img, audio, ass, out):
os.remove(p)
return {"clip_uri": uri, "duration": dur}
# #6 multi-panel composite: a group of small panels shares ONE shot (vertical stack). each panel
# keeps its own narration + audio (script/tts stay per-panel); they play in sequence while a moving
# highlight marks the active panel -- that's the "swap the front panel while audio plays" beat.
def _stack_still_cmd(images: list, out: str, rowh: int) -> list:
"""compose N panels fitted into equal vertical rows on one W×H still (black gutters)."""
n = len(images)
cmd = ["ffmpeg", "-y"]
for im in images:
cmd += ["-i", im]
parts = [f"[{i}:v]scale={W}:{rowh}:force_original_aspect_ratio=decrease,"
f"pad={W}:{rowh}:(ow-iw)/2:(oh-ih)/2:color=black[p{i}]" for i in range(n)]
stacked = "".join(f"[p{i}]" for i in range(n))
fc = ";".join(parts) + f";{stacked}vstack=inputs={n},pad={W}:{H}:0:0:color=black[v]"
return cmd + ["-filter_complex", fc, "-map", "[v]", "-frames:v", "1", out]
# D#6 revisited: camera-traversal composite. instead of vstacking cropped panels (black gutters,
# "wrong look"), pan across the REAL page — hold on each member's panel, quick-ease to the next,
# partial neighbours stay visible. groups are always single-page (grouping.py), so one page image.
TRAVERSE_EASE = 0.5 # seconds of quick slide between held panels
WIN_MARGIN = 1.6 # crop window height = tallest member bbox * this (room for neighbour reveal)
def _framed_window(pw: int, ph: int, bboxes: list) -> tuple:
"""one constant 9:16 crop window (px) sized to hold the tallest member with margin, aspect
preserved, clamped to the page. constant size => only x,y animate (crop supports per-frame x/y,
not per-frame w/h)."""
ar = W / H
ch = max((b[3] for b in bboxes), default=ph) * WIN_MARGIN
cw = ch * ar
if cw > pw:
cw, ch = pw, pw / ar
if ch > ph:
ch, cw = ph, ph * ar
return int(cw), int(ch)
def _win_tl(bbox: list, cw: int, ch: int, pw: int, ph: int) -> tuple:
"""top-left of the window centered on a bbox, clamped so it stays inside the page."""
cx, cy = bbox[0] + bbox[2] / 2, bbox[1] + bbox[3] / 2
return (min(max(cx - cw / 2, 0), pw - cw), min(max(cy - ch / 2, 0), ph - ch))
def _pan_expr(vals: list, segs: list, ease: float) -> str:
"""piecewise ffmpeg expr in t: hold vals[i] through beat i, linear-ease to vals[i+1] over the
beat's last `ease`s; final beat just holds. segs=[(start,end)] per beat."""
n = len(vals)
expr = f"{vals[-1]:.1f}"
for i in range(n - 2, -1, -1):
s, e = segs[i]
he = e - ease
a, b = vals[i], vals[i + 1]
beat = f"if(lt(t,{he:.3f}),{a:.1f},({a:.1f}+({b - a:.1f})*(t-{he:.3f})/{ease:.3f}))"
expr = f"if(lt(t,{e:.3f}),{beat},{expr})"
return expr
def traverse_cmd(page: str, audios: list, ass: str, segs: list, bboxes: list,
pw: int, ph: int, out: str, dur: float) -> list:
"""pan a constant 9:16 window across the page: hold on each member, quick-ease to the next."""
ease = min(TRAVERSE_EASE, min((e - s for s, e in segs), default=1.0) / 2)
cw, ch = _framed_window(pw, ph, bboxes)
tls = [_win_tl(b, cw, ch, pw, ph) for b in bboxes]
xexpr = _pan_expr([t[0] for t in tls], segs, ease)
yexpr = _pan_expr([t[1] for t in tls], segs, ease)
n = len(audios)
aconcat = "".join(f"[{i + 1}:a]" for i in range(n)) + f"concat=n={n}:v=0:a=1[a]"
fc = (f"[0:v]crop={cw}:{ch}:x='{xexpr}':y='{yexpr}',scale={W}:{H},setsar=1,"
f"ass={ass}[v];{aconcat}")
cmd = ["ffmpeg", "-y", "-loop", "1", "-i", page]
for a in audios:
cmd += ["-i", a]
return cmd + ["-filter_complex", fc, "-map", "[v]", "-map", "[a]", "-t", f"{dur:.3f}",
"-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "192k", out]
def composite_cmd(still: str, audios: list, ass: str, segs: list, rowh: int, out: str, dur: float) -> list:
"""loop the stacked still for the whole group, concat each panel's audio in order, burn the
per-segment subtitles, and outline the active row during its narration. segs=[(start,end)]."""
n = len(audios)
hl = "".join(
f"drawbox=x=0:y={i*rowh}:w={W}:h={rowh}:color=yellow@0.85:t=6:enable='between(t,{s:.2f},{e:.2f})',"
for i, (s, e) in enumerate(segs))
cmd = ["ffmpeg", "-y", "-loop", "1", "-i", still]
for a in audios:
cmd += ["-i", a]
aconcat = "".join(f"[{i+1}:a]" for i in range(n)) + f"concat=n={n}:v=0:a=1[a]"
fc = f"[0:v]{hl}ass={ass}[v];{aconcat}"
return cmd + ["-filter_complex", fc, "-map", "[v]", "-map", "[a]", "-t", f"{dur:.3f}",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", out]
class CompositeInput(BaseModel):
# ordered members: [{panel_uri, audio_uri, narration_text, bbox?, page_uri?}]. bbox=[x,y,w,h] on
# the source page + page_uri enable the camera-traversal look; absent -> vstack fallback.
panels: list = []
panel_id: str = "" # leader panel id -> clip key (assemble picks up one clip per group)
def _img_size(path: str) -> tuple:
r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
"stream=width,height", "-of", "csv=p=0:s=x", path], capture_output=True, text=True)
w, h = r.stdout.strip().split("x")
return int(w), int(h)
@app.post("/render/composite")
async def render_composite(data: CompositeInput):
if len(data.panels) < 2:
raise HTTPException(400, "composite needs >=2 panels")
tag = uuid.uuid4().hex[:8]
auds = [transport.get(p["audio_uri"], f"{SHM}/cmp_{tag}_{i}.wav") for i, p in enumerate(data.panels)]
durs = [_audio_dur(a) or 3.0 for a in auds]
segs, t = [], 0.0
for d in durs:
segs.append((t, t + d)); t += d
n = len(data.panels)
ass, out = f"{SHM}/cmp_{tag}.ass", f"{SHM}/cmp_{tag}.mp4"
_ass_multi([(data.panels[i].get("narration_text", ""), segs[i][0], segs[i][1]) for i in range(n)], ass)
# camera-traversal path: needs a shared page + a bbox per member (grouping keeps groups single-page)
bboxes = [p.get("bbox") for p in data.panels]
page_uri = data.panels[0].get("page_uri")
if page_uri and all(b and len(b) == 4 for b in bboxes):
page = transport.get(page_uri, f"{SHM}/cmp_{tag}_pg.png")
pw, ph = _img_size(page)
subprocess.run(traverse_cmd(page, auds, ass, segs, bboxes, pw, ph, out, t),
check=True, capture_output=True)
os.remove(page)
else: # fallback: legacy vstack (black gutters) when geometry is unavailable
imgs = [transport.get(p["panel_uri"], f"{SHM}/cmp_{tag}_{i}.png") for i, p in enumerate(data.panels)]
rowh = H // n
still = f"{SHM}/cmp_{tag}_s.png"
subprocess.run(_stack_still_cmd(imgs, still, rowh), check=True, capture_output=True)
subprocess.run(composite_cmd(still, auds, ass, segs, rowh, out, t), check=True, capture_output=True)
for p in imgs + [still]:
os.remove(p)
manga_id, chapter_id = _mc_from_uri(data.panels[0]["panel_uri"])
uri = f"s3://manga/{manga_id}/{chapter_id}/clips/{data.panel_id or 'p'}.mp4"
transport.put(out, uri)
for p in auds + [ass, out]:
os.remove(p)
return {"clip_uri": uri, "duration": t}
# director groups: render a beat's member panels as sequential FULL-FRAME shots (each its own
# scene_cmd clip: blurred bg + ken-burns + its narration), then xfade-chain them into one clip with
# the per-member transition. replaces the vstack/traverse composite look with real transitioned crops.
class GroupInput(BaseModel):
# ordered members: [{panel_uri, audio_uri, narration_text, camera, transition}]
panels: list = []
panel_id: str = "" # leader id -> clip key
@app.post("/render/group")
async def render_group(data: GroupInput):
if len(data.panels) < 2:
raise HTTPException(400, "group needs >=2 panels")
tag = uuid.uuid4().hex[:8]
clips, durs, cleanup = [], [], []
for i, p in enumerate(data.panels):
img = transport.get(p["panel_uri"], f"{SHM}/grp_{tag}_{i}.png")
aud = transport.get(p["audio_uri"], f"{SHM}/grp_{tag}_{i}.wav")
dur = _audio_dur(aud) or 3.0
ass = f"{SHM}/grp_{tag}_{i}.ass"
_ass(p.get("narration_text", ""), dur, ass)
out = f"{SHM}/grp_{tag}_{i}.mp4"
subprocess.run(scene_cmd(img, aud, ass, out, dur, p.get("camera") or {}),
check=True, capture_output=True)
clips.append(out)
durs.append(_audio_dur(out) or (dur + PAD_S)) # full clip length incl. trailing pad
cleanup += [img, aud, ass, out]
# transition INTO member i+1 = member i's transition (transition out of the leaving shot).
trans = [(p.get("transition") or "cut") for p in data.panels[:-1]]
fg, vmap, amap = _xfade_chain(durs, trans)
final = f"{SHM}/grp_{tag}.mp4"
cmd = ["ffmpeg", "-y"]
for c in clips:
cmd += ["-i", c]
cmd += ["-filter_complex", fg, "-map", vmap, "-map", amap,
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", final]
subprocess.run(cmd, check=True, capture_output=True)
manga_id, chapter_id = _mc_from_uri(data.panels[0]["panel_uri"])
uri = f"s3://manga/{manga_id}/{chapter_id}/clips/{data.panel_id or 'p'}.mp4"
transport.put(final, uri)
total = _audio_dur(final) or sum(durs)
for f in cleanup + [final]:
if os.path.exists(f):
os.remove(f)
return {"clip_uri": uri, "duration": total}
# scene-level narration: ONE narration+audio spans a whole beat, shown over its member panels as a
# ken-burns montage. the beat's single subtitle holds across every image; images split the narration
# duration equally. (per-panel narration is gone at this granularity — the beat is the story unit.)
class BeatInput(BaseModel):
panel_uris: list = [] # ordered member images (>=1)
audio_uri: str = "" # the beat's single narration audio
narration_text: str = ""
cameras: list = [] # per-image camera block; short/empty -> default zoom_in
weights: list = [] # 185: per-image relative screen-time; short/empty/degenerate -> equal split
panel_id: str = "" # leader id -> clip key
BEAT_MIN_PANEL_S = 1.0 # 185: no member panel flashes by faster than this
def _beat_slices(D: float, n: int, weights: list = None) -> list:
"""185: split beat duration D across n member panels by content weight, not evenly.
Each panel gets >= BEAT_MIN_PANEL_S so a low-weight panel never flashes. Degenerate input
(no/short weights, non-positive sum, or D too small for the floors) -> deterministic equal split.
Pure + total: sum(result) == D always."""
if n <= 0:
return []
if not weights or len(weights) < n or D <= n * BEAT_MIN_PANEL_S:
return [D / n] * n
w = [max(0.0, float(x)) for x in weights[:n]]
s = sum(w)
if s <= 0:
return [D / n] * n
floor = n * BEAT_MIN_PANEL_S
free = D - floor # distribute only the time above the floors by weight
return [BEAT_MIN_PANEL_S + free * (wi / s) for wi in w]
def _split_cues(text: str) -> list:
"""187: break the beat's one flowing narration into sentence/phrase cues. Split on sentence-ending
punctuation (kept) and newlines so short exclamations like 'Hold up.' stay their own cue."""
text = (text or "").strip()
if not text:
return []
parts = re.split(r"(?<=[.!?…。!?])\s+|\n+", text)
return [p.strip() for p in parts if p and p.strip()]
def cue_plan(text: str, D: float, slices: list) -> list:
"""187: a timed subtitle plan for one beat. Each cue = one sentence, timed along the SAME [0,D]
timeline as the per-panel slices, so a cue never appears before the panel on screen when it starts
(no future-panel facts leak early). Cue durations are length-weighted with a readable floor (reusing
_beat_slices); each cue is mapped to the member panel whose window contains its start. Pure/testable.
Returns [{text, start, end, panel_index}] in order."""
cues = _split_cues(text)
if not cues or D <= 0:
return []
durs = _beat_slices(D, len(cues), [len(c) for c in cues]) # length-weighted, min-hold, equal fallback
bounds, t = [], 0.0
for s in slices: # panel on-screen windows
bounds.append((t, t + s)); t += s
events, t = [], 0.0
for c, d in zip(cues, durs):
start, end = t, min(t + d, D)
pi = next((k for k, (a, b) in enumerate(bounds) if a <= start < b), max(0, len(bounds) - 1))
# clamp start to the panel's reveal so the caption is never ahead of its image
events.append({"text": c, "start": max(start, bounds[pi][0]) if bounds else start,
"end": end, "panel_index": pi})
t = end
return events
def beat_cmd(imgs: list, audio: str, ass: str, out: str, D: float, cameras: list = None,
weights: list = None) -> list:
"""ffmpeg: N images shown as a ken-burns montage under ONE narration audio; the last image holds
through the trailing pad. each image = blurred-fill bg + fitted panel + its camera move; the single
burned subtitle (in `ass`) spans the whole beat. 185: per-image screen-time is content-weighted
(see _beat_slices), equal split when weights are absent. pure -> testable without S3."""
cameras = cameras or []
n, fps = len(imgs), 25
slices = _beat_slices(D, n, weights) # 185: content-weighted, equal-split fallback
# one frame per image (no -loop): zoompan d=frames expands that single frame to exactly `frames`
# output frames = seg seconds. looping instead would feed many frames and zoompan multiplies each.
cmd = ["ffmpeg", "-y"]
for img in imgs:
cmd += ["-i", img]
cmd += ["-i", audio]
parts = []
for i in range(n):
cam = cameras[i] if i < len(cameras) else {}
seg = slices[i] + (PAD_S if i == n - 1 else 0.0)
frames = max(1, int(seg * fps))
parts.append(
f"[{i}:v]split=2[bg{i}][fg{i}];"
f"[bg{i}]scale={W}:{H}:force_original_aspect_ratio=increase,crop={W}:{H},boxblur=20:2[bgb{i}];"
f"[fg{i}]scale={W}:{H}:force_original_aspect_ratio=decrease[fgs{i}];"
f"[bgb{i}][fgs{i}]overlay=(W-w)/2:(H-h)/2,{_motion(cam, frames)}[v{i}]"
)
concat_in = "".join(f"[v{i}]" for i in range(n))
fc = (";".join(parts) + f";{concat_in}concat=n={n}:v=1:a=0[vc];"
f"[vc]ass={ass}[v];[{n}:a]apad=pad_dur={PAD_S:.3f}[a]")
cmd += ["-filter_complex", fc, "-map", "[v]", "-map", "[a]",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", out]
return cmd
@app.post("/render/beat")
async def render_beat(data: BeatInput):
if not data.panel_uris:
raise HTTPException(400, "beat needs >=1 panel")
tag = uuid.uuid4().hex[:8]
audio = transport.get(data.audio_uri, f"{SHM}/bt_{tag}.wav")
D = _audio_dur(audio) or 4.0
imgs = [transport.get(u, f"{SHM}/bt_{tag}_{i}.png") for i, u in enumerate(data.panel_uris)]
ass = f"{SHM}/bt_{tag}.ass"
# 187: timed sentence cues instead of one beat-long paragraph; same slice timeline as the montage,
# so each line surfaces with its panel. Empty/unsplittable text -> single caption (old behaviour).
slices = _beat_slices(D, len(imgs), data.weights)
cues = cue_plan(data.narration_text, D, slices)
cams = data.cameras or []
if cues:
cues[-1]["end"] = D + PAD_S # hold the closing line through the trailing pad
# 188: each cue dodges its panel's subject (top vs bottom) via the director's focus point.
_ass_multi([(c["text"], c["start"], c["end"],
_sub_align(cams[c["panel_index"]] if c["panel_index"] < len(cams) else {}))
for c in cues], ass)
else:
_ass_multi([(data.narration_text, 0.0, D + PAD_S)], ass)
out = f"{SHM}/bt_{tag}.mp4"
subprocess.run(beat_cmd(imgs, audio, ass, out, D, data.cameras, data.weights),
check=True, capture_output=True)
manga_id, chapter_id = _mc_from_uri(data.panel_uris[0])
uri = f"s3://manga/{manga_id}/{chapter_id}/clips/{data.panel_id or 'p'}.mp4"
transport.put(out, uri)
total = _audio_dur(out) or (D + PAD_S)
for f in imgs + [audio, ass, out]:
if os.path.exists(f):
os.remove(f)
# 187: cue plan (text/timing/panel_index per line) returned so the orchestrator can persist it for
# the review UI to edit mapping+timing, and so #183's collage can key reveals off the same cues.
return {"clip_uri": uri, "duration": total, "cues": cues}
# --- 183: animated manga collage ------------------------------------------------------------------
# A beat's member panels laid out as a motion-comic collage instead of a full-frame ken-burns montage:
# a blurred plate of the dominant panel fills the frame, sharp aspect-fit panels rest in a planned
# template (collage.plan_layout), and non-dominant panels slide into place over a brief entrance while
# the dominant one resolves by scale. Holds stay crisp. Behind the pipeline's COLLAGE flag.
# ponytail ceilings (visual polish, QA-tuned against the reference video, no still to check here):
# - transition-only directional/radial MOTION BLUR is not applied (clean slide/scale entrance); the
# beat-to-beat "streak" transition still rides the assemble-stage xfade. Add tblend accumulation
# when a fixture shows the clean slide reads too flat.
# - per-panel slow "drift" during the hold is omitted (static hold); add a gentle zoompan when needed.
class CollageInput(BaseModel):
panel_uris: list = [] # ordered member images (reading order), 1..4
audio_uri: str = ""
narration_text: str = ""
weights: list = [] # 185 timing hints (also picks the dominant panel = argmax)
cameras: list = [] # for 188 subtitle subject-dodge
rtl: bool = True
active: int = -1 # dominant panel index; <0 -> argmax(weights) or 0
panel_id: str = ""
def collage_cmd(imgs, plate_i, rects, entrances, z_order, audio, ass, out, D, trans):
"""ffmpeg graph: blurred plate of imgs[plate_i] + each sharp panel scaled to its resting rect with a
restrained drop shadow, composited back-to-front (z_order), non-dominant panels sliding in from their
entrance offset over `trans` seconds. One narration audio; burned cues in `ass`. Pure -> testable."""
n, T = len(imgs), D + PAD_S
cmd = ["ffmpeg", "-y"]
for img in imgs:
cmd += ["-loop", "1", "-i", img] # still held for the whole clip; -t caps output
cmd += ["-i", audio]
parts = [f"[{plate_i}:v]scale={W}:{H}:force_original_aspect_ratio=increase,"
f"crop={W}:{H},boxblur=24:2,setsar=1[bg]"]
base = "bg"
for k, i in enumerate(z_order): # shadows first, at rest positions (static)
x, y, w, h = (int(round(v)) for v in rects[i])
parts.append(f"[{base}]drawbox=x={x + 7}:y={y + 7}:w={w}:h={h}:color=black@0.35:t=fill[sh{k}]")
base = f"sh{k}"
for i in range(n):
w, h = int(round(rects[i][2])), int(round(rects[i][3]))
parts.append(f"[{i}:v]scale={w}:{h},setsar=1[p{i}]")
cur = base
for k, i in enumerate(z_order):
x, y = int(round(rects[i][0])), int(round(rects[i][1]))
dx, dy = entrances[i]
# ease from (x+dx, y+dy) to (x, y) over `trans`s, then hold. commas safe inside the '...' quotes.
ease = f"max(0,1-t/{trans:.3f})"
parts.append(f"[{cur}][p{i}]overlay=x='{x}+({dx})*{ease}':y='{y}+({dy})*{ease}'[o{k}]")
cur = f"o{k}"
fc = ";".join(parts) + f";[{cur}]ass={ass}[v];[{n}:a]apad=pad_dur={PAD_S:.3f}[a]"
cmd += ["-filter_complex", fc, "-map", "[v]", "-map", "[a]", "-t", f"{T:.3f}",
"-r", "30", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", out]
return cmd
@app.post("/render/collage")
async def render_collage(data: CollageInput):
if not data.panel_uris:
raise HTTPException(400, "collage needs >=1 panel")
uris = data.panel_uris[:4] # planner templates cap at 4 readable panels
n = len(uris)
tag = uuid.uuid4().hex[:8]
audio = transport.get(data.audio_uri, f"{SHM}/cl_{tag}.wav")
D = _audio_dur(audio) or 4.0
imgs = [transport.get(u, f"{SHM}/cl_{tag}_{i}.png") for i, u in enumerate(uris)]
aspects = []
for p in imgs:
w, h = _img_size(p)
aspects.append(w / h if h else 1.0)
active = data.active if 0 <= data.active < n else (
max(range(n), key=lambda i: data.weights[i]) if len(data.weights) >= n else 0)
lay = plan_layout(aspects, data.rtl, active, (W, H))
# 185/187: content-weighted slices + timed cues, same as the montage path.
slices = _beat_slices(D, n, data.weights)
cues = cue_plan(data.narration_text, D, slices)
ass = f"{SHM}/cl_{tag}.ass"
if cues:
cues[-1]["end"] = D + PAD_S
cams = data.cameras or []
_ass_multi([(c["text"], c["start"], c["end"],
_sub_align(cams[c["panel_index"]] if c["panel_index"] < len(cams) else {}))
for c in cues], ass)
else:
_ass_multi([(data.narration_text, 0.0, D + PAD_S)], ass)
out = f"{SHM}/cl_{tag}.mp4"
subprocess.run(collage_cmd(imgs, active, lay["rects"], lay["entrances"], lay["z_order"],
audio, ass, out, D, lay["transition_s"]), check=True, capture_output=True)
manga_id, chapter_id = _mc_from_uri(uris[0])
uri = f"s3://manga/{manga_id}/{chapter_id}/clips/{data.panel_id or 'p'}.mp4"
transport.put(out, uri)
total = _audio_dur(out) or (D + PAD_S)
for f in imgs + [audio, ass, out]:
if os.path.exists(f):
os.remove(f)
return {"clip_uri": uri, "duration": total, "cues": cues, "template": lay["template"]}
# #6 non-generic transitions: map the direction schema's transition.type -> (xfade name, seconds).
# "cut" stays a hard cut via the concat fast-path; the rest re-encode through an xfade chain.
XFADE = {
"cut": ("fade", 0.0),
"crossfade": ("fade", 0.5),
"dissolve": ("dissolve", 0.5),
"fade_black": ("fadeblack", 0.6),
"fade_white": ("fadewhite", 0.6),
"wipe_left": ("wipeleft", 0.4),
"wipe_right": ("wiperight", 0.4),
"push": ("slideleft", 0.4),
}
# Chapter assembly used to open every clip in one ffmpeg process. A long chapter therefore created
# one enormous xfade graph: N decoders + N-1 full-frame filter stages, enough to exhaust RAM/VRAM and
# get ffmpeg SIGKILLed by the OOM killer. Assemble a bounded tree instead. Eight inputs keeps enough
# work in each encode to be efficient without letting decoder/filter threads grow with chapter size.
ASSEMBLE_BATCH = max(2, int(os.environ.get("ASSEMBLE_BATCH", "8")))
FFMPEG_THREADS = max(1, int(os.environ.get("FFMPEG_THREADS", "2")))
def _xfade_chain(durs: list, trans: list):
"""build a filter_complex that xfades N clips with per-boundary transitions, keeping audio in
sync via matching acrossfade. trans[i] is the transition OUT of clip i (boundary i->i+1).
returns (filtergraph, video_label, audio_label). offsets accumulate as clips overlap."""
# A clip whose duration probed as 0/unreadable must not poison the chain: with dur=0 the offset
# accumulator would run BACKWARDS (cum += dur - td), swallowing every later clip into a frozen
# overlap near the middle. Floor to a small positive length so the timeline stays monotonic.
durs = [d if (d and d > 0.1) else 0.1 for d in durs]
parts, vlast, alast, cum = [], "[0:v]", "[0:a]", durs[0]
for i in range(1, len(durs)):
name, td = XFADE.get(trans[i - 1] if i - 1 < len(trans) else "cut", XFADE["cut"])
td = max(0.05, min(td, durs[i - 1] - 0.05, durs[i] - 0.05)) # overlap fits in both clips
off = max(cum - td, 0)
parts.append(f"{vlast}[{i}:v]xfade=transition={name}:duration={td:.3f}:offset={off:.3f}[v{i}]")
parts.append(f"{alast}[{i}:a]acrossfade=d={td:.3f}[a{i}]")
vlast, alast, cum = f"[v{i}]", f"[a{i}]", cum + durs[i] - td
return ";".join(parts), vlast, alast
def _assemble_once(inputs: list[str], trans: list[str], out: str):
"""Assemble one bounded batch. `trans[i]` is the transition out of inputs[i]."""
fancy = len(inputs) >= 2 and any(t not in ("", "cut") for t in trans[:len(inputs) - 1])
if fancy:
durs = [_audio_dur(p) for p in inputs]
fg, vmap, amap = _xfade_chain(durs, trans)
cmd = ["ffmpeg", "-y", "-filter_complex_threads", str(FFMPEG_THREADS)]
# Input-side -threads limits each decoder; otherwise ffmpeg may create a decoder thread pool
# for every input in the batch in addition to the filter and libx264 pools.
for p in inputs:
cmd += ["-threads", "1", "-i", p]
cmd += ["-filter_complex", fg, "-map", vmap, "-map", amap,
"-c:v", "libx264", "-threads", str(FFMPEG_THREADS), "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "192k", out]
subprocess.run(cmd, check=True, capture_output=True)
return
# A cut-only batch is concatenated with the concat FILTER, not -c copy or the concat demuxer.
# The tree feeds xfade intermediates (irregular video timestamps) back in as inputs; the concat
# demuxer + -vsync cfr DROPS video frames to force CFR while audio survives -> the video ends up
# minutes short and freezes on a frame with narration playing on (the "one image, narration behind
# it" bug). The concat filter decodes and re-times every segment, so no frames are dropped. It needs
# N decoders, but the tree already bounds a batch to ASSEMBLE_BATCH inputs, so memory stays capped.
n = len(inputs)
cmd = ["ffmpeg", "-y"]
for p in inputs:
cmd += ["-i", p]
pre = "".join(f"[{i}:v]setsar=1,fps=30[v{i}];" for i in range(n))
fg = pre + "".join(f"[v{i}][{i}:a]" for i in range(n)) + f"concat=n={n}:v=1:a=1[v][a]"
cmd += ["-filter_complex", fg, "-map", "[v]", "-map", "[a]",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p",
"-threads", str(FFMPEG_THREADS), "-c:a", "aac", "-b:a", "192k", out]
subprocess.run(cmd, check=True, capture_output=True)
def _assemble_batched(inputs: list[str], transitions: list[str], out: str, tag: str,
cleanup: list[str]):
"""Bounded tree assembly preserving every original transition.
Each intermediate carries the transition OUT of its final source clip. That becomes the boundary
transition between intermediates in the next round, so batching does not change direction. A
chapter of 100 clips with batch=8 uses at most eight simultaneous inputs instead of 100.
"""
items = [{"path": p,
"out_transition": transitions[i] if i < len(transitions) else "cut",
"temporary": False}
for i, p in enumerate(inputs)]
round_no = 0
while len(items) > 1:
final_round = len(items) <= ASSEMBLE_BATCH
next_items = []
for start in range(0, len(items), ASSEMBLE_BATCH):
group = items[start:start + ASSEMBLE_BATCH]
if len(group) == 1 and not final_round:
next_items.append(group[0])
continue
target = out if final_round else f"{SHM}/asm_{tag}_r{round_no}_{start // ASSEMBLE_BATCH}.mp4"
_assemble_once([x["path"] for x in group], [x["out_transition"] for x in group], target)
if not final_round:
cleanup.append(target)
next_items.append({"path": target, "out_transition": group[-1]["out_transition"],
"temporary": not final_round})
# Intermediates from the previous round are no longer needed. Keep cleanup idempotent: remove
# them here for low /dev/shm usage and from the cleanup list so endpoint cleanup won't retry.
for item in items:
p = item["path"]
if item["temporary"] and os.path.exists(p):
os.remove(p)
if p in cleanup:
cleanup.remove(p)
items = next_items
round_no += 1
class AssembleInput(BaseModel):
clip_uris: list
transitions: list = [] # #6 per-clip transition-out type; empty/all-"cut" -> fast concat
chapter_id: str = ""
manga_id: str = ""
def _music_filter(gain: str) -> str:
"""#13 loop a music bed under the narration, ducked by sidechain compression keyed on the
narration itself, then mix. duration=first ends the mix with the video's audio."""
return (
f"[1:a]volume={gain}[bed];"
"[0:a]asplit=2[nar][key];"
"[bed][key]sidechaincompress=threshold=0.03:ratio=8:attack=20:release=400[duck];"
"[nar][duck]amix=inputs=2:duration=first:dropout_transition=0[a]"
)
def _add_music_bed(video: str, tag: str, cleanup: list) -> str:
"""mix MUSIC_BED under the assembled chapter. no-op (returns input) when unset or on failure."""
if not MUSIC_BED:
return video
music = MUSIC_BED
if MUSIC_BED.startswith("s3://"):
music = transport.get(MUSIC_BED, f"{SHM}/bed_{tag}{os.path.splitext(MUSIC_BED)[1] or '.mp3'}")
cleanup.append(music)
mixed = f"{SHM}/chapter_{tag}_mus.mp4"
try:
subprocess.run(
["ffmpeg", "-y", "-i", video, "-stream_loop", "-1", "-i", music,
"-filter_complex", _music_filter(MUSIC_GAIN), "-map", "0:v", "-map", "[a]",
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest", mixed],
check=True, capture_output=True)
except (subprocess.CalledProcessError, FileNotFoundError) as e:
print(f"[render] music bed skipped: {e}", flush=True)
if os.path.exists(mixed):
os.remove(mixed)
return video
cleanup.append(mixed)
return mixed
@app.post("/render/assemble")
async def assemble(data: AssembleInput):
if not data.clip_uris:
raise HTTPException(400, "no clips to assemble")
tag = uuid.uuid4().hex[:8]
with ThreadPoolExecutor(max_workers=8) as pool:
locals_ = list(pool.map(
lambda iu: transport.get(iu[1], f"{SHM}/asm_{tag}_{iu[0]}.mp4"),
enumerate(data.clip_uris),
))
out = f"{SHM}/chapter_{tag}.mp4"
cleanup = list(locals_) + [out]
fancy = len(locals_) >= 2 and any(t not in ("", "cut") for t in data.transitions)
if fancy:
_assemble_batched(locals_, data.transitions, out, tag, cleanup)
else:
# all hard cuts: stream-copy concat (no re-encode) -- unchanged fast path.
listfile = f"{SHM}/asm_{tag}.txt"; cleanup.append(listfile)
with open(listfile, "w") as f:
f.write("".join(f"file '{p}'\n" for p in locals_))
subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", listfile, "-c", "copy", out],
check=True, capture_output=True)
out = _add_music_bed(out, tag, cleanup)
manga_id, chapter_id = _mc_from_uri(data.clip_uris[0])
uri = f"s3://manga/{manga_id}/{chapter_id}/chapter.mp4"
transport.put(out, uri)
for p in cleanup:
os.remove(p)
return {"video_uri": uri} # orchestrator save_video reads video_uri
@app.get("/health")
async def health():
return {"status": "ok"}
if __name__ == "__main__":
# self-check: ass file well-formed; ffmpeg produces a clip from a synthetic image + silence.
import shutil
_ass("Hello\nworld", 2.0, f"{SHM}/t.ass")
txt = open(f"{SHM}/t.ass").read()
assert "Hello world" in txt and "Dialogue:" in txt # short cue -> one line
# 188: subtitle redesign. wrap to <=2 lines, off-mode burns nothing, subject-dodge picks top/bottom.
assert _wrap2("one two three four five six seven eight", 12).count("\\N") == 1 # exactly two lines
long = _wrap2(" ".join(["word"] * 40), 12)
assert long.count("\\N") == 1 and long.endswith("") # 3rd line truncated, never a paragraph
_ass_multi([("hidden", 0.0, 2.0)], f"{SHM}/off.ass", mode="off")
assert "Dialogue:" not in open(f"{SHM}/off.ass").read() # off -> nothing burned
_ass_multi([("boxed", 0.0, 2.0)], f"{SHM}/bx.ass", mode="boxed")
assert ",3," in open(f"{SHM}/bx.ass").read() # BorderStyle 3 = box
assert _sub_align({"to": [0.5, 0.8]}) == 8 and _sub_align({"to": [0.5, 0.3]}) == 2 # dodge low subject
_ass_multi([("top", 0.0, 2.0, 8)], f"{SHM}/an.ass")
assert "{\\an8}top" in open(f"{SHM}/an.ass").read() # per-cue alignment override
for f in (f"{SHM}/off.ass", f"{SHM}/bx.ass", f"{SHM}/an.ass"):
os.remove(f)
# #8 motion: each effect yields a distinct, well-formed zoompan expr; dolly aims at its target.
assert "z='1.0'" in _motion({"effect": "static"}, 50) # truly still
assert "on/49" in _motion({"effect": "pan_left"}, 50) # travels over the clip
assert "0.8*iw" in _motion({"effect": "dolly_to_subject", "to": [0.8, 0.2]}, 50)
assert _motion({}, 50) == _motion({"effect": "zoom_in"}, 50) # default == ken burns
# 185: content-weighted beat slices. always sum to D; floor honored; degenerate -> equal split.
eq = _beat_slices(12.0, 3)
assert eq == [4.0, 4.0, 4.0], eq # no weights -> equal
w = _beat_slices(12.0, 3, [3, 1, 1]) # heavier panel gets more time
assert abs(sum(w) - 12.0) < 1e-6 and w[0] > w[1] and min(w) >= BEAT_MIN_PANEL_S, w
assert all(abs(s - 0.8) < 1e-9 for s in _beat_slices(2.4, 3, [3, 1, 1])) # below floors -> equal
assert _beat_slices(12.0, 3, [0, 0, 0]) == [4.0, 4.0, 4.0] # zero-sum -> equal split
assert _beat_slices(12.0, 3, [5]) == [4.0, 4.0, 4.0] # short weights -> equal split
# 187: timed cue plan. "Hold up." must be its own cue and must not start before its panel's window.
sl = _beat_slices(9.0, 3, [1, 1, 1]) # three 3.0s panel windows
cues = cue_plan("She sees the reflection. Those are psycho eyes. Hold up.", 9.0, sl)
assert [c["text"] for c in cues] == ["She sees the reflection.", "Those are psycho eyes.", "Hold up."]
assert cues[0]["start"] == 0.0 # first cue opens the beat
for c in cues: # every cue starts within/after its panel
assert c["start"] >= c["panel_index"] * 3.0 - 1e-6, c
assert cues[-1]["panel_index"] == 2, cues[-1] # last line maps to the last panel
assert cues == cue_plan("She sees the reflection. Those are psycho eyes. Hold up.", 9.0, sl) # deterministic
assert cue_plan("", 9.0, sl) == [] # empty narration -> no cues (paragraph fallback)
# Long chapter assembly is bounded, and the transition out of a batch's last source clip is used
# to join that batch to the next one. Mock the encoder so this remains a cheap pure orchestration test.
_real_once = _assemble_once
_calls = []
try:
globals()["_assemble_once"] = lambda ins, trs, out: _calls.append((list(ins), list(trs), out))
_n = ASSEMBLE_BATCH * 2 + 1
_batch_trans = [f"t{i}" for i in range(_n)]
_assemble_batched([f"in{i}.mp4" for i in range(_n)], _batch_trans,
"/tmp/final.mp4", "batchck", [])
assert all(len(ins) <= ASSEMBLE_BATCH for ins, _, _ in _calls), _calls
assert _calls[-1][1][0] == _batch_trans[ASSEMBLE_BATCH - 1], _calls[-1]
assert _calls[-1][1][1] == _batch_trans[ASSEMBLE_BATCH * 2 - 1], _calls[-1]
finally:
globals()["_assemble_once"] = _real_once
if shutil.which("ffmpeg"):
aud = f"{SHM}/t.wav"; out = f"{SHM}/t.mp4"
subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=16000:cl=mono", "-t", "1", aud],
check=True, capture_output=True)
assert abs(_audio_dur(aud) - 1.0) < 0.1, _audio_dur(aud) # #1: duration from audio
# both a wide-short and a tall webtoon-style panel must render (tall used to break pad)
cams = ({}, {"effect": "pan_right"}, {"effect": "dolly_to_subject", "to": [0.7, 0.3]})
for size, cam in zip(("200x60", "900x2200", "800x1200"), cams):
img = f"{SHM}/t.png"
subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", f"color=c=black:s={size}",
"-frames:v", "1", img], check=True, capture_output=True)
subprocess.run(scene_cmd(img, aud, f"{SHM}/t.ass", out, 1.0, cam), check=True, capture_output=True)
assert os.path.getsize(out) > 0, size
os.remove(img)
# #13 padding: a 1.0s narration clip runs ~1.0+PAD_S with the trailing silence held.
img = f"{SHM}/tp.png"
subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", "color=c=black:s=400x600",
"-frames:v", "1", img], check=True, capture_output=True)
subprocess.run(scene_cmd(img, aud, f"{SHM}/t.ass", out, 1.0, pad=0.4), check=True, capture_output=True)
assert abs(_audio_dur(out) - 1.4) < 0.15, _audio_dur(out)
# #13 music bed: mixing a generated tone under the clip keeps duration and produces output.
bed = f"{SHM}/bed.wav"
subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", "sine=frequency=220:duration=0.5", bed],
check=True, capture_output=True)
_saved = globals()["MUSIC_BED"]; globals()["MUSIC_BED"] = bed
cl = []
mixed = _add_music_bed(out, "selfck", cl)
assert mixed != out and os.path.exists(mixed) and abs(_audio_dur(mixed) - 1.4) < 0.2, _audio_dur(mixed)
globals()["MUSIC_BED"] = _saved
for p in (img, bed, *cl):
if os.path.exists(p): os.remove(p)
# #6 transitions: two real clips xfade into one chapter; graph offsets/labels well-formed.
fg, vmap, amap = _xfade_chain([1.0, 1.0], ["fade_white"])
assert "xfade=transition=fadewhite" in fg and vmap == "[v1]" and amap == "[a1]"
img = f"{SHM}/t.png"
subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", "color=c=black:s=400x600",
"-frames:v", "1", img], check=True, capture_output=True)
c0, c1 = f"{SHM}/c0.mp4", f"{SHM}/c1.mp4"
subprocess.run(scene_cmd(img, aud, f"{SHM}/t.ass", c0, 1.0), check=True, capture_output=True)
subprocess.run(scene_cmd(img, aud, f"{SHM}/t.ass", c1, 1.0), check=True, capture_output=True)
subprocess.run(["ffmpeg", "-y", "-i", c0, "-i", c1, "-filter_complex", fg,
"-map", vmap, "-map", amap, "-c:v", "libx264", "-pix_fmt", "yuv420p",
"-c:a", "aac", out], check=True, capture_output=True)
assert os.path.getsize(out) > 0
for p in (img, c0, c1):
os.remove(p)
# #6 composite: 2 panels + 2 audios -> one stacked clip; duration = sum, subs timed per row.
a2 = f"{SHM}/a2.wav"
subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=16000:cl=mono", "-t", "1.5", a2],
check=True, capture_output=True)
i0, i1 = f"{SHM}/i0.png", f"{SHM}/i1.png"
for im, sz in ((i0, "300x200"), (i1, "500x300")):
subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", f"color=c=gray:s={sz}",
"-frames:v", "1", im], check=True, capture_output=True)
segs = [(0.0, 1.0), (1.0, 2.5)]
still = f"{SHM}/stk.png"
subprocess.run(_stack_still_cmd([i0, i1], still, H // 2), check=True, capture_output=True)
_ass_multi([("first beat", *segs[0]), ("second beat", *segs[1])], f"{SHM}/t.ass")
subprocess.run(composite_cmd(still, [aud, a2], f"{SHM}/t.ass", segs, H // 2, out, 2.5),
check=True, capture_output=True)
assert abs(_audio_dur(out) - 2.5) < 0.2, _audio_dur(out) # duration = sum of both narrations
for p in (a2, i0, i1, still):
os.remove(p)
# scene-level narration: 3 images under ONE 1.5s narration -> clip = D + PAD_S, single subtitle.
b0, b1, b2 = f"{SHM}/b0.png", f"{SHM}/b1.png", f"{SHM}/b2.png"
for im, sz in ((b0, "300x200"), (b1, "800x1200"), (b2, "500x900")):
subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", f"color=c=navy:s={sz}",
"-frames:v", "1", im], check=True, capture_output=True)
bnar = f"{SHM}/bnar.wav"
subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=16000:cl=mono", "-t", "1.5", bnar],
check=True, capture_output=True)
_ass("one flowing beat narration", 1.5 + PAD_S, f"{SHM}/t.ass")
cams = ({}, {"effect": "pan_right"}, {"effect": "dolly_to_subject", "to": [0.6, 0.4]})
subprocess.run(beat_cmd([b0, b1, b2], bnar, f"{SHM}/t.ass", out, 1.5, list(cams)),
check=True, capture_output=True)
assert abs(_audio_dur(out) - (1.5 + PAD_S)) < 0.2, _audio_dur(out) # one narration spans N images
# 183: collage of the same 3 panels -> one clip of D+PAD, laid out by plan_layout (dominant=2).
aspects = [_img_size(p)[0] / _img_size(p)[1] for p in (b0, b1, b2)]
lay = plan_layout(aspects, rtl=True, active=2, frame=(W, H))
_ass_multi([("collage cue", 0.0, 1.5 + PAD_S)], f"{SHM}/t.ass")
subprocess.run(collage_cmd([b0, b1, b2], 2, lay["rects"], lay["entrances"], lay["z_order"],
bnar, f"{SHM}/t.ass", out, 1.5, lay["transition_s"]),
check=True, capture_output=True)
assert abs(_audio_dur(out) - (1.5 + PAD_S)) < 0.2, _audio_dur(out) # collage clip = beat length
assert os.path.getsize(out) > 0
for p in (b0, b1, b2, bnar):
os.remove(p)
os.remove(aud); os.remove(out)
print("worker_render self-check ok (ffmpeg ran)")
else:
print("worker_render self-check ok (ffmpeg absent, ass-only)")
os.remove(f"{SHM}/t.ass")