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.
242 lines
9.2 KiB
Python
Executable File
242 lines
9.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Record a *real* interactive shell session on the lab firewall, as asciicast v2.
|
|
|
|
Why this exists: the first screencast batch rendered a script whose output was
|
|
dumped in blocks, which looked like a slideshow. Here the session is captured
|
|
as it happens - an SSH session into the Alpine firewall, commands typed key by
|
|
key, the remote tty echoing them, the kernel answering in real time. The
|
|
capture is raw bytes with timestamps, so any renderer can replay it at any
|
|
speed, and the same capture serves both narrated languages.
|
|
|
|
./scripts/screencast/record.py --plan plans/04-roteamento.json \\
|
|
--out out/screencast/caps/04-roteamento.cast
|
|
|
|
The plan is JSON:
|
|
|
|
{
|
|
"title": "...", "subtitle": "...",
|
|
"beats": [
|
|
{"id": "b1", "cmds": ["uname -r", "lsmod"]},
|
|
{"id": "b2", "cmds": ["pfctl -f /tmp/x.conf"], "note": "..."}
|
|
]
|
|
}
|
|
"""
|
|
import argparse
|
|
import errno
|
|
import fcntl
|
|
import json
|
|
import os
|
|
import pty
|
|
import re
|
|
import select
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import termios
|
|
import time
|
|
|
|
import pyte
|
|
|
|
# The prompt of the remote shell is "root@pf0:~# ". A prompt is only accepted
|
|
# when it is the *whole* line: while a command is being typed, the line still
|
|
# ends with the command text, never with the prompt marker.
|
|
PROMPT_RE = re.compile(r"^\S+:[^\r\n]{0,80}[#$]\s*$")
|
|
COLS, ROWS = 118, 34
|
|
|
|
|
|
class RemoteShell:
|
|
"""An SSH session on a pty, with a terminal emulator on our side."""
|
|
|
|
def __init__(self, argv, env=None):
|
|
self.master, slave = pty.openpty()
|
|
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", ROWS, COLS, 0, 0))
|
|
e = dict(os.environ)
|
|
e.update({"TERM": "xterm-256color", "LC_ALL": "C.UTF-8", "LANG": "C.UTF-8"})
|
|
if env:
|
|
e.update(env)
|
|
self.proc = subprocess.Popen(argv, stdin=slave, stdout=slave, stderr=slave,
|
|
env=e, start_new_session=True, close_fds=True)
|
|
os.close(slave)
|
|
self.screen = pyte.Screen(COLS, ROWS)
|
|
self.screen.set_mode(pyte.modes.LNM)
|
|
self.stream = pyte.ByteStream(self.screen)
|
|
self.events = [] # (t, bytes)
|
|
|
|
def pump(self, duration):
|
|
end = time.time() + duration
|
|
while True:
|
|
remaining = end - time.time()
|
|
if remaining <= 0:
|
|
return
|
|
try:
|
|
r, _, _ = select.select([self.master], [], [], min(remaining, 0.05))
|
|
except InterruptedError:
|
|
continue
|
|
if self.master not in r:
|
|
continue
|
|
try:
|
|
data = os.read(self.master, 65536)
|
|
except OSError as exc:
|
|
if exc.errno == errno.EIO:
|
|
return
|
|
raise
|
|
if not data:
|
|
return
|
|
self.stream.feed(data)
|
|
self.events.append((time.time(), data))
|
|
|
|
def last_line(self):
|
|
lines = [l for l in self.screen.display if l.strip()]
|
|
return lines[-1].rstrip() if lines else ""
|
|
|
|
def at_prompt(self):
|
|
return bool(PROMPT_RE.match(self.last_line()))
|
|
|
|
def wait_prompt(self, timeout=90.0):
|
|
end = time.time() + timeout
|
|
while time.time() < end:
|
|
self.pump(0.05)
|
|
if self.at_prompt():
|
|
self.pump(0.12)
|
|
if self.at_prompt():
|
|
return True
|
|
return False
|
|
|
|
def type_line(self, text, cps=34.0):
|
|
"""Type a line the way a person does.
|
|
|
|
The remote tty echoes the characters back, exactly like a real
|
|
terminal - that echo is what ends up in the capture, so the video
|
|
shows the command being written, one character at a time.
|
|
"""
|
|
for ch in text:
|
|
os.write(self.master, ch.encode())
|
|
time.sleep(1.0 / cps + (ord(ch) % 7) * 0.0035)
|
|
os.write(self.master, b"\r")
|
|
self.pump(0.15)
|
|
|
|
def send(self, text):
|
|
os.write(self.master, text.encode())
|
|
self.pump(0.2)
|
|
|
|
|
|
def beat_pacing(phase):
|
|
"""Minimum on-screen time per beat, taken from the narration audio.
|
|
|
|
Only this phase's narration counts: beat ids repeat across phases, so
|
|
mixing them would stretch a beat by another phase's sentence.
|
|
"""
|
|
here = os.path.dirname(os.path.abspath(__file__))
|
|
vo = os.path.abspath(os.path.join(here, "..", "..", "out", "screencast", "vo"))
|
|
out = {}
|
|
for lang in ("en", "pt"):
|
|
script = json.load(open(os.path.join(here, "narration", f"{phase}.{lang}.json")))
|
|
for i, seg in enumerate(script["segments"]):
|
|
audio = os.path.join(vo, f"{phase}.{lang}", f"{i:02d}-{seg['beat']}.mp3")
|
|
if not os.path.exists(audio):
|
|
continue
|
|
dur = subprocess.run(
|
|
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
|
"-of", "csv=p=0", audio],
|
|
capture_output=True, text=True).stdout.strip()
|
|
try:
|
|
d = float(dur)
|
|
except ValueError:
|
|
continue
|
|
out[seg["beat"]] = max(out.get(seg["beat"], 0.0), d + 0.8)
|
|
return out
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--plan", required=True)
|
|
ap.add_argument("--out", required=True)
|
|
ap.add_argument("--host", default="192.168.122.8")
|
|
ap.add_argument("--jump", default="root@192.168.1.242")
|
|
ap.add_argument("--key", default=os.path.expanduser("~/.ssh/pflab_ed"))
|
|
ap.add_argument("--cps", type=float, default=34.0)
|
|
ap.add_argument("--pace", action="store_true",
|
|
help="hold each beat long enough for its narration")
|
|
ap.add_argument("--ensure-pf", action="store_true",
|
|
help="make sure pf.ko is loaded before the capture starts")
|
|
args = ap.parse_args()
|
|
|
|
plan = json.load(open(args.plan))
|
|
os.makedirs(os.path.dirname(os.path.abspath(args.out)) or ".", exist_ok=True)
|
|
|
|
# A login shell would print the Alpine MOTD; running bash directly with a
|
|
# fixed PS1 gives a clean, deterministic opening - one prompt, no banner.
|
|
# --noediting turns off readline: with it, the line editor redraws the
|
|
# command and the terminal echoes it a second time. Without it, the
|
|
# command appears exactly once, the way a real terminal shows it.
|
|
argv = ["ssh", "-tt", "-J", args.jump,
|
|
"-o", "StrictHostKeyChecking=accept-new", "-o", "LogLevel=ERROR",
|
|
"-i", args.key, f"root@{args.host}",
|
|
"env PS1='\\u@\\h:\\w\\$ ' LC_ALL=C.UTF-8 LANG=C.UTF-8 "
|
|
"PATH=/opt/pf:$PATH bash --norc --noprofile --noediting -i"]
|
|
sh = RemoteShell(argv)
|
|
sh.pump(2.0)
|
|
sh.wait_prompt(timeout=40)
|
|
|
|
if args.ensure_pf:
|
|
# The module has to be in place before the capture starts: a phase
|
|
# that runs `rmmod` (activation) or a lost race with another
|
|
# recording would otherwise leave /dev/pf missing mid-session.
|
|
sh.send("lsmod | grep -q '^pf ' || insmod /opt/pf/pf.ko\n")
|
|
sh.wait_prompt(timeout=60)
|
|
sh.send("pfctl -e\n")
|
|
sh.wait_prompt(timeout=60)
|
|
|
|
# Drop the opening prompt: the capture must start with the first command.
|
|
sh.events = []
|
|
|
|
# How long each beat has to stay on screen: the narration of that beat
|
|
# must fit, otherwise the voice would talk over the next one. The
|
|
# longest of the two languages wins, so one capture serves both.
|
|
pace = {}
|
|
if args.pace:
|
|
pace = beat_pacing(plan.get("phase") or os.path.basename(args.plan)[:-5])
|
|
|
|
beats = []
|
|
for beat in plan["beats"]:
|
|
b_start = time.time() - sh.events[0][0] if sh.events else 0.0
|
|
for cmd in beat["cmds"]:
|
|
sh.type_line(cmd, cps=args.cps)
|
|
if not sh.wait_prompt(timeout=150):
|
|
sys.stderr.write(f"[record] WARNING: no prompt after: {cmd}\n")
|
|
sh.pump(0.45)
|
|
b_end = time.time() - sh.events[0][0] if sh.events else 0.0
|
|
# hold the screen while the narrator talks about what just happened
|
|
want = pace.get(beat["id"], 0.0)
|
|
left = want - (b_end - b_start)
|
|
if left > 0.1:
|
|
sh.pump(left)
|
|
b_end = time.time() - sh.events[0][0] if sh.events else b_end
|
|
sys.stderr.write(f"[record] {plan.get('phase', '?')} {beat['id']}: "
|
|
f"{b_start:.1f}-{b_end:.1f}s (narration {want:.1f}s)\n")
|
|
beats.append({"id": beat["id"], "start": b_start, "end": b_end,
|
|
"note": beat.get("note", "")})
|
|
|
|
sh.send("exit\n")
|
|
sh.pump(0.8)
|
|
|
|
t0 = sh.events[0][0]
|
|
header = {"version": 2, "width": COLS, "height": ROWS,
|
|
"timestamp": int(time.time()),
|
|
"title": plan.get("title", ""), "subtitle": plan.get("subtitle", "")}
|
|
with open(args.out, "w") as f:
|
|
f.write(json.dumps(header) + "\n")
|
|
for t, data in sh.events:
|
|
f.write(json.dumps([round(t - t0, 4), "o", data.decode("utf-8", "replace")]) + "\n")
|
|
meta = {"title": plan.get("title", ""), "subtitle": plan.get("subtitle", ""),
|
|
"duration": sh.events[-1][0] - t0, "beats": beats}
|
|
with open(args.out.replace(".cast", ".json"), "w") as f:
|
|
json.dump(meta, f, indent=2)
|
|
sys.stderr.write(f"[record] {args.out}: {len(sh.events)} chunks, "
|
|
f"{meta['duration']:.1f}s, {len(beats)} beats\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|