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.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
#!/usr/bin/env python3
"""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 [--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:
{
"size": "1920x1080", "fps": 30,
"region": [0.26, 0.16, 0.77, 0.86], # x0,y0,x1,y1 fractions watched for change (default: whole frame); a segment may carry its own "region"
"chart": { # optional closing bar chart, brand style, narrated
"title": "Real time to route six nets", "subtitle": ["one line", "another"],
"rows": [{"name": "Freerouting", "value": 3.2, "unit": "s", "note": "one call"}],
"max": 30, "footer": "wiki.adom.inc/adom/kicad-bridge",
"narration": "Real time to route the six nets: ..."
},
"segments": [
{
"clip": "take1.mp4",
"label": "1 / 3 Freerouting",
"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
}
]
}
"""
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 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"
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):
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
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):
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 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)
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 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))
region = seg.get("region") or region # a clip with its own layout (another box, a caption band) watches its own region
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"]), dur(seg["narration_mp3"]), "given"
text = seg.get("narration")
if not text:
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)
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 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)
im = Image.new("RGB", (W, H), BG); d = ImageDraw.Draw(im); m = W / 1920
disp = ImageFont.truetype(str(fonts["display"]), int(64 * m)); body = ImageFont.truetype(str(fonts["body"]), int(34 * m))
mono = ImageFont.truetype(str(fonts["mono"]), int(40 * m)); monoS = ImageFont.truetype(str(fonts["mono"]), int(30 * m))
# title: shrink until it fits the frame width (a chart title that runs off the edge is worse than a smaller one)
title = chart.get("title", ""); size = int(64 * m)
while size > int(34 * m) and d.textlength(title, font=ImageFont.truetype(str(fonts["display"]), size)) > W - 240 * m:
size -= 2
disp = ImageFont.truetype(str(fonts["display"]), size)
d.text((120 * m, 100 * m), title, font=disp, fill=TEXT)
subs = chart.get("subtitle", []); subs = [subs] if isinstance(subs, str) else subs
for i, line in enumerate(subs[:3]):
d.text((120 * m, (190 + 46 * i) * m), line, font=body, fill=TXT2)
rows = chart.get("rows", []); vmax = float(chart.get("max") or max((float(r.get("value", 0)) for r in rows), default=1) * 1.08)
x0, y0, bw, rowh = 520 * m, (360 if len(subs) > 1 else 320) * m, 900 * m, min(170 * m, (H - 500 * m) / max(1, len(rows)))
for i, r in enumerate(rows):
y = y0 + i * rowh; val = float(r.get("value", 0)); unit = r.get("unit", "")
d.text((120 * m, y + 30 * m), str(r.get("name", "")), font=body, fill=TEXT)
d.rounded_rectangle((x0, y + 18 * m, x0 + bw, y + 98 * m), radius=int(12 * m), fill=PANEL)
w = max(24 * m, bw * val / vmax if vmax else 0)
d.rounded_rectangle((x0, y + 18 * m, x0 + w, y + 98 * m), radius=int(12 * m), fill=ACCENT)
label = f"{val:g}{(' ' + unit) if unit else ''}"; lx = x0 + bw + 28 * m
d.text((lx, y + 30 * m), label, font=mono, fill=TEXT)
if r.get("note"):
# the note sits under the bar on its own line, so it can never run off the right edge
d.text((x0, y + 106 * m), str(r["note"]), font=monoS, fill=TXT2)
if chart.get("footer"):
d.text((120 * m, H - 150 * m), chart["footer"], font=monoS, fill=ACCENT)
im.save(path)
def chart_segment(chart, W, H, fps, work, fonts):
"""A narrated chart at the end: lasts as long as its narration (or `seconds`), fades in."""
png = work / "chart.png"; render_chart(png, chart, W, H, fonts)
mp3, nd, how = fit_narration("chart", {"narration": chart.get("narration"), "narration_mp3": chart.get("narration_mp3")}, 10_000, work) if (chart.get("narration") or chart.get("narration_mp3")) else (None, 0.0, "none")
D = round(max(float(chart.get("seconds", 0)), nd + 0.6, 3.0), 3)
out = work / "chart-seg.mp4"
if mp3:
sh(["ffmpeg", "-y", "-v", "error", "-loop", "1", "-i", str(png), "-i", str(mp3), "-filter_complex",
f"[0:v]fps={fps},format=yuv420p,fade=t=in:st=0:d=0.3[v];[1:a]aresample=48000,apad,atrim=0:{D:.3f},asetpts=PTS-STARTPTS[a]",
"-map", "[v]", "-map", "[a]", "-t", f"{D:.3f}", "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "160k", "-ar", "48000", "-ac", "2", str(out)])
else:
sh(["ffmpeg", "-y", "-v", "error", "-loop", "1", "-i", str(png), "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo", "-filter_complex", f"[0:v]fps={fps},format=yuv420p,fade=t=in:st=0:d=0.3[v]",
"-map", "[v]", "-map", "1:a", "-t", f"{D:.3f}", "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "160k", str(out)])
return out, D, {"seconds": round(nd, 2), "fit": how}
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))
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}
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 = []; 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:
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)
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)])
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__":
main()