Screencasts: seis videos silenciosos 1080p30 da demonstracao
Novo pipeline de video: scripts/screencast/render.py roda uma fase em um pty,
emula o terminal com pyte, gera frames PNG e mux com ffmpeg (H.264,
yuv420p, +faststart, sem trilha de audio). Fallback de encoder: libx264 ->
libopenh264 -> VP9.
Fases (scripts/screencast/phases/, uma sessao real contra o lab por video):
1. implementacao no kernel - fontes OpenBSD + compat + build limpo do pf.ko
2. ativacao - insmod, /dev/pf, pfctl -e, pfctl -s info
3. regras basicas - tabela, anchor, keep state, block drop
4. roteamento - route-to e reply-to (provado com tcpdump nas 2 interfaces)
5. balanceamento - pool round-robin, pool source-hash, dup-to
6. QOS - filas hfsc, pfctl -s queue -v, transferencia de 2 MB pela fila
Os videos ficam versionados em out/screencast/ (.gitignore ajustado para
nao ignorar *.mp4) e o indice/texto de descricao do YouTube em
docs/{en,pt-BR}/screencast.md.
Correcoes na propria demo (encontradas gravando): a acao de uma regra no pf
e pass OU block (nunca os dois); as regras de um anchor vivem em um ruleset
proprio (pfctl -a <anchor> -f); o nc do QOS precisa rodar na VM do servidor,
nao no firewall; o rotulo 'FASE x/6' vem do numero da fase, nao da posicao
na lista de gravacao.
This commit is contained in:
1 parent
90c51f43fc
commit
fe324259c0
20 files changed
+958
-2
No files matched your search
Executable
+336
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Render a shell session into a silent, YouTube-ready MP4 screencast.
|
||||
|
||||
Pipeline: pty -> raw bytes -> pyte (terminal emulation) -> PNG frames -> MP4
|
||||
|
||||
Every byte on screen is the real output of the command; only the pacing and
|
||||
the framing are produced here. No audio track is written (the videos are
|
||||
silent by design).
|
||||
|
||||
Usage:
|
||||
./scripts/screencast/render.py --script scripts/screencast/phases/02-ativacao.sh \
|
||||
--out out/screencast/02-ativacao.mp4 --title "Fase 2 - Ativacao" \
|
||||
--subtitle "insmod pf.ko / pfctl -e / pfctl -s info"
|
||||
"""
|
||||
import argparse
|
||||
import errno
|
||||
import fcntl
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import shutil
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import termios
|
||||
import time
|
||||
|
||||
import pyte
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# ---------------------------------------------------------------- palette ---
|
||||
BG = (14, 17, 23)
|
||||
PANEL = (20, 24, 31)
|
||||
BORDER = (48, 54, 61)
|
||||
FG = (201, 209, 217)
|
||||
HEADER_BG = (24, 29, 38)
|
||||
HEADER_FG = (240, 246, 252)
|
||||
ACCENT = (88, 166, 255)
|
||||
MUTED = (110, 118, 129)
|
||||
TITLE_BG = (17, 21, 28)
|
||||
|
||||
# pyte colour names -> RGB (a calm, high-contrast terminal palette)
|
||||
NAMED = {
|
||||
"black": (48, 54, 61),
|
||||
"red": (255, 123, 114),
|
||||
"green": (63, 185, 80),
|
||||
"yellow": (210, 153, 34),
|
||||
"blue": (88, 166, 255),
|
||||
"magenta": (188, 140, 255),
|
||||
"cyan": (57, 197, 207),
|
||||
"white": (201, 209, 217),
|
||||
"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
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""Runs a command on a pty and keeps a timestamped snapshot per output event."""
|
||||
|
||||
def __init__(self, cols, rows, env=None):
|
||||
self.cols, self.rows = cols, rows
|
||||
self.screen = pyte.Screen(cols, rows)
|
||||
self.screen.set_mode(pyte.modes.LNM)
|
||||
self.stream = pyte.ByteStream(self.screen)
|
||||
self.events = [] # (timestamp, rows_of_cells)
|
||||
self.t0 = time.time()
|
||||
|
||||
def snapshot(self):
|
||||
rows = []
|
||||
for y in range(self.rows):
|
||||
line = self.screen.buffer[y]
|
||||
cells = []
|
||||
for x in range(self.cols):
|
||||
ch = line[x]
|
||||
fg = color_of(ch.fg, FG)
|
||||
bold = ch.bold
|
||||
cells.append((ch.data or " ", fg, bold))
|
||||
rows.append(cells)
|
||||
return rows, (self.screen.cursor.y, self.screen.cursor.x)
|
||||
|
||||
def run(self, argv, env=None):
|
||||
master, slave = pty.openpty()
|
||||
fcntl.ioctl(slave, termios.TIOCSWINSZ,
|
||||
struct.pack("HHHH", self.rows, self.cols, 0, 0))
|
||||
e = dict(os.environ)
|
||||
e.update({"TERM": "xterm-256color", "COLUMNS": str(self.cols),
|
||||
"LINES": str(self.rows), "LC_ALL": "C.UTF-8"})
|
||||
if env:
|
||||
e.update(env)
|
||||
proc = subprocess.Popen(argv, stdin=slave, stdout=slave, stderr=slave,
|
||||
env=e, start_new_session=True, close_fds=True)
|
||||
os.close(slave)
|
||||
while True:
|
||||
try:
|
||||
r, _, _ = select.select([master], [], [], 0.25)
|
||||
except InterruptedError:
|
||||
continue
|
||||
if master in r:
|
||||
try:
|
||||
data = os.read(master, 65536)
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.EIO:
|
||||
break
|
||||
raise
|
||||
if not data:
|
||||
break
|
||||
self.stream.feed(data)
|
||||
rows, cur = self.snapshot()
|
||||
self.events.append((time.time() - self.t0, rows, cur))
|
||||
elif proc.poll() is not None:
|
||||
# drain whatever is left
|
||||
try:
|
||||
while True:
|
||||
data = os.read(master, 65536)
|
||||
if not data:
|
||||
break
|
||||
self.stream.feed(data)
|
||||
rows, cur = self.snapshot()
|
||||
self.events.append((time.time() - self.t0, rows, cur))
|
||||
except OSError:
|
||||
pass
|
||||
break
|
||||
proc.wait()
|
||||
os.close(master)
|
||||
if not self.events:
|
||||
rows, cur = self.snapshot()
|
||||
self.events.append((time.time() - self.t0, rows, cur))
|
||||
return proc.returncode
|
||||
|
||||
|
||||
# ------------------------------------------------------------- rendering ---
|
||||
def load_font(path, size):
|
||||
return ImageFont.truetype(path, size)
|
||||
|
||||
|
||||
def render_frame(rows, title, subtitle, phase_no, phase_total, cols, font, bold_font,
|
||||
small_font, footer, cursor=None, panel="alpine (pf0)"):
|
||||
W, H = 1920, 1080
|
||||
img = Image.new("RGB", (W, H), BG)
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
# header
|
||||
d.rectangle([0, 0, W, 74], fill=HEADER_BG)
|
||||
d.text((44, 26), "PF on Linux", font=bold_font, fill=HEADER_FG)
|
||||
tw = d.textlength("PF on Linux", font=bold_font)
|
||||
d.text((44 + tw + 26, 30), "-", font=font, fill=MUTED)
|
||||
d.text((44 + tw + 46, 30), "OpenBSD PF 8.0 engine inside the Linux kernel",
|
||||
font=font, fill=MUTED)
|
||||
if phase_total:
|
||||
label = f"FASE {phase_no}/{phase_total}"
|
||||
lw = d.textlength(label, font=small_font)
|
||||
d.text((W - 44 - lw, 32), label, font=small_font, fill=ACCENT)
|
||||
|
||||
# title block
|
||||
d.text((44, 112), title, font=bold_font, fill=HEADER_FG)
|
||||
if subtitle:
|
||||
d.text((44, 162), subtitle, font=font, fill=MUTED)
|
||||
|
||||
# terminal panel, sized to the content
|
||||
top = 212
|
||||
left = 44
|
||||
right = W - 44
|
||||
lh = 30
|
||||
used = 0
|
||||
for i, line in enumerate(rows):
|
||||
if any(c[0].strip() for c in line):
|
||||
used = i + 1
|
||||
bottom = min(H - 84, top + 44 + 28 + (max(used, 4) + 1) * lh)
|
||||
d.rectangle([left, top, right, bottom], fill=PANEL, outline=BORDER, width=2)
|
||||
d.rectangle([left, top, right, top + 44], fill=HEADER_BG)
|
||||
for i, c in enumerate([(255, 95, 86), (255, 189, 46), (39, 201, 63)]):
|
||||
d.ellipse([left + 24 + i * 26, top + 15, left + 38 + i * 26, top + 29], fill=c)
|
||||
d.text((left + 110, top + 13), panel, font=small_font, fill=MUTED)
|
||||
|
||||
# text
|
||||
x0 = left + 28
|
||||
y0 = top + 64
|
||||
cw = d.textlength("M", font=font)
|
||||
for i, line in enumerate(rows):
|
||||
y = y0 + i * lh
|
||||
if y > bottom - 10:
|
||||
break
|
||||
for x, (ch, fg, bold) in enumerate(line):
|
||||
if ch == " ":
|
||||
continue
|
||||
d.text((x0 + x * cw, y), ch, font=bold_font if bold else font, fill=fg)
|
||||
|
||||
# cursor block
|
||||
if cursor:
|
||||
cy, cx = cursor
|
||||
if 0 <= cy < len(rows) and 0 <= cx < cols:
|
||||
d.rectangle([x0 + cx * cw, y0 + cy * lh, x0 + (cx + 1) * cw - 1,
|
||||
y0 + (cy + 1) * lh - 1], fill=ACCENT)
|
||||
|
||||
# footer
|
||||
d.text((44, H - 58), footer, font=small_font, fill=MUTED)
|
||||
url = "git.labexposed.com/lgcosta/pf-linux-port"
|
||||
uw = d.textlength(url, font=small_font)
|
||||
d.text((W - 44 - uw, H - 58), url, font=small_font, fill=MUTED)
|
||||
return img
|
||||
|
||||
|
||||
def render_title(title, subtitle, phase_no, phase_total, bold_font, font, small_font):
|
||||
W, H = 1920, 1080
|
||||
img = Image.new("RGB", (W, H), TITLE_BG)
|
||||
d = ImageDraw.Draw(img)
|
||||
d.rectangle([0, 0, W, 8], fill=ACCENT)
|
||||
d.text((120, 400), title, font=bold_font, fill=HEADER_FG)
|
||||
if subtitle:
|
||||
d.text((120, 470), subtitle, font=font, fill=MUTED)
|
||||
if phase_total:
|
||||
d.text((120, 560), f"FASE {phase_no} DE {phase_total}", font=small_font, fill=ACCENT)
|
||||
d.text((120, 620), "PF on Linux - OpenBSD PF 8.0 no kernel Linux",
|
||||
font=small_font, fill=MUTED)
|
||||
return img
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--script", required=True)
|
||||
ap.add_argument("--out", required=True)
|
||||
ap.add_argument("--title", required=True)
|
||||
ap.add_argument("--subtitle", default="")
|
||||
ap.add_argument("--footer", default="")
|
||||
ap.add_argument("--phase-no", type=int, default=0)
|
||||
ap.add_argument("--phase-total", type=int, default=0)
|
||||
ap.add_argument("--cols", type=int, default=120)
|
||||
ap.add_argument("--rows", type=int, default=32)
|
||||
ap.add_argument("--fps", type=int, default=30)
|
||||
ap.add_argument("--max-hold", type=float, default=2.0)
|
||||
ap.add_argument("--min-hold", type=float, default=0.25)
|
||||
ap.add_argument("--title-card", type=float, default=3.0)
|
||||
ap.add_argument("--keep-frames", action="store_true")
|
||||
ap.add_argument("--panel", default="alpine (pf0) - lab libvirt - kernel 7.3.4 + pf.ko")
|
||||
args = ap.parse_args()
|
||||
|
||||
repo = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
mono = "/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf"
|
||||
mono_b = "/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono-Bold.ttf"
|
||||
font = load_font(mono, 21)
|
||||
bold_font = load_font(mono_b, 21)
|
||||
small_font = load_font(mono, 19)
|
||||
|
||||
rec = Recorder(args.cols, args.rows)
|
||||
rc = rec.run(["bash", args.script])
|
||||
sys.stderr.write(f"[screencast] script rc={rc}, events={len(rec.events)}\n")
|
||||
|
||||
os.makedirs(os.path.dirname(os.path.abspath(args.out)) or ".", exist_ok=True)
|
||||
frames_dir = os.path.join(os.path.dirname(os.path.abspath(args.out)), "_frames")
|
||||
shutil.rmtree(frames_dir, ignore_errors=True)
|
||||
os.makedirs(frames_dir, exist_ok=True)
|
||||
|
||||
def dump(img, idx):
|
||||
img.save(os.path.join(frames_dir, f"{idx:05d}.png"))
|
||||
|
||||
n = 0
|
||||
# title card
|
||||
title_img = render_title(args.title, args.subtitle, args.phase_no,
|
||||
args.phase_total, bold_font, font, small_font)
|
||||
title_frames = int(args.title_card * args.fps)
|
||||
for _ in range(title_frames):
|
||||
dump(title_img, n)
|
||||
n += 1
|
||||
|
||||
# session frames, paced by the real inter-event delays
|
||||
prev_t = 0.0
|
||||
for i, (ts, rows, cur) in enumerate(rec.events):
|
||||
hold = ts - prev_t
|
||||
prev_t = ts
|
||||
if i + 1 < len(rec.events):
|
||||
hold = rec.events[i + 1][0] - ts
|
||||
hold = max(args.min_hold, min(args.max_hold, hold))
|
||||
img = render_frame(rows, args.title, args.subtitle, args.phase_no,
|
||||
args.phase_total, args.cols, font, bold_font,
|
||||
small_font, args.footer, cursor=cur, panel=args.panel)
|
||||
frames = max(1, int(round(hold * args.fps)))
|
||||
for _ in range(frames):
|
||||
dump(img, n)
|
||||
n += 1
|
||||
|
||||
# closing card
|
||||
end_img = render_frame(rec.events[-1][1], args.title, args.subtitle,
|
||||
args.phase_no, args.phase_total, args.cols, font,
|
||||
bold_font, small_font,
|
||||
args.footer or "fim da fase",
|
||||
cursor=rec.events[-1][2], panel=args.panel)
|
||||
for _ in range(int(1.5 * args.fps)):
|
||||
dump(end_img, n)
|
||||
n += 1
|
||||
|
||||
# encoder: prefer x264, fall back to openh264 (both H.264 in MP4, which is
|
||||
# what YouTube wants), then VP9/WebM.
|
||||
have = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
|
||||
capture_output=True, text=True).stdout
|
||||
base = ["ffmpeg", "-y", "-loglevel", "error", "-framerate", str(args.fps),
|
||||
"-i", os.path.join(frames_dir, "%05d.png"),
|
||||
"-pix_fmt", "yuv420p", "-movflags", "+faststart", "-r", str(args.fps)]
|
||||
if "libx264" in have:
|
||||
cmd = base + ["-c:v", "libx264", "-preset", "medium", "-crf", "20",
|
||||
"-profile:v", "high", "-level", "4.0", args.out]
|
||||
elif "libopenh264" in have:
|
||||
cmd = base + ["-c:v", "libopenh264", "-b:v", "6M",
|
||||
"-profile:v", "high", args.out]
|
||||
elif "libvpx-vp9" in have:
|
||||
cmd = base + ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "30",
|
||||
"-row-mt", "1", "-cpu-used", "4", args.out]
|
||||
else:
|
||||
raise SystemExit("[screencast] no usable H.264/VP9 encoder in ffmpeg")
|
||||
subprocess.run(cmd, check=True)
|
||||
if not args.keep_frames:
|
||||
shutil.rmtree(frames_dir, ignore_errors=True)
|
||||
dur = subprocess.run(["ffprobe", "-v", "error", "-show_entries",
|
||||
"format=duration", "-of", "csv=p=0", args.out],
|
||||
capture_output=True, text=True).stdout.strip()
|
||||
size = os.path.getsize(args.out)
|
||||
sys.stderr.write(f"[screencast] wrote {args.out} frames={n} "
|
||||
f"duration={dur}s size={size/1e6:.1f}MB\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user