# 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://///... 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")