← Commit history

Publish 1.0.9

John Lauer ·8cd5f7586e ·7d ago ·parent c5b76a9
3 files changed +82−4
page.json+1−1
@@ -26,7 +26,7 @@     "binary_name": "adom-video-post"   },   "type": "app",-  "version": "1.0.8",+  "version": "1.0.9",   "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+25−1
@@ -9,7 +9,8 @@ description: >   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".+  the three takes", "brand fonts on video captions", "captions file for the wiki video",+  "chapters for the video", "adom-video widget". ---  # gang-takes: one narrated video from several takes, action only@@ -97,6 +98,29 @@ python3 ~/.claude/skills/gang-takes/gang_takes.py --manifest takes.json --out fi 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. +It also writes, for free, from what it already knows:++- **`final.vtt`**: captions from the narration text. The narration is adom-tts reading your text, so the+  words are exact and every cue starts where its segment's narration starts and ends where the trimmed+  clip ends; long lines split at sentences, then commas, at most two lines of about 42 characters, each+  timed by its share of the characters. Segments with only `narration_mp3` and no `narration` text get no cues.+- **`final.chapters.json`**: one chapter per segment, titled from `label` with the "1 / 3" counter+  dropped (set `chapter` on a segment, or on the `chart`, to name it yourself; the chart defaults to its title).++The report's `embed` line is the wiki tag that uses all four files. Push them next to each other on the+page and fill in OWNER/SLUG/PATH:++```markdown+::adom-video{video="/OWNER/SLUG/render/PATH/final.mp4" poster="/OWNER/SLUG/render/PATH/final-poster.jpg" captions="/OWNER/SLUG/render/PATH/final.vtt" chapters="/OWNER/SLUG/render/PATH/final.chapters.json"}+```++`::adom-video` (adom/widgets) is the wiki's YouTube-style player: every YouTube shortcut, a timeline you+can hit, chapters as bar segments, C toggles the captions. Captions and chapter files are fetched by the+reader's browser without a login, so on a PRIVATE page pass a JSON manifest instead, which the wiki+inlines: `::adom-video{src="/OWNER/SLUG/render/PATH/final.video.json"}` with+`{"video": "...mp4", "poster": "...", "chapters": [..the chapters.json array..], "captions": "WEBVTT\n\n..the vtt text.."}`.+Give the absolute path: `--out /abs/path/final.mp4` (a relative `--out` breaks the concat step).+ ## Check it before you ship it  Extract frames at the start of each segment, just after a change, and the last second, and
skills/gang-takes/gang_takes.py+56−2
@@ -224,6 +224,46 @@ def fit_narration(idx, seg, D, work):     return mp3, d, f"+{rate}% (still {d - D:.2f}s over: shorten the text or raise last_post)"  +def caption_cues(text, t0, seconds, max_chars=84):+    """Split a narration line into caption cues (whole sentences, long ones at commas or words, at most+    two lines of ~42 chars), each timed by its share of the characters across the spoken length."""+    import re+    parts = []+    for sent in re.split(r"(?<=[.!?])\s+", " ".join(str(text).split())):+        while len(sent) > max_chars:+            cut = max(sent.rfind(", ", 0, max_chars), sent.rfind("; ", 0, max_chars))+            cut = cut + 1 if cut > max_chars // 3 else sent.rfind(" ", 0, max_chars)+            if cut <= 0: cut = max_chars+            parts.append(sent[:cut].strip()); sent = sent[cut:].strip()+        if sent: parts.append(sent)+    total = sum(len(x) for x in parts) or 1; t = t0; cues = []+    for x in parts:+        d = seconds * len(x) / total+        cues.append((t, t + d, x)); t += d+    return cues+++def vtt_time(t):+    h, rem = divmod(max(0.0, t), 3600); m, s = divmod(rem, 60)+    return f"{int(h):02d}:{int(m):02d}:{s:06.3f}"+++def write_sidecars(out, timeline):+    """final.vtt (captions from the narration text) and final.chapters.json (one chapter per segment),+    the files the ::adom-video wiki widget takes as captions= and chapters=."""+    import re+    cues, chapters = [], []+    for t0, D, label, text, nd in timeline:+        if label: chapters.append({"t": round(t0, 3), "title": re.sub(r"^\d+\s*/\s*\d+\s+", "", re.sub(r"\s+", " ", label).strip())})+        if text and nd > 0: cues += caption_cues(text, t0, nd)+    vtt = out.with_suffix(".vtt"); chj = out.with_name(out.stem + ".chapters.json")+    if cues:+        vtt.write_text("WEBVTT\n\n" + "".join(f"{vtt_time(a)} --> {vtt_time(b)}\n{x}\n\n" for a, b, x in cues))+    if len(chapters) > 1:+        chj.write_text(json.dumps(chapters, indent=1) + "\n")+    return (vtt if cues else None), (chj if len(chapters) > 1 else None)++ def render_chart(path, chart, W, H, fonts):     """Brand-style horizontal bar chart: title, subtitle lines, rows of (name, value, unit, note)."""     BG = (13, 17, 23); PANEL = (22, 27, 34); TXT2 = (139, 148, 158)@@ -277,6 +317,7 @@ 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("--measure", action="store_true", help="build the cut, print segment lengths, stop")+    ap.add_argument("--wiki-path", help="where the files will live on the wiki, /OWNER/SLUG/render/PATH: fills the embed line and writes final.video.json")     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))@@ -290,9 +331,10 @@ def main():     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 = []+    finals = []; timeline = []; t0 = 0.0     for i, (seg, v, D) in enumerate(segs, 1):         mp3, nd, how = fit_narration(i, seg, D, work)+        timeline.append((t0, D, seg.get("chapter") or seg.get("label"), seg.get("narration") if mp3 else None, nd)); t0 += D         report[i - 1]["narration"] = {"seconds": round(nd, 2), "fit": how}         f = work / f"seg{i}-final.mp4"         if mp3:@@ -303,13 +345,25 @@ def main():         finals.append(f)     if man.get("chart"):         cseg, cD, cfit = chart_segment(man["chart"], W, H, fps, work, fonts)+        timeline.append((t0, cD, man["chart"].get("chapter") or man["chart"].get("title") or "Results", man["chart"].get("narration"), cfit["seconds"]))         finals.append(cseg); report.append({"segment": "chart", "seconds": round(cD, 2), "narration": cfit})     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", "-"])     poster = out.with_name(out.stem + "-poster.jpg")     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))+    vtt, chj = write_sidecars(out, timeline)+    base = (a.wiki_path or "/OWNER/SLUG/render/PATH").rstrip("/")+    embed = f'::adom-video{{video="{base}/{out.name}" poster="{base}/{poster.name}"' + (f' captions="{base}/{vtt.name}"' if vtt else "") + (f' chapters="{base}/{chj.name}"' if chj else "") + "}"+    manifest = None+    if a.wiki_path:   # for a PRIVATE page: the wiki inlines a src manifest, so chapters and captions ride inside it+        man_out = {"video": f"{base}/{out.name}", "poster": f"{base}/{poster.name}"}+        if chj: man_out["chapters"] = json.loads(chj.read_text())+        if vtt: man_out["captions"] = vtt.read_text()+        manifest = out.with_name(out.stem + ".video.json"); manifest.write_text(json.dumps(man_out, indent=1) + "\n")+    print(json.dumps({"out": str(out), "poster": str(poster), "captions": str(vtt) if vtt else None, "chapters": str(chj) if chj else None,+                      "total": round(dur(out), 2), "segments": report, "embed": embed,+                      "embed_private": f'::adom-video{{src="{base}/{manifest.name}"}}' if manifest else None}, indent=1))   if __name__ == "__main__":