app
Adom Video Post-Production
Public Made by Adomby adom
AI-driven studio for finishing demo videos: review every captured clip, flag and re-record the weak ones, speed up dead air, narrate, mux and auto-level, validate the final cut, and publish straight to the wiki, driven end to end by your AI.
← Commit history
Publish 1.0.5
8 files changed
+232−149
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-video-post"-version = "1.0.4"+version = "1.0.5" edition = "2021" description = "Post-process screen recordings: speed up boring sections with overlay captions, voiceover after the fact, publish to the Adom Wiki" license = "MIT"
README.md+1−1@@ -60,7 +60,7 @@ The user originally asked for this as a "post-process script for the Fusion demo So it lives in its own repo, follows the standard Rust + clap + SKILL.md + wiki-discovery pattern (the `tool-publisher` skill), and gets installed by any container that needs it via the Adom Wiki auto-discovery flow. Same model as `adom-molecule`, `shotlog`, and other on-demand tools. -> **Ganging takes into one narrated video** (each take at 10x, held on its result while the narration finishes, brand-font captions on a glow, no cards) is the bundled [`skills/gang-takes/`](skills/gang-takes/SKILL.md) skill and its `gang_takes.py`.+> **Ganging takes into one narrated video** (each take cut to its real-time action by change detection, real durations on screen, narration fitted to the measured cut, brand-font captions on a glow, no cards) is the bundled [`skills/gang-takes/`](skills/gang-takes/SKILL.md) skill and its `gang_takes.py`. > **Recording skills live here now.** The full demo-recording skill (script → record → mux → storyboard → publish, with the Hydrogen / pup / native-browser capture mechanics) is bundled under [`skills/demo-recording/`](skills/demo-recording/SKILL.md), homed in this repo since gallia was retired. Recording the user's **real, logged-in** browser at full framerate in the background is the adom-browser-extension's `native-browser-recording` skill.
SKILL.md+1−1@@ -219,7 +219,7 @@ it's the right fit. ## 🎞️ Gang several takes into one narrated video: the `gang-takes` skill -When the user wants "one video from these takes", "speed them up 10x and narrate", or "captions over the screen recording in our fonts", use the bundled **gang-takes** skill (`~/.claude/skills/gang-takes/`, tool `gang_takes.py`). It keeps the frames pure (no cards, no letterbox), plays each take at the speed factor and holds its result while the adom-tts narration finishes (no dead air), and draws the labels and captions in the Adom brand fonts on a soft glow. Reference output: the three-engine routing video on the kicad-bridge page.+When the user wants "one video from these takes", "only show the action", "cut the boring parts and narrate", or "captions over the screen recording in our fonts", use the bundled **gang-takes** skill (`~/.claude/skills/gang-takes/`, tool `gang_takes.py`). It keeps the frames pure (no cards, no letterbox), cuts each take to its action in real time by change detection (idle before, between and after removed, nothing sped up unless asked), states the real duration on screen, measures the cut and fits the adom-tts narration to that length, and draws the labels and captions in the Adom brand fonts on a soft glow. Reference output: the 21 s three-engine routing video on the kicad-bridge page, cut from three raw takes of 12, 38 and 43 s. ## CLI Reference
bin/adom-video-post⋯ 1 unchanged line ⋯
page.json+1−1@@ -26,7 +26,7 @@ "binary_name": "adom-video-post" }, "type": "app",- "version": "1.0.4",+ "version": "1.0.5", "description": "AI-driven studio for finishing demo videos: review every captured clip, flag and re-record the weak ones, speed up dead air, narrate, mux and auto-level, validate the final cut, and publish straight to the wiki, driven end to end by your AI.", "bundled": true, "dependencies": {},
skills/gang-takes/SKILL.md+69−62@@ -1,97 +1,104 @@ --- name: gang-takes description: >- Gang several screen-recording takes into one narrated video: pure screen video,- each take sped up (10x by default) and held on its result while adom-tts- narration explains it, so there is no dead air, with text overlays in the Adom- brand fonts on a soft glow and no cards, no letterbox. Use for "combine these- clips into one video", "gang up the recordings", "speed them up 10x and narrate",- "captions over the screen recording", "one video from the three takes",- "narrated compilation", "brand fonts on video captions".+ Cut several screen-recording takes to their action and gang them into one+ narrated video: real time by default, the idle seconds before, between and+ after the action removed by change detection, the real duration of the work+ stated on screen, narration written and fitted to the measured length of the+ cut, text overlays in the Adom brand fonts on a soft glow, no cards, no+ letterbox. Use for "combine these clips into one video", "gang up the+ recordings", "only show the action", "cut the boring parts", "narrate to the+ length of the video", "captions over the screen recording", "one video from+ the three takes", "brand fonts on video captions". --- -# gang-takes: one narrated video from several takes+# gang-takes: one narrated video from several takes, action only -`gang_takes.py` (next to this file) turns a manifest of takes into one MP4. It is what-made the three-engine routing video on the kicad-bridge page-(Freerouting, Claude Fable 5.1, GPT-6 Astra, 91 s from three raw takes).+`gang_takes.py` (next to this file) turns a manifest of takes into one MP4. It made the+three-engine routing video on the kicad-bridge page: three raw takes of 12, 38 and 43 seconds+became 21 seconds of real-time action. -## What it does, and what it refuses to do+## The rule that matters: show action, never delay it -- **Pure screen video.** No title card, no outro, no letterbox, no bars. The only thing added- to the frames is text.-- **Speed then hold.** Each take plays at `speed` (default 10x). When the take ends, its last- frame holds while that segment's narration finishes. Segment length is whichever is longer:- narration plus 0.6 s, or the sped-up take plus 1 s. So there is never dead air and the- narration is never cut off.-- **Narration from adom-tts.** Give `narration` text per segment and the tool synthesises it- with `adom-tts say` (edge-tts neural voice, default en-US-AndrewNeural, with the shared- pronunciation table applied). Give `narration_mp3` instead to reuse a clip you already made.- Write the text the way it should be spoken: "kicad route net", "net three", "J three".-- **Brand text.** Segment label in Familjen Grotesk 700 (teal, top left), captions in Satoshi- 500 (bottom left, wrapped to the frame), speed and result chips in JetBrains Mono 500 (top- right). Fonts are fetched once from `wiki.adom.inc/static/fonts` and converted to TTF into- `~/.cache/adom-brand-fonts`. Colours are the wiki's tokens: accent #00b8b0, dark #0d1117,- text #e6edf3.-- **Glow, not a band.** Text sits on a blurred halo (dark behind captions and labels so they- read over busy UI, teal behind the speed chip). Rendered with Pillow as PNG overlays, so the- frame underneath stays untouched.-- **Captions spread evenly.** N captions in a segment each get 1/N of that segment. Keep each- under about 25 words; two lines at most at 1080p.-- **Validated.** Full decode of the final file and a poster frame from its last second.+A viewer wants to see the thing happen. So:++- **Real time by default.** If the work on screen is already fast, it is not sped up. `speed`+ is per segment and off unless you set it.+- **Cut the idle, do not compress it.** The tool scans the frames of a region you name for the+ moments the screen changes, keeps a short beat before and after each change (default 0.4 s+ before, 0.8 s after, 1.6 s after the last one) and drops everything else: the seconds before+ the first commit, the waits between commits, the tail after the last.+- **Say how long it really took.** Put the real duration in the `chip` text ("real time 3.2 s")+ and in the narration. If idle was cut out, say so ("real time 25 s, idle cut").+- **Measure the cut first, then write the words.** Run with `--measure` to get the length of+ every segment, write narration for those numbers, then run for real. The tool synthesises+ with adom-tts, trims the leading and trailing silence, and raises the speaking rate in steps+ up to +20% until the audio fits the segment. If it still does not fit it tells you the+ overrun instead of padding the video with a hold.+- **Pure screen video.** No title card, no outro, no letterbox. The only thing added to the+ frames is text.++## Text on the frame++Segment label in Familjen Grotesk 700 (teal, top left), chip in JetBrains Mono 500 (top+right), optional captions in Satoshi 500 (bottom left, wrapped). All on a blurred dark halo so+they read over busy UI. Fonts are fetched once from `wiki.adom.inc/static/fonts` into+`~/.cache/adom-brand-fonts`; colours are the wiki's tokens (accent #00b8b0, dark #0d1117,+text #e6edf3). ## Manifest ```json {- "speed": 10,+ "size": "1920x1080",+ "fps": 30,+ "region": [0.26, 0.16, 0.77, 0.86], "segments": [ { "clip": "/path/take1.mp4", "label": "1 / 3 Freerouting",- "narration": "First, Freerouting. It is installed on demand ...",- "captions": [- "Freerouting 2.4.1, installed on demand into the bridge cache.",- "Nine segments in 3.2 s of real time. DRC clean, zero unconnected."- ],- "note": "result 3.2 s real time"+ "chip": "real time 3.2 s",+ "narration": "Freerouting: one call, six nets, three point two seconds.",+ "pre": 0.4, "post": 0.8, "last_post": 1.6+ },+ {+ "clip": "/path/take2.mp4",+ "label": "2 / 3 Claude Fable 5.1",+ "chip": "real time 25 s, idle cut",+ "narration": "Claude Fable 5.1, net by net: one undo step per call, DRC before each lands. Six nets in twenty-five seconds.",+ "last_post": 2.0 } ] } ``` -`note` is the chip that replaces the speed chip once the take has finished, so the viewer knows-the held frame is the result and how long it took in real time.+- `region` is the part of the frame to watch for change, as fractions (x0, y0, x1, y1). Name+ the canvas, not the whole screen, so a clock or a status bar does not count as action.+- `events` lets you pass the change moments yourself (seconds into the raw take) when detection+ picks up the wrong thing; `min_change_px` tunes sensitivity (pixels changed at 640 px width).+- `narration_mp3` reuses audio you already made. Write narration text the way it should be+ spoken: "kicad route net", "net three", "three point two seconds".+- `captions` are optional and spread evenly over the segment. ## Run ```bash-pip install pillow fonttools brotli # once+pip install pillow numpy fonttools brotli # once+python3 ~/.claude/skills/gang-takes/gang_takes.py --manifest takes.json --out final.mp4 --measure+# read the lengths, write the narration to them, then python3 ~/.claude/skills/gang-takes/gang_takes.py --manifest takes.json --out final.mp4-# options: --speed 10 --size 1920x1080 --fps 30 --work /tmp/gang-work ``` -Output: `final.mp4` (H.264 yuv420p, AAC, faststart) and `final-poster.jpg`, plus the per-segment-files and overlay PNGs in the work folder for inspection.+Output: `final.mp4` (H.264 yuv420p, AAC, faststart), `final-poster.jpg`, and a report with the+detected events, the kept intervals, each segment's length and how the narration was fitted. ## Check it before you ship it -Extract frames at the start, the middle of a hold, and the last second, and look at them:--```bash-for t in 1 20 45 88; do ffmpeg -v error -y -ss $t -i final.mp4 -frames:v 1 -vf scale=1280:-2 chk-$t.png; done-```--Look for: the take is visible (not a white canvas: record the monitor, not the window, for-OpenGL apps like KiCad), captions do not cover the thing being demonstrated, no caption wrapped-into a dangling one-word line (shorten it), the label does not sit on top of important UI.--## Where the takes come from--Record with Adom Bridge's native recorder (`desktop_record_start`, `desktop_record_stop`,-`pull_file`). For the etiquette of bringing a window forward on an idle box and the-white-canvas trap, read the kicad-bridge autorouting guide's video section. For the wider-pipeline (per-clip storyboard review, wiki publishing), read the adom-video-post skill.+Extract frames at the start of each segment, just after a change, and the last second, and+look at them. Look for: the take is visible (record the monitor, not the window, for OpenGL apps+like KiCad, or the canvas is white), the label does not sit on the thing being demonstrated,+every change you expected made it into the cut (compare the report's events with the number of+commits you made), and the narration report says it fit. ## Publishing
skills/gang-takes/__pycache__/gang_takes.cpython-312.pycadded⋯ 1 unchanged line ⋯
skills/gang-takes/gang_takes.py+159−83@@ -1,42 +1,56 @@ #!/usr/bin/env python3-"""gang_takes.py: gang several screen-recording takes into one narrated video.--Pure screen video, no cards, no letterbox. Each take plays at a speed factor-(default 10x) and then holds its final frame while that take's narration-finishes, so there is never dead air. Text overlays use the Adom brand fonts-(Familjen Grotesk for the segment label, Satoshi for captions, JetBrains Mono-for the speed and result chips) drawn on a soft glow: dark behind captions and-labels for legibility over busy UI, teal behind the speed chip.+"""gang_takes.py: cut several screen-recording takes to their action and gang+them into one narrated video.++Default mode is `action`: the tool finds the moments the screen actually+changes (a frame-difference scan of the region you care about), keeps a short+beat before and after each change in REAL TIME, drops the idle seconds before,+between and after, and states the real duration of the work on screen. Nothing+is sped up unless you ask (`"speed": N` on a segment) and nothing is held+beyond a short result beat. Narration is written to the measured length of the+cut: the tool measures each segment first, prints the lengths, synthesises the+text with adom-tts, trims edge silence, and nudges the speaking rate (up to++20%) until the audio fits; if it still does not fit it says so instead of+padding the video.++Text overlays use the Adom brand fonts on a soft glow (Familjen Grotesk label,+Satoshi captions, JetBrains Mono chips), fetched once from wiki.adom.inc.+Pure screen video: no cards, no letterbox. Usage:- gang_takes.py --manifest takes.json --out final.mp4 [--speed 10] [--size 1920x1080]+ gang_takes.py --manifest takes.json --out final.mp4 [--measure]++--measure builds the video cut and prints the segment lengths, then stops, so+you can write narration to those numbers before the final run. -Manifest (JSON):+Manifest: {- "speed": 10,+ "size": "1920x1080", "fps": 30,+ "region": [0.26, 0.16, 0.77, 0.86], # x0,y0,x1,y1 fractions watched for change (default: whole frame) "segments": [ { "clip": "take1.mp4", "label": "1 / 3 Freerouting",- "narration": "Spoken text for this segment.", # synthesised with adom-tts- "narration_mp3": "already-made.mp3", # optional, skips synthesis- "captions": ["first caption", "second caption"], # spread evenly over the segment- "note": "result 3.2 s real time" # chip shown once the take has finished+ "chip": "real time 3.2 s", # top-right text, shown for the whole segment+ "narration": "Freerouting: one call, six nets, three point two seconds.",+ "narration_mp3": "optional-prebuilt.mp3",+ "captions": ["optional caption", "spread evenly over the segment"],+ "events": [6.5], # optional: change moments in seconds; detected when absent+ "pre": 0.4, "post": 0.8, "last_post": 1.6, # beats kept around each event, seconds+ "min_change_px": 15, # detection sensitivity (pixels changed at 640 px width)+ "speed": 1 # optional playback factor for this segment; 1 = real time } ] }--Needs: ffmpeg/ffprobe, Pillow, fontTools + brotli (pip install fonttools brotli),-adom-tts on PATH when any segment gives narration text. Brand fonts are fetched-once from wiki.adom.inc/static/fonts into ~/.cache/adom-brand-fonts. """-import argparse, json, os, shutil, subprocess, sys, urllib.request+import argparse, glob, json, os, shutil, subprocess, sys, tempfile, urllib.request from pathlib import Path try: from PIL import Image, ImageDraw, ImageFont, ImageFilter+ import numpy as np except ImportError:- sys.exit("gang_takes.py needs Pillow: pip install pillow")+ sys.exit("gang_takes.py needs Pillow and numpy: pip install pillow numpy") ACCENT = (0, 184, 176); DARK = (13, 17, 23); TEXT = (230, 237, 243) FONT_URL = "https://wiki.adom.inc/static/fonts/{}.woff2"@@ -56,10 +70,8 @@ def dur(p): def brand_font(kind):- """TTF path for a brand font, fetched and converted on first use. Falls back to DejaVu.""" CACHE.mkdir(parents=True, exist_ok=True)- name = FONTS[kind]- ttf = CACHE / f"{name}.ttf"+ name = FONTS[kind]; ttf = CACHE / f"{name}.ttf" if ttf.exists(): return ttf woff2 = CACHE / f"{name}.woff2"@@ -69,9 +81,9 @@ def brand_font(kind): f = TTFont(str(woff2)); f.flavor = None; f.save(str(ttf)) return ttf except Exception as e: # noqa: BLE001- fallback = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if kind == "display" else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"- print(f"warning: brand font {name} unavailable ({e}); using {fallback}", file=sys.stderr)- return Path(fallback)+ fb = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if kind == "display" else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"+ print(f"warning: brand font {name} unavailable ({e}); using {fb}", file=sys.stderr)+ return Path(fb) def wrap(draw, text, font, max_w):@@ -88,7 +100,7 @@ def wrap(draw, text, font, max_w): def glow_png(path, text, font_path, size, color, glow, max_w, glow_radius=20, pad=48, line_gap=10):- """Text on a transparent canvas over a blurred halo. Returns (w, h)."""+ """Text over a blurred halo on a transparent canvas. Returns (w, h).""" font = ImageFont.truetype(str(font_path), size) probe = ImageDraw.Draw(Image.new("RGBA", (10, 10))) lines = wrap(probe, text, font, max_w)@@ -107,74 +119,138 @@ def glow_png(path, text, font_path, size, color, glow, max_w, glow_radius=20, pa return w, h -def narration_mp3(seg, work, idx):+def detect_events(src, region, min_px, work, scan_fps=10):+ """Timestamps (s) where the watched region changes. Frames scaled to 640 px wide."""+ d = Path(tempfile.mkdtemp(prefix="scan-", dir=work))+ sh(["ffmpeg", "-v", "error", "-y", "-i", str(src), "-vf", f"fps={scan_fps},scale=640:-2", f"{d}/f%05d.png"])+ files = sorted(glob.glob(f"{d}/f*.png")); prev = None; events = []+ x0, y0, x1, y1 = region+ for i, f in enumerate(files):+ im = np.asarray(Image.open(f).convert("RGB"), dtype=np.int16); h, w, _ = im.shape+ reg = im[int(h * y0):int(h * y1), int(w * x0):int(w * x1)]+ if prev is not None and i > scan_fps // 2: # ignore the recorder's first half second+ if int((np.abs(reg - prev).max(axis=2) > 10).sum()) >= min_px:+ events.append(round(i / scan_fps, 2))+ prev = reg+ shutil.rmtree(d, ignore_errors=True)+ # merge bursts closer than 0.3 s into one event+ merged = []+ for t in events:+ if merged and t - merged[-1] < 0.3:+ continue+ merged.append(t)+ return merged+++def intervals(events, pre, post, last_post):+ iv = []+ for i, t in enumerate(events):+ a = max(0.0, t - pre); b = t + (last_post if i == len(events) - 1 else post)+ if iv and a <= iv[-1][1]:+ iv[-1][1] = b+ else:+ iv.append([a, b])+ return iv+++def cut_video(idx, seg, W, H, fps, work, fonts, region):+ src = Path(seg["clip"]); speed = float(seg.get("speed", 1))+ events = seg.get("events") or detect_events(src, region, int(seg.get("min_change_px", 15)), work)+ if not events:+ sys.exit(f"segment {idx}: no change detected in {src.name}; pass events or lower min_change_px")+ iv = intervals(events, float(seg.get("pre", 0.4)), float(seg.get("post", 0.8)), float(seg.get("last_post", 1.6)))+ parts = []+ for j, (a, b) in enumerate(iv):+ p = work / f"seg{idx}-part{j}.mp4"+ vf = f"scale={W}:{H}:flags=lanczos,fps={fps}" + (f",setpts=(PTS-STARTPTS)/{speed}" if speed != 1 else "")+ sh(["ffmpeg", "-y", "-v", "error", "-ss", f"{a:.3f}", "-to", f"{b:.3f}", "-i", str(src), "-vf", vf, "-an",+ "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p", str(p)])+ parts.append(p)+ lst = work / f"seg{idx}-list.txt"; lst.write_text("".join(f"file '{p}'\n" for p in parts))+ raw = work / f"seg{idx}-cut.mp4"+ sh(["ffmpeg", "-y", "-v", "error", "-f", "concat", "-safe", "0", "-i", str(lst), "-c", "copy", str(raw)])+ D = dur(raw)+ # overlays+ m = W / 1920; inputs = ["-i", str(raw)]; chain = []; cur = "[0:v]"; k = 0+ def add(png, x, y, enable=None):+ nonlocal cur, k, inputs+ inputs += ["-i", str(png)]; k += 1+ en = f":enable='{enable}'" if enable else ""+ chain.append(f"{cur}[{k}:v]overlay=x={x}:y={y}{en}[v{k}]"); cur = f"[v{k}]"+ if seg.get("label"):+ p = work / f"ov{idx}-label.png"; glow_png(p, seg["label"], fonts["display"], int(40 * m), ACCENT, DARK, int(1200 * m), 18); add(p, int(24 * m), int(-20 * m))+ if seg.get("chip"):+ p = work / f"ov{idx}-chip.png"; w, _ = glow_png(p, seg["chip"], fonts["mono"], int(32 * m), TEXT, DARK, int(900 * m), 18); add(p, W - int(24 * m) - w, int(-18 * m))+ caps = seg.get("captions") or []+ for j, cap in enumerate(caps):+ a, b = D * j / len(caps), D * (j + 1) / len(caps)+ p = work / f"ov{idx}-cap{j}.png"; w, h = glow_png(p, cap, fonts["body"], int(38 * m), TEXT, DARK, int(1720 * m), 22)+ add(p, int(24 * m), H - int(12 * m) - h, f"between(t,{a:.3f},{b:.3f})")+ out = work / f"seg{idx}-video.mp4"+ if chain:+ chain.append(f"{cur}format=yuv420p[v]")+ sh(["ffmpeg", "-y", "-v", "error"] + inputs + ["-filter_complex", ";".join(chain), "-map", "[v]", "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-r", str(fps), "-pix_fmt", "yuv420p", str(out)])+ else:+ shutil.copy(raw, out)+ return out, dur(out), events, iv+++def fit_narration(idx, seg, D, work):+ """Narration audio no longer than D. Trims edge silence, then raises the rate up to +20%.""" if seg.get("narration_mp3"):- return Path(seg["narration_mp3"])+ return Path(seg["narration_mp3"]), dur(seg["narration_mp3"]), "given" text = seg.get("narration") if not text:- sys.exit(f"segment {idx}: give narration text or narration_mp3")+ return None, 0.0, "none" if not shutil.which("adom-tts"): sys.exit("adom-tts is not on PATH; install the adom-tts package or pass narration_mp3")- txt = work / f"narr-{idx}.txt"; txt.write_text(text)- mp3 = work / f"narr-{idx}.mp3"- sh(["adom-tts", "say", f"@{txt}", "--out", str(mp3)])- return mp3---def build_segment(idx, seg, speed, W, H, fps, work, fonts):- src = Path(seg["clip"]); narr = narration_mp3(seg, work, idx)- motion = dur(src) / speed- D = round(max(dur(narr) + 0.6, motion + 1.0), 3); hold = D - motion- inputs = ["-i", str(src), "-i", str(narr)]- chain = [f"[0:v]scale={W}:{H}:flags=lanczos,setpts=(PTS-STARTPTS)/{speed},fps={fps},"- f"tpad=stop_mode=clone:stop_duration={hold + 1:.3f},trim=0:{D:.3f},setpts=PTS-STARTPTS[v0]"]- overlays = []- m = W / 1920 # scale margins with the frame- p = work / f"ov-{idx}-label.png"; glow_png(p, seg.get("label", ""), fonts["display"], int(40 * m), ACCENT, DARK, int(1200 * m), 18)- overlays.append((p, int(24 * m), int(-20 * m), None))- p = work / f"ov-{idx}-speed.png"; w, _ = glow_png(p, f"{speed}x", fonts["mono"], int(40 * m), TEXT, ACCENT, int(400 * m), 18)- overlays.append((p, W - int(24 * m) - w, int(-20 * m), f"lt(t,{motion:.3f})"))- if seg.get("note"):- p = work / f"ov-{idx}-note.png"; w, _ = glow_png(p, seg["note"], fonts["mono"], int(32 * m), TEXT, DARK, int(900 * m), 18)- overlays.append((p, W - int(24 * m) - w, int(-18 * m), f"gte(t,{motion:.3f})"))- caps = seg.get("captions") or []- for j, cap in enumerate(caps):- a, b = D * j / len(caps), D * (j + 1) / len(caps)- p = work / f"ov-{idx}-cap{j}.png"; w, h = glow_png(p, cap, fonts["body"], int(38 * m), TEXT, DARK, int(1720 * m), 22)- overlays.append((p, int(24 * m), H - int(12 * m) - h, f"between(t,{a:.3f},{b:.3f})"))- cur = "[v0]"- for k, (p, x, y, en) in enumerate(overlays):- inputs += ["-i", str(p)]- en_s = f":enable='{en}'" if en else ""- chain.append(f"{cur}[{2 + k}:v]overlay=x={x}:y={y}{en_s}[v{k + 1}]"); cur = f"[v{k + 1}]"- chain.append(f"{cur}format=yuv420p[v]")- chain.append(f"[1:a]aresample=48000,apad,atrim=0:{D:.3f},asetpts=PTS-STARTPTS[a]")- out = work / f"seg-{idx}.mp4"- sh(["ffmpeg", "-y", "-v", "error"] + inputs + ["-filter_complex", ";".join(chain), "-map", "[v]", "-map", "[a]",- "-c:v", "libx264", "-preset", "medium", "-crf", "19", "-r", str(fps), "-pix_fmt", "yuv420p",- "-c:a", "aac", "-b:a", "160k", "-ar", "48000", "-ac", "2", "-t", f"{D:.3f}", str(out)])- return out, D, motion+ txt = work / f"narr{idx}.txt"; txt.write_text(text)+ for rate in (0, 6, 12, 20):+ raw = work / f"narr{idx}-r{rate}.mp3"; mp3 = work / f"narr{idx}-r{rate}-trim.mp3"+ sh(["adom-tts", "say", f"@{txt}", "--rate", f"+{rate}%", "--out", str(raw)])+ sh(["ffmpeg", "-v", "error", "-y", "-i", str(raw), "-af",+ "silenceremove=start_periods=1:start_threshold=-45dB:start_silence=0.05,areverse,silenceremove=start_periods=1:start_threshold=-45dB:start_silence=0.05,areverse",+ "-c:a", "libmp3lame", "-q:a", "2", str(mp3)])+ d = dur(mp3)+ if d <= D:+ return mp3, d, f"+{rate}%"+ return mp3, d, f"+{rate}% (still {d - D:.2f}s over: shorten the text or raise last_post)" def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--manifest", required=True); ap.add_argument("--out", required=True)- ap.add_argument("--speed", type=float); ap.add_argument("--size", default="1920x1080"); ap.add_argument("--fps", type=int, default=30)- ap.add_argument("--work", help="working folder (default: next to --out)")- a = ap.parse_args()- man = json.load(open(a.manifest)); speed = a.speed or man.get("speed", 10)- W, H = (int(x) for x in a.size.lower().split("x"))- out = Path(a.out); work = Path(a.work) if a.work else out.with_suffix("") .parent / (out.stem + "-work")- work.mkdir(parents=True, exist_ok=True)+ ap.add_argument("--measure", action="store_true", help="build the cut, print segment lengths, stop")+ ap.add_argument("--work"); a = ap.parse_args()+ man = json.load(open(a.manifest)); out = Path(a.out)+ W, H = (int(x) for x in man.get("size", "1920x1080").lower().split("x")); fps = int(man.get("fps", 30))+ region = man.get("region", [0, 0, 1, 1])+ work = Path(a.work) if a.work else out.parent / (out.stem + "-work"); work.mkdir(parents=True, exist_ok=True) fonts = {k: brand_font(k) for k in FONTS}- segs = [build_segment(i + 1, s, speed, W, H, a.fps, work, fonts) for i, s in enumerate(man["segments"])]- lst = work / "concat.txt"; lst.write_text("".join(f"file '{p}'\n" for p, _, _ in segs))+ report = []; segs = []+ for i, seg in enumerate(man["segments"], 1):+ v, D, events, iv = cut_video(i, seg, W, H, fps, work, fonts, region)+ segs.append((seg, v, D)); report.append({"segment": i, "clip": Path(seg["clip"]).name, "events": events, "kept": [[round(x, 2), round(y, 2)] for x, y in iv], "seconds": round(D, 2)})+ total = round(sum(D for _, _, D in segs), 2)+ if a.measure:+ print(json.dumps({"segments": report, "total": total, "next": "write narration to these lengths, then run again without --measure"}, indent=1)); return+ finals = []+ for i, (seg, v, D) in enumerate(segs, 1):+ mp3, nd, how = fit_narration(i, seg, D, work)+ report[i - 1]["narration"] = {"seconds": round(nd, 2), "fit": how}+ f = work / f"seg{i}-final.mp4"+ if mp3:+ sh(["ffmpeg", "-y", "-v", "error", "-i", str(v), "-i", str(mp3), "-filter_complex", f"[1:a]aresample=48000,apad,atrim=0:{D:.3f},asetpts=PTS-STARTPTS[a]",+ "-map", "0:v", "-map", "[a]", "-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-ar", "48000", "-ac", "2", "-shortest", str(f)])+ else:+ sh(["ffmpeg", "-y", "-v", "error", "-i", str(v), "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo", "-map", "0:v", "-map", "1:a", "-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", str(f)])+ finals.append(f)+ lst = work / "final-list.txt"; lst.write_text("".join(f"file '{p}'\n" for p in finals)) sh(["ffmpeg", "-y", "-v", "error", "-f", "concat", "-safe", "0", "-i", str(lst), "-c", "copy", "-movflags", "+faststart", str(out)])- sh(["ffmpeg", "-v", "error", "-i", str(out), "-f", "null", "-"]) # full decode check+ sh(["ffmpeg", "-v", "error", "-i", str(out), "-f", "null", "-"]) poster = out.with_name(out.stem + "-poster.jpg")- sh(["ffmpeg", "-y", "-v", "error", "-sseof", "-1", "-i", str(out), "-frames:v", "1", "-q:v", "3", str(poster)])- print(json.dumps({"out": str(out), "poster": str(poster), "duration": round(sum(d for _, d, _ in segs), 2),- "segments": [{"file": p.name, "seconds": d, "motionSeconds": round(m, 2)} for p, d, m in segs]}, indent=1))+ sh(["ffmpeg", "-y", "-v", "error", "-sseof", "-0.5", "-i", str(out), "-frames:v", "1", "-q:v", "3", str(poster)])+ print(json.dumps({"out": str(out), "poster": str(poster), "total": round(dur(out), 2), "segments": report}, indent=1)) if __name__ == "__main__":