← Commit history
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()