Files
pf-linux-port/scripts/screencast/render_video.py
T
lgcosta cc67531602 Screencasts v2: real shell sessions, MiniMax narration (EN/PT), lab diagram
O problema da primeira serie era a sensacao de 'quadro a quadro': a captura
era a saida de um script despejada em blocos.  Agora cada video e uma sessao
shell de verdade.

- record.py: abre um pty, faz SSH no firewall e digita os comandos tecla a
  tecla (~34 c/s, com jitter).  O tty remoto ecoa, o kernel responde ao vivo;
  a captura e asciicast v2 com timestamps.  'bash --noediting' evita que o
  editor de linha redesenhe o comando e o tty o repita (a linha aparecia
  duplicada).
- record.py --pace: segura a tela enquanto a voz fala - cada batida dura o
  trecho de audio que a acompanha, de modo que uma unica captura serve as
  duas versoes de idioma.
- record.py --ensure-pf: garante pf.ko carregado antes de gravar.  Sem isso
  uma fase que roda 'rmmod' (ativacao) pode derrubar o /dev/pf de outra
  gravacao concorrentes - foi exatamente o que quebrou a fase de roteamento
  na primeira tentativa.
- make_audio.py: geracao da narracao pelo MiniMax TTS, uma voz feminina por
  idioma (English_ConfidentWoman / Portuguese_ConfidentWoman), um arquivo
  por segmento.  A API devolve o audio em hexadecimal, nao base64.
- render_video.py: reproduz a captura por um emulador de terminal e escreve
  os frames direto no ffmpeg como RGB bruto (um PNG por frame era uma ordem
  de grandeza mais lento).  Desenha o diagrama de rede do lab por cima do
  terminal nas fases de roteamento e balanceamento, destacando o caminho em
  discussao, e queima a legenda da narracao.
- assemble.py: mixa os segmentos de audio na linha do tempo.
- join.py: emenda as fases com crossfade sobre um cartao de capitulo e
  sincroniza o audio na mesma linha do tempo.
- build-all.sh: roda o pipeline inteiro.

Arte de fundo e audio vem do MiniMax; a geracao de video do MiniMax nao esta
habilitada nesta conta (erro 2068 em todos os modelos), entao as transicoes
sao renderizadas localmente com ffmpeg.

16 videos: 7 fases + filme completo, em ingles e portugues, com legenda
queimada.
2026-11-12 10:57:29 -03:00

406 lines
15 KiB
Python
Executable File

#!/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()