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.4
7 files changed
+291−3
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-video-post"-version = "1.0.3"+version = "1.0.4" 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+2@@ -60,6 +60,8 @@ 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`.+ > **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. `adom-desktop`'s `scripts/demo-record.sh` is the consumer, it calls `adom-video-post mark init` after Hydrogen recording starts and `adom-video-post process` after it stops. But adom-video-post itself doesn't know about adom-desktop. Any other recording orchestrator (even a hand-rolled bash script for a one-off demo) can use adom-video-post the same way.
SKILL.md+5−1@@ -125,7 +125,7 @@ done ## 🖥️ Fullscreen review in a Hydrogen webview is blocked — route users to pup -Hydrogen's webview panel wraps the storyboard URL in an iframe without `allow="fullscreen"` (filed as `adom-inc/hydrogen#329`). The ⛶ Expand button on each clip card calls `element.requestFullscreen()` which throws silently. This is OUT OF scope for `adom-video-post` to fix.+Hydrogen's Web View panel wraps the storyboard URL in an iframe without `allow="fullscreen"` (filed as `adom-inc/hydrogen#329`). The ⛶ Expand button on each clip card calls `element.requestFullscreen()` which throws silently. This is OUT OF scope for `adom-video-post` to fix. **When the user asks why fullscreen doesn't work** (and they will), tell them: same URL, standalone Chrome window via `pup`: @@ -217,6 +217,10 @@ fabrication exports) that you want to compress. Flow B is the original pipeline. Still supported, still correct when 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.+ ## CLI Reference A CLI for taking raw screen recordings and turning them into watchable narrated
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.3",+ "version": "1.0.4", "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.mdadded+101@@ -0,0 +1,101 @@+---+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".+---++# gang-takes: one narrated video from several takes++`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).++## What it does, and what it refuses to do++- **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.++## Manifest++```json+{+ "speed": 10,+ "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"+ }+ ]+}+```++`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.++## Run++```bash+pip install pillow fonttools brotli # once+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.++## 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.++## Publishing++Push the MP4 and poster to the page and embed with a `<video>` tag whose `src` is+`/blob/app/<page>/docs/videos/<file>.mp4`, with a plain "Watch" link to the+`/api/pages/<owner>/<page>/files/...` URL for phones. Confirm the URL serves with curl before+telling anyone it is live.
skills/gang-takes/gang_takes.pyadded+181@@ -0,0 +1,181 @@+#!/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.++Usage:+ gang_takes.py --manifest takes.json --out final.mp4 [--speed 10] [--size 1920x1080]++Manifest (JSON):+{+ "speed": 10,+ "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+ }+ ]+}++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+from pathlib import Path++try:+ from PIL import Image, ImageDraw, ImageFont, ImageFilter+except ImportError:+ sys.exit("gang_takes.py needs Pillow: pip install pillow")++ACCENT = (0, 184, 176); DARK = (13, 17, 23); TEXT = (230, 237, 243)+FONT_URL = "https://wiki.adom.inc/static/fonts/{}.woff2"+FONTS = {"display": "familjen-grotesk-700", "body": "satoshi-500", "mono": "jetbrains-mono-500"}+CACHE = Path(os.environ.get("ADOM_BRAND_FONT_CACHE", Path.home() / ".cache" / "adom-brand-fonts"))+++def sh(cmd, check=True):+ r = subprocess.run(cmd, capture_output=True, text=True)+ if check and r.returncode != 0:+ sys.exit(f"command failed: {' '.join(str(c) for c in cmd[:4])}\n{r.stderr[-2000:]}")+ return r.stdout+++def dur(p):+ return float(sh(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(p)]).strip())+++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"+ if ttf.exists():+ return ttf+ woff2 = CACHE / f"{name}.woff2"+ try:+ urllib.request.urlretrieve(FONT_URL.format(name), woff2)+ from fontTools.ttLib import TTFont+ 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)+++def wrap(draw, text, font, max_w):+ words, lines, cur = text.split(), [], ""+ for w in words:+ t = (cur + " " + w).strip()+ if draw.textlength(t, font=font) <= max_w:+ cur = t+ else:+ lines.append(cur); cur = w+ if cur:+ lines.append(cur)+ return lines+++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)."""+ font = ImageFont.truetype(str(font_path), size)+ probe = ImageDraw.Draw(Image.new("RGBA", (10, 10)))+ lines = wrap(probe, text, font, max_w)+ asc, desc = font.getmetrics(); lh = asc + desc + line_gap+ tw = max(probe.textlength(l, font=font) for l in lines)+ w, h = int(tw) + 2 * pad, lh * len(lines) + 2 * pad+ halo = Image.new("RGBA", (w, h), (0, 0, 0, 0)); d = ImageDraw.Draw(halo)+ for i, l in enumerate(lines):+ d.text((pad, pad + i * lh), l, font=font, fill=glow + (255,))+ halo = halo.filter(ImageFilter.MaxFilter(15)).filter(ImageFilter.GaussianBlur(glow_radius))+ halo = Image.alpha_composite(halo, halo)+ top = Image.new("RGBA", (w, h), (0, 0, 0, 0)); d = ImageDraw.Draw(top)+ for i, l in enumerate(lines):+ d.text((pad, pad + i * lh), l, font=font, fill=color + (255,))+ halo.alpha_composite(top); halo.save(path)+ return w, h+++def narration_mp3(seg, work, idx):+ if seg.get("narration_mp3"):+ return Path(seg["narration_mp3"])+ text = seg.get("narration")+ if not text:+ sys.exit(f"segment {idx}: give narration text or narration_mp3")+ 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+++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)+ 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))+ 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+ 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))+++if __name__ == "__main__":+ main()