Files
pf-linux-port/scripts/screencast/join.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

190 lines
7.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Join the phases into one continuous video per language, with transitions.
Each phase fades into a short "chapter" card that names it, and the card
fades into the next phase - one continuous stream, no hard cuts, so the
viewer always knows where they are.
./scripts/screencast/join.py --lang en
"""
import argparse
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HERE))
OUT = os.path.join(REPO, "out", "screencast")
ART = os.path.join(OUT, "art")
PHASE_ORDER = ["00-ambiente", "01-implementacao", "02-ativacao", "03-regras",
"04-roteamento", "05-balanceamento", "06-qos"]
TITLES = {
"00-ambiente": ("The lab environment", "O ambiente de testes",
"Four Alpine VMs under libvirt", "Quatro VMs Alpine sob libvirt"),
"01-implementacao": ("Implementation in the kernel", "Implementacao no kernel",
"OpenBSD 8.0 sources + a Linux compatibility layer",
"Fontes do OpenBSD 8.0 + camada de compatibilidade Linux"),
"02-ativacao": ("Activation", "Ativacao",
"insmod pf.ko, then pfctl over the OpenBSD ioctl ABI",
"insmod pf.ko e o pfctl pela ABI de ioctls do OpenBSD"),
"03-regras": ("Basic rules", "Regras basicas",
"Tables, anchors, keep state, block drop",
"Tabelas, anchors, keep state, block drop"),
"04-roteamento": ("Routing", "Roteamento",
"route-to and reply-to", "route-to e reply-to"),
"05-balanceamento": ("Load balancing", "Balanceamento",
"Pools round-robin, source-hash, dup-to",
"Pools round-robin, source-hash, dup-to"),
"06-qos": ("Quality of service", "Qualidade de servico",
"hfsc queues in the data path", "Filas hfsc no caminho de dados"),
}
def run(cmd):
subprocess.run(cmd, check=True)
def probe(path):
out = subprocess.run(["ffprobe", "-v", "error", "-show_entries",
"format=duration", "-of", "csv=p=0", path],
capture_output=True, text=True).stdout.strip()
return float(out or 0)
def chapter_card(phase, lang, out_path, seconds=1.8):
"""A short card naming the phase; it is the pivot of the crossfade."""
title = TITLES[phase][0 if lang == "en" else 1]
sub = TITLES[phase][2 if lang == "en" else 3]
num = PHASE_ORDER.index(phase) + 1
art = None
if os.path.isdir(ART):
imgs = sorted(f for f in os.listdir(ART) if f.endswith((".jpg", ".png")))
if imgs:
art = os.path.join(ART, imgs[0])
script = os.path.join(OUT, "_chapter.py")
with open(script, "w") as f:
f.write(f"""
import os, sys
from PIL import Image, ImageDraw, ImageFont
W, H = 1920, 1080
BG = (12, 16, 22); ACCENT = (88, 168, 255); FG = (240, 246, 252); MUTED = (140, 150, 165)
mono = "/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf"
sans = "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf"
sansb = "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf"
art = {art!r}
img = Image.new("RGB", (W, H), BG)
if art and os.path.exists(art):
img = Image.blend(img, Image.open(art).resize((W, H)).convert("RGB"), 0.5)
d = ImageDraw.Draw(img, "RGBA")
d.rectangle([0, 0, W, 8], fill=ACCENT)
d.text((140, 430), {title!r}, font=ImageFont.truetype(sansb, 62), fill=FG)
d.text((140, 520), {sub!r}, font=ImageFont.truetype(sans, 30), fill=MUTED)
d.text((140, 586), "PHASE %d OF %d" % ({num}, {len(PHASE_ORDER)}),
font=ImageFont.truetype(mono, 24), fill=ACCENT)
d.text((140, 640), "PF on Linux - OpenBSD PF 8.0 no kernel Linux",
font=ImageFont.truetype(mono, 24), fill=MUTED)
img.save(sys.argv[1])
""")
png = out_path + ".png"
run([sys.executable, script, png])
have = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
capture_output=True, text=True).stdout
venc = (["-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-profile:v", "high"]
if "libx264" in have else
["-c:v", "libopenh264", "-b:v", "6M", "-profile:v", "high"])
run(["ffmpeg", "-y", "-loglevel", "error", "-loop", "1", "-i", png,
"-t", str(seconds), "-r", "30"] + venc + ["-pix_fmt", "yuv420p", out_path])
os.remove(png)
os.remove(script)
return out_path
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--lang", required=True, choices=["en", "pt"])
ap.add_argument("--out")
ap.add_argument("--fade", type=float, default=0.7)
args = ap.parse_args()
lang = args.lang
tmp = os.path.join(OUT, "_tmp")
os.makedirs(tmp, exist_ok=True)
segs = [] # (path, duration)
for phase in PHASE_ORDER:
src = os.path.join(OUT, lang, f"{phase}.mp4")
if not os.path.exists(src):
sys.stderr.write(f"[join] missing {src}, skipping\n")
continue
segs.append((src, probe(src)))
card = chapter_card(phase, lang, os.path.join(tmp, f"card-{phase}.mp4"))
segs.append((card, probe(card)))
if not segs:
raise SystemExit("[join] nothing to join")
inputs = []
for path, _ in segs:
inputs += ["-i", path]
# sequential xfade: each segment fades into the next
filters = []
prev = "0:v"
offset = segs[0][1] - args.fade
for i in range(1, len(segs)):
filters.append(f"[{prev}][{i}:v]xfade=transition=fade:"
f"duration={args.fade}:offset={offset:.3f}[v{i}]")
prev = f"v{i}"
offset += segs[i][1] - args.fade
# The audio has to follow the same timeline: with xfade the segments
# overlap by the fade duration, so each one starts `fade` earlier than
# the plain sum would suggest.
audio_at = []
acc = 0.0
for i, (_, dur) in enumerate(segs):
audio_at.append(max(0.0, acc - args.fade * i))
acc += dur
# Only the phase videos carry narration; the chapter cards are silent.
# Their input indices are the even ones in `inputs`.
phase_idx = [i for i in range(len(segs)) if i % 2 == 0]
afilters = []
labels = []
for k, i in enumerate(phase_idx):
ms = int(audio_at[i] * 1000)
afilters.append(f"[{i}:a]adelay={ms}|{ms}[a{k}]")
labels.append(f"[a{k}]")
afilters.append("".join(labels) +
f"amix=inputs={len(phase_idx)}:normalize=0,"
f"atrim=0:{audio_at[-1] + segs[-1][1]:.3f},"
f"asetpts=N/SR/TB[amix]")
filters += afilters
amap = "[amix]"
out = args.out or os.path.join(OUT, lang, "pf-on-linux-full.mp4")
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"]
# the phases already carry their narration; the cards are silent
run(["ffmpeg", "-y", "-loglevel", "error"] + inputs +
["-filter_complex", ";".join(filters), "-map", f"[{prev}]",
"-map", amap] + venc +
["-pix_fmt", "yuv420p", "-movflags", "+faststart", out])
print(f"[join] {out} {probe(out):.1f}s")
if __name__ == "__main__":
main()