#!/usr/bin/env python3 """ Render a recorded shell session (asciicast v2) into a narrated, subtitled MP4. The recording is replayed through a terminal emulator, so the video shows the session as it was typed: characters appear one by one, output scrolls in. Three layers end up on screen: * the terminal, framed like a real window; * a *network diagram* of the lab, drawn over the terminal in the phases that talk about routing and balancing - it highlights the path the packet really took; * burned-in subtitles of the narration, in the language of the track. The narration audio is generated by MiniMax (female voices) and mixed in at the moment the matching beat appears on screen. ./scripts/screencast/render_video.py --cast out/screencast/caps/04.cast \\ --narration scripts/screencast/narration/04.en.json \\ --out out/screencast/en/04-roteamento.mp4 --lang en """ import argparse import json import os import shutil import subprocess import sys import pyte from PIL import Image, ImageDraw, ImageFont # ---------------------------------------------------------------- palette --- BG = (10, 13, 18) PANEL = (17, 21, 27) BORDER = (46, 52, 60) FG = (205, 213, 221) HEADER_BG = (20, 25, 32) HEADER_FG = (240, 246, 252) ACCENT = (88, 168, 255) ACCENT2 = (56, 199, 209) MUTED = (110, 118, 130) GREEN = (63, 185, 80) YELLOW = (212, 155, 36) RED = (255, 123, 114) TITLE_BG = (14, 18, 24) SUB_BG = (8, 10, 14) NAMED = { "black": (48, 54, 61), "red": RED, "green": GREEN, "yellow": YELLOW, "blue": ACCENT, "magenta": (188, 140, 255), "cyan": ACCENT2, "white": FG, "brown": (168, 115, 66), "default": None, } def color_of(spec, default): if spec == "default": return default if spec in NAMED: return NAMED[spec] or default try: if len(spec) == 6: return tuple(int(spec[i:i + 2], 16) for i in (0, 2, 4)) except ValueError: pass return default def load_fonts(): mono = "/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf" monob = "/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono-Bold.ttf" sans = "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf" sansb = "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf" return { "term": ImageFont.truetype(mono, 18), "termb": ImageFont.truetype(monob, 18), "small": ImageFont.truetype(mono, 16), "sans": ImageFont.truetype(sans, 20), "sansb": ImageFont.truetype(sansb, 32), "sub": ImageFont.truetype(sans, 25), } # ------------------------------------------------------------- lab diagram --- # The topology the lab really has (see docs/en/vm-validation.md): # cli 10.0.1.10 --- (eth1) pf0 (eth2) --- srv 10.0.2.20 # pf0 (eth3) --- gw2 10.0.3.2 class Diagram: """Small, precise network map of the lab, drawn with PIL.""" NODES = { "cli": (110, 300, "cli\n10.0.1.10", (72, 170, 255)), "pf0": (560, 300, "pf0 (firewall)\n10.0.1.1 / 10.0.2.1 / 10.0.3.1", (56, 199, 209)), "srv": (1010, 430, "srv\n10.0.2.20", (63, 185, 80)), "gw2": (1010, 90, "gw2\n10.0.3.2", (212, 155, 36)), } LINKS = [ ("cli", "pf0", "eth1"), ("pf0", "srv", "eth2"), ("pf0", "gw2", "eth3"), ] def __init__(self, width, height, fonts): self.w, self.h = width, height self.f = fonts def draw(self, img, highlight=None, caption=None): d = ImageDraw.Draw(img, "RGBA") W, H = self.w, self.h panel = [W - 660, 206, W - 44, 622] d.rounded_rectangle(panel, radius=14, fill=(14, 19, 26, 240), outline=(58, 68, 80, 255), width=2) d.text((panel[0] + 22, panel[1] + 16), "LAB - libvirt, 4 VMs Alpine", font=self.f["small"], fill=(140, 190, 230)) ox, oy = panel[0] + 40, panel[1] + 60 s = min((panel[2] - panel[0] - 80) / 1010.0, (panel[3] - panel[1] - 130) / 430.0) def pos(name): x, y, _, _ = self.NODES[name] return (ox + x * s, oy + y * s) hot_links = set() for a, b, label in self.LINKS: hot = highlight and {a, b} <= set(highlight) (x1, y1), (x2, y2) = pos(a), pos(b) if hot: hot_links.add(label) d.line([x1, y1, x2, y2], fill=(56, 199, 209, 255), width=7) mx, my = (x1 + x2) / 2, (y1 + y2) / 2 d.ellipse([mx - 9, my - 9, mx + 9, my + 9], fill=(56, 199, 209, 255)) d.text((mx + 14, my - 24), label, font=self.f["small"], fill=(56, 199, 209, 255)) else: d.line([x1, y1, x2, y2], fill=(104, 114, 126, 170), width=3) d.text(((x1 + x2) / 2 + 14, (y1 + y2) / 2 - 24), label, font=self.f["small"], fill=(112, 120, 132, 190)) for name, (x, y, label, col) in self.NODES.items(): cx, cy = pos(name) hot = highlight and name in highlight r = 26 if name != "pf0" else 34 if hot: d.ellipse([cx - r - 9, cy - r - 9, cx + r + 9, cy + r + 9], fill=(col[0], col[1], col[2], 55)) d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=col) for i, line in enumerate(label.split("\n")): tw = d.textlength(line, font=self.f["small"]) d.text((cx - tw / 2, cy + r + 6 + i * 20), line, font=self.f["small"], fill=(224, 231, 240)) if caption: d.text((panel[0] + 22, panel[3] - 32), caption, font=self.f["small"], fill=(150, 200, 235)) # ------------------------------------------------------------- rendering --- def wrap(text, font, draw, max_w): words, lines, cur = text.split(), [], "" for w in words: trial = (cur + " " + w).strip() if draw.textlength(trial, font=font) <= max_w: cur = trial else: if cur: lines.append(cur) cur = w if cur: lines.append(cur) return lines def render_frame(screen, fonts, title, subtitle, phase_no, phase_total, footer, diagram, highlight, diagram_caption, art, subtitle_text): W, H = 1920, 1080 img = Image.new("RGB", (W, H), BG) if art is not None: img = Image.blend(img, art, 0.16) d = ImageDraw.Draw(img, "RGBA") d.rectangle([0, 0, W, 66], fill=HEADER_BG) d.text((44, 20), "PF on Linux", font=fonts["sansb"], fill=HEADER_FG) tw = d.textlength("PF on Linux", font=fonts["sansb"]) d.text((44 + tw + 22, 28), "OpenBSD PF 8.0 engine inside the Linux kernel", font=fonts["small"], fill=MUTED) if phase_total: label = f"FASE {phase_no}/{phase_total}" lw = d.textlength(label, font=fonts["small"]) d.text((W - 44 - lw, 26), label, font=fonts["small"], fill=ACCENT) d.text((44, 88), title, font=fonts["sansb"], fill=HEADER_FG) if subtitle: d.text((44, 134), subtitle, font=fonts["sans"], fill=MUTED) top, left, right = 182, 44, W - 44 bottom = H - 168 if subtitle_text else H - 84 d.rectangle([left, top, right, bottom], fill=PANEL, outline=BORDER, width=2) d.rectangle([left, top, right, top + 36], fill=HEADER_BG) for i, c in enumerate([(255, 95, 86), (255, 189, 46), (39, 201, 63)]): d.ellipse([left + 20 + i * 22, top + 12, left + 30 + i * 22, top + 22], fill=c) d.text((left + 92, top + 9), "root@pf0 - alpine, kernel 7.3.4 + pf.ko - lab libvirt", font=fonts["small"], fill=MUTED) x0, y0, lh = left + 24, top + 52, 25 cw = d.textlength("M", font=fonts["term"]) for y in range(screen.lines): line = screen.buffer[y] yy = y0 + y * lh if yy > bottom - 30: break # group consecutive cells with the same style into one draw call x = 0 while x < screen.columns: ch = line[x] if ch.data in (" ", ""): x += 1 continue fg = color_of(ch.fg, FG) bold = ch.bold run = [] while x < screen.columns: c = line[x] if c.data in ("", " ") or color_of(c.fg, FG) != fg or c.bold != bold: break run.append(c.data) x += 1 d.text((x0 + (x - len(run)) * cw, yy), "".join(run), font=fonts["termb"] if bold else fonts["term"], fill=fg) cx, cy = screen.cursor.x, screen.cursor.y if 0 <= cy < screen.lines and 0 <= cx < screen.columns: d.rectangle([x0 + cx * cw, y0 + cy * lh, x0 + (cx + 1) * cw - 1, y0 + (cy + 1) * lh - 1], fill=ACCENT) if diagram is not None: diagram.draw(img, highlight, diagram_caption) if subtitle_text: lines = wrap(subtitle_text, fonts["sub"], d, W - 260) bh = len(lines) * 34 + 22 by = H - 76 - bh d.rounded_rectangle([70, by, W - 70, by + bh], radius=10, fill=(6, 8, 12, 215), outline=(48, 56, 66, 220), width=2) for i, line in enumerate(lines): lw2 = d.textlength(line, font=fonts["sub"]) d.text(((W - lw2) / 2, by + 11 + i * 34), line, font=fonts["sub"], fill=(236, 241, 248)) d.text((44, H - 56), footer, font=fonts["small"], fill=MUTED) url = "git.labexposed.com/lgcosta/pf-linux-port" uw = d.textlength(url, font=fonts["small"]) d.text((W - 44 - uw, H - 56), url, font=fonts["small"], fill=MUTED) return img def render_title(title, subtitle, phase_no, phase_total, fonts, art): W, H = 1920, 1080 img = Image.new("RGB", (W, H), TITLE_BG) if art is not None: img = Image.blend(img, art, 0.55) d = ImageDraw.Draw(img, "RGBA") d.rectangle([0, 0, W, 8], fill=ACCENT) d.text((120, 430), title, font=fonts["sansb"], fill=(250, 252, 255)) if subtitle: d.text((120, 494), subtitle, font=fonts["sans"], fill=(190, 200, 214)) if phase_total: d.text((120, 562), f"FASE {phase_no} DE {phase_total}", font=fonts["small"], fill=ACCENT) d.text((120, 614), "PF on Linux - OpenBSD PF 8.0 no kernel Linux", font=fonts["small"], fill=(150, 160, 175)) return img def srt_time(t): h = int(t // 3600) m = int((t % 3600) // 60) s = t % 60 return f"{h:02d}:{m:02d}:{s:06.3f}".replace(".", ",") def main(): ap = argparse.ArgumentParser() ap.add_argument("--cast", required=True) ap.add_argument("--out", required=True) ap.add_argument("--narration", required=True) ap.add_argument("--lang", default="en") ap.add_argument("--phase-no", type=int, default=0) ap.add_argument("--phase-total", type=int, default=0) ap.add_argument("--title-card", type=float, default=2.6) ap.add_argument("--art") ap.add_argument("--fps", type=int, default=30) ap.add_argument("--diagram", action="store_true", help="draw the lab diagram over the terminal") ap.add_argument("--footer", default="PF on Linux - OpenBSD PF 8.0 no kernel Linux - lab libvirt") ap.add_argument("--keep-frames") args = ap.parse_args() fonts = load_fonts() art = None if args.art and os.path.exists(args.art): # resize once: doing it per frame costs more than the whole draw art = Image.open(args.art).resize((1920, 1080)).convert("RGB") with open(args.cast) as f: lines = f.read().splitlines() header = json.loads(lines[0]) events = [json.loads(l) for l in lines[1:]] meta = json.load(open(args.cast.replace(".cast", ".json"))) nar = json.load(open(args.narration)) title = nar.get("title", header.get("title", "")) subtitle = nar.get("subtitle", header.get("subtitle", "")) # map every narration segment onto the beat it belongs to beats = {b["id"]: b for b in meta["beats"]} segs = [] for i, seg in enumerate(nar["segments"]): beat = beats.get(seg["beat"]) if not beat: continue segs.append({ "index": i, "beat": seg["beat"], "start": beat["start"], "end": beat["end"], "text": seg["text"], "highlight": seg.get("highlight"), "diagram_caption": seg.get("diagram_caption"), }) outdir = os.path.dirname(os.path.abspath(args.out)) os.makedirs(outdir, exist_ok=True) # frames go straight into ffmpeg as raw RGB: writing a PNG per frame is # an order of magnitude slower and buys nothing. have = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True).stdout if "libx264" in have: venc = ["-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-profile:v", "high", "-level", "4.0"] elif "libopenh264" in have: venc = ["-c:v", "libopenh264", "-b:v", "6M", "-profile:v", "high"] else: venc = ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "30", "-row-mt", "1", "-cpu-used", "4"] ff = subprocess.Popen( ["ffmpeg", "-y", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", "1920x1080", "-r", str(args.fps), "-i", "-", "-pix_fmt", "yuv420p", "-movflags", "+faststart"] + venc + [args.out], stdin=subprocess.PIPE) screen = pyte.Screen(header["width"], header["height"]) screen.set_mode(pyte.modes.LNM) stream = pyte.ByteStream(screen) diagram = Diagram(1920, 1080, fonts) n = 0 title_img = render_title(title, subtitle, args.phase_no, args.phase_total, fonts, art) for _ in range(int(args.title_card * args.fps)): ff.stdin.write(title_img.tobytes()) n += 1 # subtitle state: the spoken sentence of the segment currently running sub_text = None sub_until = 0.0 end_t = events[-1][0] if events else 0.0 t = 0.0 idx = 0 frame_dt = 1.0 / args.fps while t <= end_t: while idx < len(events) and events[idx][0] <= t: stream.feed(events[idx][2].encode()) idx += 1 active = None for s in segs: if s["start"] <= t: active = s else: break if active is not None: if active["index"] != (sub_until or 0) and t >= active["start"]: sub_text = active["text"] sub_until = active["index"] if t > end_t - 0.05: sub_text = None img = render_frame(screen, fonts, title, subtitle, args.phase_no, args.phase_total, args.footer, diagram if args.diagram else None, active["highlight"] if active else None, active["diagram_caption"] if active else None, art, sub_text) ff.stdin.write(img.tobytes()) n += 1 t += frame_dt # the closing card holds the last frame ff.stdin.write(img.tobytes()) n += 1 for _ in range(int(1.6 * args.fps)): ff.stdin.write(img.tobytes()) n += 1 ff.stdin.close() if ff.wait() != 0: raise SystemExit("[render] ffmpeg failed") sys.stderr.write(f"[render] {args.out}: {n} frames, {end_t:.1f}s\n") if __name__ == "__main__": main()