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.
This commit is contained in:
1 parent
fe324259c0
commit
cc67531602
51 files changed
+2818
-71
No files matched your search
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/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()
|
||||
Reference in new issue
Block a user