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.
406 lines
15 KiB
Python
Executable File
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()
|