Docs: bring the documentation in line with the code
- Demo docs replaced by the screencast docs (single source of truth): the legacy demo-session.sh/demo-record.py and the v0.9 renderer were removed instead of being kept as a second convention. - feature-matrix: stale 'M2-in-progress' vocabulary gone; the rows the 15 gates prove now say VERIFIED-in-VM and name the gate; af-to is now an explicit non-goal row (INET6 0 in this build). - guide: the queues/divert/pflog sections described a state that no longer exists (priq-only, divert dropping packets, pflog logging nothing); they now describe the shipped behaviour. - vm-lab: the 15 real gates with what each proves, 15/15 green, and the pf0 -> cli/srv/gw2 ssh setup the screencasts rely on. - install/build: pdivert and pflogd are built and ported (docs said otherwise); module object list now includes hfsc.c, fq_codel.c, pfk_pflog.c; the screencast pipeline is an optional build stage. - pf-guide: the 'M2 scope only' framing is gone; honest gaps listed. - caveats: three new sections about the screencast pipeline (MiniMax video generation blocked by the account tier, narration as a separate track, a video frame is not a verdict). - porting-notes: what the recording taught us. - README/README.pt-BR: status, milestones, layout and video section updated. All internal doc links verified.
This commit is contained in:
1 parent
cc67531602
commit
dff2185b20
37 files changed
+677
-1616
No files matched your search
@@ -1,161 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PF-on-Linux demo recorder -> asciicast v2 (.cast)
|
||||
|
||||
Every byte shown comes from the REAL lab (Alpine VMs under libvirt, kernel
|
||||
7.3.4 built from source with our pf.ko). Only the pacing is scripted.
|
||||
|
||||
Usage:
|
||||
./scripts/demo-record.py [--out out/demo-pf-linux.cast] [--quick]
|
||||
|
||||
Play back with: asciinema play out/demo-pf-linux.cast
|
||||
Upload with: asciinema upload out/demo-pf-linux.cast
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEB = os.environ.get("DEB", "root@192.168.1.242")
|
||||
PF0 = os.environ.get("PF0_IP", "192.168.122.8")
|
||||
CLI = os.environ.get("CLI_IP", "10.0.1.10")
|
||||
SRV = os.environ.get("SRV_IP", "10.0.2.20")
|
||||
KEY = os.path.join(os.environ.get("HOME", "/root"), ".ssh/pflab_ed")
|
||||
|
||||
CONF = """# PF-on-Linux demo ruleset (pf.conf syntax, real OpenBSD pfctl)
|
||||
set block-policy return
|
||||
table <trusted> { 10.0.1.0/24 }
|
||||
|
||||
# transit traffic: stateful, table-driven
|
||||
pass in quick on ingress proto icmp from <trusted> keep state
|
||||
pass out quick on egress proto icmp keep state
|
||||
|
||||
# deny a specific destination
|
||||
block drop in quick proto icmp to 10.0.9.99
|
||||
|
||||
# everything else
|
||||
pass in quick
|
||||
pass out quick
|
||||
"""
|
||||
|
||||
DIVERT_CONF = """pass in quick proto icmp from 10.0.1.10 to 10.0.1.1 divert-to "10.0.1.1" port 10000 keep state
|
||||
pass out quick
|
||||
"""
|
||||
|
||||
|
||||
def sh(cmd, timeout=40):
|
||||
p = subprocess.run(cmd, shell=True, capture_output=True, text=True,
|
||||
timeout=timeout)
|
||||
return (p.stdout or "") + (p.stderr or "")
|
||||
|
||||
|
||||
def on_pf0(cmd, timeout=40):
|
||||
return sh(f"ssh -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new "
|
||||
f"-i {KEY} root@{PF0} {shlex.quote(cmd)}", timeout)
|
||||
|
||||
|
||||
def on_vm(ip, cmd, timeout=40):
|
||||
return sh(f"ssh -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new "
|
||||
f"-i {KEY} root@{ip} {shlex.quote(cmd)}", timeout)
|
||||
|
||||
|
||||
def on_deb(cmd, timeout=40):
|
||||
return sh(f"ssh -o ConnectTimeout=8 {DEB} {shlex.quote(cmd)}", timeout)
|
||||
|
||||
|
||||
def stage():
|
||||
"""put the demo ruleset + pdivert on pf0 (through the lab host)"""
|
||||
with open("/tmp/demo-pf.conf", "w") as f:
|
||||
f.write(CONF)
|
||||
with open("/tmp/demo-divert.conf", "w") as f:
|
||||
f.write(DIVERT_CONF)
|
||||
sh(f"scp -q /tmp/demo-pf.conf /tmp/demo-divert.conf "
|
||||
f"{os.path.join(REPO, 'tools/out/pdivert')} {DEB}:/tmp/", timeout=90)
|
||||
on_deb(f"scp -q -o StrictHostKeyChecking=accept-new -i {KEY} "
|
||||
f"/tmp/demo-pf.conf /tmp/demo-divert.conf /tmp/pdivert "
|
||||
f"root@{PF0}:/tmp/ 2>&1", timeout=90)
|
||||
|
||||
|
||||
SCENARIO = [
|
||||
("local", "echo '=== PF on Linux — OpenBSD PF 8.0 engine running inside the Linux 7.3.4 kernel ==='"),
|
||||
("local", "echo 'lab: 3 Alpine VMs under libvirt (pf0 = firewall, cli/srv = transit endpoints)'"),
|
||||
("pf0", "uname -r"),
|
||||
("pf0", "lsmod | grep '^pf ' || echo 'pf: not loaded'"),
|
||||
("pf0", "insmod /opt/pf/pf.ko && dmesg | tail -3"),
|
||||
("pf0", "/opt/pf/pfctl -s info | head -4"),
|
||||
("pf0", "/opt/pf/pfctl -e && /opt/pf/pfctl -s info | head -1"),
|
||||
("pf0", "cat /tmp/demo-pf.conf"),
|
||||
("pf0", "/opt/pf/pfctl -f /tmp/demo-pf.conf && echo 'rules loaded' && /opt/pf/pfctl -sr"),
|
||||
("pf0", "/opt/pf/pfctl -sr -vv | head -8"),
|
||||
("cli", f"ping -c2 -W2 {SRV} | tail -3"),
|
||||
("pf0", "/opt/pf/pfctl -ss"),
|
||||
("pf0", "/opt/pf/pfctl -t trusted -T show"),
|
||||
("pf0", "/opt/pf/pfctl -s Tables"),
|
||||
("cli", "ping -c2 -W2 10.0.9.99 | tail -3"),
|
||||
("pf0", "/opt/pf/pfctl -sr -vv | head -6"),
|
||||
("pf0", "/opt/pf/pfctl -f /tmp/demo-divert.conf && echo 'divert rule loaded'"),
|
||||
("pf0", "(setsid /tmp/pdivert -p 10000 -f >/tmp/pdivert.log 2>&1 </dev/null &); sleep 1; pgrep pdivert"),
|
||||
("cli", "ping -c2 -W2 10.0.1.1 | tail -3"),
|
||||
("pf0", "head -3 /tmp/pdivert.log"),
|
||||
("pf0", "pkill pdivert; /opt/pf/pfctl -f /dev/null; /opt/pf/pfctl -d; echo 'pf disabled'"),
|
||||
]
|
||||
|
||||
|
||||
def record(out_path, quick=False):
|
||||
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
|
||||
events = []
|
||||
t = 0.0
|
||||
|
||||
def emit(text, delay):
|
||||
nonlocal t
|
||||
t += delay
|
||||
events.append([round(t, 3), "o", text])
|
||||
|
||||
header = {
|
||||
"version": 2, "width": 100, "height": 26,
|
||||
"timestamp": int(time.time()),
|
||||
"title": "PF on Linux — OpenBSD PF engine inside the Linux kernel (lab demo)",
|
||||
"env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"},
|
||||
}
|
||||
runners = {
|
||||
"pf0": lambda c: on_pf0(c),
|
||||
"cli": lambda c: on_vm(CLI, c),
|
||||
"srv": lambda c: on_vm(SRV, c),
|
||||
"local": lambda c: sh(c),
|
||||
}
|
||||
labels = {"pf0": "pftest-pf0", "cli": "pftest-cli", "srv": "pftest-srv",
|
||||
"local": "lab-host"}
|
||||
|
||||
stage()
|
||||
with open(out_path, "w") as f:
|
||||
f.write(json.dumps(header) + "\n")
|
||||
for kind, cmd in SCENARIO:
|
||||
emit(f"\x1b[1;36m{labels[kind]}\x1b[0m:\x1b[1;37m {cmd}\x1b[0m\r\n",
|
||||
0.35)
|
||||
try:
|
||||
out = runners[kind](cmd)
|
||||
except subprocess.TimeoutExpired:
|
||||
out = "[timeout]\r\n"
|
||||
except Exception as e: # noqa: BLE001
|
||||
out = f"[error: {e}]\r\n"
|
||||
out = out.replace("\r\n", "\n").replace("\n", "\r\n")
|
||||
emit(out or "(no output)\r\n", min(1.4, 0.3 + len(out) / 5000.0))
|
||||
with open(out_path, "a") as f:
|
||||
for ev in events:
|
||||
f.write(json.dumps(ev) + "\n")
|
||||
print(f"WROTE {out_path}: {len(events)} events, {t:.1f}s")
|
||||
print("play: asciinema play " + out_path)
|
||||
print("upload: asciinema upload " + out_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--out", default=os.path.join(REPO, "out",
|
||||
"demo-pf-linux.cast"))
|
||||
ap.add_argument("--quick", action="store_true")
|
||||
a = ap.parse_args()
|
||||
record(a.out, a.quick)
|
||||
@@ -1,182 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Demo session for PF-on-Linux: a scripted terminal tour of the lab.
|
||||
# Recorded with: asciinema rec out/demo-pf-linux.cast -c "bash scripts/demo-session.sh"
|
||||
#
|
||||
# Everything printed is the REAL output of the lab (Alpine VMs under libvirt
|
||||
# running the kernel built from source with our pf.ko). The VM management
|
||||
# addresses are resolved from the hypervisor (they are DHCP), the lab
|
||||
# topology (10.0.1.0/24, 10.0.2.0/24, ...) is fixed.
|
||||
set -u
|
||||
|
||||
DEB=${DEB:-root@192.168.1.242}
|
||||
CLI_IP=${CLI_IP:-10.0.1.10}
|
||||
SRV_IP=${SRV_IP:-10.0.2.20}
|
||||
KEY=${KEY:-$HOME/.ssh/pflab_ed}
|
||||
|
||||
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
|
||||
cyan() { printf '\033[1;36m%s\033[0m\n' "$*"; }
|
||||
|
||||
# management address of a lab VM (DHCP, so ask the hypervisor)
|
||||
mgmt() {
|
||||
ssh -o ConnectTimeout=8 "$DEB" "virsh domifaddr $1 | awk '/vnet/{print \$4}' | cut -d/ -f1" \
|
||||
2>/dev/null | tr -d '\r' | head -1
|
||||
}
|
||||
|
||||
PF0_MGMT=$(mgmt pftest-pf0)
|
||||
CLI_MGMT=$(mgmt pftest-cli)
|
||||
[ -n "$PF0_MGMT" ] || PF0_MGMT=192.168.122.8
|
||||
[ -n "$CLI_MGMT" ] || CLI_MGMT=192.168.122.80
|
||||
|
||||
# run a command on a lab VM (through the hypervisor)
|
||||
onvm() {
|
||||
local ip=$1; shift
|
||||
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
|
||||
-o ProxyJump=root@192.168.1.242 -i "$KEY" root@"$ip" "$@" 2>&1
|
||||
}
|
||||
|
||||
p0() {
|
||||
cyan "pftest-pf0# $*"
|
||||
onvm "$PF0_MGMT" "$@"
|
||||
sleep 0.4
|
||||
}
|
||||
vmc() {
|
||||
cyan "pftest-cli# $*"
|
||||
onvm "$CLI_MGMT" "$@"
|
||||
sleep 0.4
|
||||
}
|
||||
|
||||
|
||||
CONF=/tmp/demo-pf.conf
|
||||
DIV=/tmp/demo-divert.conf
|
||||
|
||||
cat > "$CONF" <<'CONF'
|
||||
# PF-on-Linux demo ruleset (pf.conf syntax, real OpenBSD pfctl)
|
||||
set block-policy return
|
||||
table <trusted> { 10.0.1.0/24 }
|
||||
|
||||
# transit traffic: stateful, table-driven
|
||||
pass in quick on ingress proto icmp from <trusted> keep state
|
||||
pass out quick on egress proto icmp keep state
|
||||
|
||||
# deny a specific destination
|
||||
block drop in quick proto icmp to 10.0.9.99
|
||||
|
||||
# everything else
|
||||
pass in quick
|
||||
pass out quick
|
||||
CONF
|
||||
|
||||
cat > "$DIV" <<CONF
|
||||
pass in quick proto icmp from $CLI_IP to 10.0.1.1 divert-to "10.0.1.1" port 10000 keep state
|
||||
pass out quick
|
||||
CONF
|
||||
|
||||
# the pf0 interface towards the server (used by the NAT pool)
|
||||
EGRESS_IF=$(onvm "$PF0_MGMT" "ip route get $SRV_IP | grep -o 'dev [a-z0-9]*' | cut -d' ' -f2" | tr -d '\r')
|
||||
[ -n "$EGRESS_IF" ] || EGRESS_IF=eth2
|
||||
|
||||
scp -q -o ProxyJump=root@192.168.1.242 -i "$KEY" "$CONF" "$DIV" \
|
||||
tools/out/pdivert tools/out/pflogd "root@$PF0_MGMT:/tmp/" 2>/dev/null
|
||||
|
||||
bold "==============================================================="
|
||||
bold " PF on Linux - the OpenBSD PF 8.0 engine inside the Linux kernel"
|
||||
bold "==============================================================="
|
||||
echo
|
||||
echo "Lab: 3 Alpine VMs under libvirt."
|
||||
echo " pftest-pf0 = firewall (kernel 7.3.4 built from source + pf.ko)"
|
||||
echo " pftest-cli = client 10.0.1.10 pftest-srv = server 10.0.2.20"
|
||||
echo
|
||||
|
||||
bold "--- 1. our kernel, the OpenBSD engine as a module ---"
|
||||
p0 "uname -r"
|
||||
p0 "lsmod | grep '^pf ' || echo 'pf: not loaded yet'"
|
||||
p0 "insmod /opt/pf/pf.ko && dmesg | tail -3"
|
||||
echo
|
||||
|
||||
bold "--- 2. pfctl talks to the module over the OpenBSD ioctl ABI ---"
|
||||
p0 "/opt/pf/pfctl -s info | head -4"
|
||||
p0 "/opt/pf/pfctl -e && /opt/pf/pfctl -s info | head -1"
|
||||
echo
|
||||
|
||||
bold "--- 3. a real pf.conf: tables, stateful rules, an explicit drop ---"
|
||||
p0 "cat /tmp/demo-pf.conf"
|
||||
p0 "/opt/pf/pfctl -f /tmp/demo-pf.conf && echo 'rules loaded'"
|
||||
p0 "/opt/pf/pfctl -sr"
|
||||
echo
|
||||
|
||||
bold "--- 4. transit traffic is allowed and stateful ---"
|
||||
vmc "ping -c2 -W2 $SRV_IP | tail -3"
|
||||
p0 "/opt/pf/pfctl -ss | grep -E 'icmp' | head -4"
|
||||
echo
|
||||
|
||||
bold "--- 5. tables are live objects ---"
|
||||
p0 "/opt/pf/pfctl -t trusted -T show"
|
||||
p0 "/opt/pf/pfctl -s Tables"
|
||||
echo
|
||||
|
||||
bold "--- 6. the drop rule actually drops ---"
|
||||
p0 "cat > /tmp/demo-block.conf <<EOF
|
||||
block drop in quick proto icmp to $SRV_IP
|
||||
pass in quick
|
||||
pass out quick
|
||||
EOF"
|
||||
p0 "/opt/pf/pfctl -f /tmp/demo-block.conf && echo 'block rules loaded'"
|
||||
vmc "ping -c2 -W2 $SRV_IP | tail -3"
|
||||
p0 "/opt/pf/pfctl -sr -vv | head -6"
|
||||
p0 "/opt/pf/pfctl -f /tmp/demo-pf.conf && echo 'back to the stateful ruleset'"
|
||||
echo
|
||||
|
||||
bold "--- 7. NAT: the source is translated on the way out, restored on the way back ---"
|
||||
p0 "cat > /tmp/demo-nat.conf <<EOF
|
||||
pass out quick on egress from 10.0.1.0/24 to any nat-to ($EGRESS_IF)
|
||||
pass in quick on ingress proto icmp keep state
|
||||
pass out quick on egress proto icmp keep state
|
||||
EOF"
|
||||
p0 "/opt/pf/pfctl -f /tmp/demo-nat.conf && echo 'nat rules loaded'"
|
||||
vmc "ping -c2 -W2 $SRV_IP | tail -3"
|
||||
p0 "/opt/pf/pfctl -ss | grep -E 'icmp' | head -4"
|
||||
p0 "/opt/pf/pfctl -f /tmp/demo-pf.conf && echo 'back to the stateful ruleset'"
|
||||
echo
|
||||
|
||||
bold "--- 8. pflog: logged packets reach userspace (/dev/pflog) ---"
|
||||
p0 "cat > /tmp/demo-log.conf <<EOF
|
||||
pass in quick log proto icmp from $CLI_IP keep state
|
||||
pass out quick
|
||||
EOF"
|
||||
p0 "/opt/pf/pfctl -f /tmp/demo-log.conf && echo 'log rule loaded'"
|
||||
p0 "chmod +x /tmp/pflogd; rm -f /tmp/pflog.pcap; (setsid /tmp/pflogd -c 2 /tmp/pflog.pcap >/tmp/pflogd.log 2>&1 </dev/null &); sleep 1; echo 'pflogd collecting 2 records'"
|
||||
vmc "ping -c2 -W2 $SRV_IP | tail -2"
|
||||
sleep 2
|
||||
p0 "cat /tmp/pflogd.log; ls -l /tmp/pflog.pcap; echo 'pcap header (magic d4c3b2a1, linktype 0x75=DLT_PFLOG):'; od -An -tx1 -N24 /tmp/pflog.pcap"
|
||||
p0 "pkill pflogd 2>/dev/null; /opt/pf/pfctl -f /tmp/demo-pf.conf >/dev/null"
|
||||
echo
|
||||
|
||||
bold "--- 9. route-to: force the next hop away from the normal path ---"
|
||||
p0 "cat > /tmp/demo-route.conf <<EOF
|
||||
pass in quick proto icmp from $CLI_IP to $SRV_IP route-to { 10.0.3.2 } keep state
|
||||
pass out quick
|
||||
EOF"
|
||||
p0 "/opt/pf/pfctl -f /tmp/demo-route.conf && echo 'route-to rule loaded'"
|
||||
p0 "(tcpdump -ni eth3 -c 2 -e icmp > /tmp/route.td 2>&1 &); (tcpdump -ni eth2 -c 2 -e icmp > /tmp/norm.td 2>&1 &); sleep 1"
|
||||
vmc "ping -c1 -W2 $SRV_IP | tail -2"
|
||||
sleep 2
|
||||
p0 "echo 'forced egress (eth3, toward the gateway):'; grep -v '^tcpdump\|^listening\|^$' /tmp/route.td | head -2; echo 'normal egress (eth2):'; grep -c 'echo request' /tmp/norm.td"
|
||||
p0 "/opt/pf/pfctl -f /tmp/demo-pf.conf >/dev/null"
|
||||
echo
|
||||
|
||||
bold "--- 10. divert-to: the packet goes to userspace and comes back ---"
|
||||
p0 "cat /tmp/demo-divert.conf"
|
||||
p0 "/opt/pf/pfctl -f /tmp/demo-divert.conf && echo 'divert rule loaded'"
|
||||
p0 "chmod +x /tmp/pdivert; (setsid /tmp/pdivert -p 10000 -f >/tmp/pdivert.log 2>&1 </dev/null &); sleep 1; pgrep pdivert"
|
||||
vmc "ping -c2 -W2 10.0.1.1 | tail -3"
|
||||
p0 "head -3 /tmp/pdivert.log"
|
||||
echo " (pdivert flipped the last payload byte and fixed the ICMP checksum)"
|
||||
echo
|
||||
|
||||
bold "--- 11. shutdown ---"
|
||||
p0 "pkill pdivert; /opt/pf/pfctl -f /dev/null; /opt/pf/pfctl -d; echo 'pf disabled'"
|
||||
p0 "lsmod | grep '^pf '"
|
||||
echo
|
||||
bold "demo over - port, gates and docs:"
|
||||
bold " https://git.labexposed.com/lgcosta/pf-linux-port"
|
||||
echo
|
||||
@@ -1,3 +0,0 @@
|
||||
@run clear
|
||||
uname -r
|
||||
lsmod | grep '^pf ' || echo 'pf: not loaded yet'
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Render every screencast phase into out/screencast/*.mp4 (silent, 1080p30).
|
||||
#
|
||||
# ./scripts/screencast/record-all.sh [phase ...]
|
||||
#
|
||||
# Each phase is a real session against the lab; the videos carry no audio.
|
||||
set -u
|
||||
cd "$(dirname "$0")/../.."
|
||||
REPO=$(pwd)
|
||||
OUT="$REPO/out/screencast"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
render() { # <file> <title> <subtitle> <phase-no> [panel]
|
||||
local f=$1 title=$2 sub=$3 no=$4
|
||||
local panel=${5:-"alpine (pf0) - lab libvirt - kernel 7.3.4 + pf.ko"}
|
||||
echo "=== gravando $f ==="
|
||||
python3 "$REPO/scripts/screencast/render.py" \
|
||||
--script "$REPO/scripts/screencast/phases/$f" \
|
||||
--out "$OUT/${f%.sh}.mp4" \
|
||||
--title "$title" --subtitle "$sub" \
|
||||
--phase-no "$no" --phase-total 6 --panel "$panel" --min-hold 1.1 \
|
||||
--footer "PF on Linux - OpenBSD PF 8.0 no kernel Linux - lab libvirt" || return 1
|
||||
}
|
||||
|
||||
declare -A TITLES=(
|
||||
[01-implementacao-kernel]="Fase 1 - Implementacao no kernel"
|
||||
[02-ativacao]="Fase 2 - Ativacao"
|
||||
[03-regras-basicas]="Fase 3 - Regras basicas"
|
||||
[04-roteamento]="Fase 4 - Roteamento"
|
||||
[05-balanceamento]="Fase 5 - Balanceamento"
|
||||
[06-qos]="Fase 6 - QOS"
|
||||
)
|
||||
declare -A SUBS=(
|
||||
[01-implementacao-kernel]="Das fontes OpenBSD 8.0 ao modulo pf.ko"
|
||||
[02-ativacao]="insmod pf.ko / pfctl -e / pfctl -s info"
|
||||
[03-regras-basicas]="tabelas, anchors, keep state, block drop"
|
||||
[04-roteamento]="route-to e reply-to: o proximo salto decidido pelo pf"
|
||||
[05-balanceamento]="pools round-robin, source-hash e dup-to"
|
||||
[06-qos]="filas hfsc do OpenBSD 8.0 no caminho de dados"
|
||||
)
|
||||
|
||||
PHASES=("$@")
|
||||
[ ${#PHASES[@]} -eq 0 ] && PHASES=(01-implementacao-kernel 02-ativacao 03-regras-basicas \
|
||||
04-roteamento 05-balanceamento 06-qos)
|
||||
|
||||
for p in "${PHASES[@]}"; do
|
||||
f="$p.sh"
|
||||
[ -f "$REPO/scripts/screencast/phases/$f" ] || { echo "fase inexistente: $f"; exit 1; }
|
||||
# the phase number comes from the phase's own name, not from its position
|
||||
# in the list, so a partial re-render keeps the right "FASE x/6" label
|
||||
n=$((10#${p%%-*}))
|
||||
case "$p" in
|
||||
01-*) panel="host de build - repositorio pf_port" ;;
|
||||
*) panel="alpine (pf0) - lab libvirt - kernel 7.3.4 + pf.ko" ;;
|
||||
esac
|
||||
render "$f" "${TITLES[$p]}" "${SUBS[$p]}" "$n" "$panel" || echo "FALHOU: $f"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "=== videos gerados ==="
|
||||
for m in "$OUT"/*.mp4; do
|
||||
[ -e "$m" ] || continue
|
||||
d=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$m")
|
||||
printf '%-46s %6.1fs %5.1f MB\n' "$(basename "$m")" "$d" "$(echo "scale=1; $(stat -c%s "$m")/1000000" | bc)"
|
||||
done
|
||||
@@ -1,336 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,331 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Record a *real* interactive shell session and render it as a video.
|
||||
|
||||
The difference to the earlier renderer: the shell is not a script whose output
|
||||
is dumped at once. We open a real pty, type the commands **key by key** with
|
||||
human-like timing, wait for the prompt to come back, and capture the byte
|
||||
stream that the terminal actually produced. The result is a session that
|
||||
looks like somebody sitting in front of a terminal - characters appearing one
|
||||
by one, output scrolling in as the kernel answers.
|
||||
|
||||
./scripts/screencast/session.py --plan scripts/screencast/plans/04.sh \\
|
||||
--out out/screencast/session-04.mp4 --title "..." --subtitle "..."
|
||||
|
||||
The plan file is a shell script executed *inside* the recording pty. Every
|
||||
line the plan prints is real output from the lab.
|
||||
"""
|
||||
import argparse
|
||||
import errno
|
||||
import fcntl
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import shlex
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import termios
|
||||
import time
|
||||
|
||||
import pyte
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# ---------------------------------------------------------------- palette ---
|
||||
BG = (11, 14, 19)
|
||||
PANEL = (18, 22, 28)
|
||||
BORDER = (44, 50, 58)
|
||||
FG = (203, 211, 219)
|
||||
HEADER_BG = (21, 26, 33)
|
||||
HEADER_FG = (240, 246, 252)
|
||||
ACCENT = (86, 166, 255)
|
||||
ACCENT2 = (57, 197, 207)
|
||||
MUTED = (108, 116, 128)
|
||||
GREEN = (63, 185, 80)
|
||||
YELLOW = (210, 153, 34)
|
||||
RED = (255, 123, 114)
|
||||
TITLE_BG = (15, 19, 25)
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ fonts ---
|
||||
def load_fonts():
|
||||
mono = "/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf"
|
||||
monob = "/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono-Bold.ttf"
|
||||
return (ImageFont.truetype(mono, 20), ImageFont.truetype(monob, 20),
|
||||
ImageFont.truetype(mono, 18))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- session ---
|
||||
class Shell:
|
||||
"""A real interactive shell on a pty; we type into it and read what it says."""
|
||||
|
||||
PROMPT = "__PS1__"
|
||||
|
||||
def __init__(self, argv, cols=118, rows=34, env=None):
|
||||
self.cols, self.rows = cols, rows
|
||||
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", "COLUMNS": str(cols),
|
||||
"LINES": str(rows), "LC_ALL": "C.UTF-8", "PS1": self.PROMPT + " "})
|
||||
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.stream = pyte.ByteStream(self.screen)
|
||||
self.events = [] # (t, snapshot)
|
||||
self.t0 = time.time()
|
||||
self.raw = bytearray()
|
||||
self.idle = 0.0
|
||||
|
||||
def pump(self, duration):
|
||||
"""Read from the pty for `duration` seconds, recording screen states."""
|
||||
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 in r:
|
||||
try:
|
||||
data = os.read(self.master, 65536)
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.EIO:
|
||||
return
|
||||
raise
|
||||
if not data:
|
||||
return
|
||||
self.raw += data
|
||||
self.stream.feed(data)
|
||||
self.events.append((time.time() - self.t0, self.snapshot()))
|
||||
self.idle = 0.0
|
||||
else:
|
||||
self.idle += 0.05
|
||||
if self.idle > 0.25:
|
||||
# nothing new: keep the timeline going so the frame clock
|
||||
# stays uniform
|
||||
self.events.append((time.time() - self.t0, self.snapshot()))
|
||||
|
||||
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]
|
||||
cells.append((ch.data or " ", color_of(ch.fg, FG), ch.bold))
|
||||
rows.append(cells)
|
||||
return rows, (self.screen.cursor.y, self.screen.cursor.x)
|
||||
|
||||
def wait_prompt(self, timeout=25.0):
|
||||
"""Wait until the shell prompt shows up again."""
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
self.pump(0.05)
|
||||
text = "\n".join(self.screen.display)
|
||||
if text.rstrip().endswith(self.PROMPT):
|
||||
return True
|
||||
return False
|
||||
|
||||
def type_line(self, text, cps=42.0):
|
||||
"""Type a line character by character, like a human."""
|
||||
for ch in text:
|
||||
self.write(ch)
|
||||
# slight jitter so it does not look machine-perfect
|
||||
delay = 1.0 / cps + (hash(ch) % 7) * 0.0016
|
||||
time.sleep(delay)
|
||||
self.write("\r")
|
||||
self.pump(0.12)
|
||||
|
||||
def write(self, s):
|
||||
os.write(self.master, s.encode())
|
||||
|
||||
def run(self):
|
||||
self.pump(1.2)
|
||||
return self.proc.wait()
|
||||
|
||||
|
||||
# --------------------------------------------------------------- rendering ---
|
||||
def render(rows, cursor, title, subtitle, phase_no, phase_total, font, bold,
|
||||
small, footer, panel_label, art=None, art_alpha=0.0):
|
||||
W, H = 1920, 1080
|
||||
img = Image.new("RGB", (W, H), BG)
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
d.rectangle([0, 0, W, 70], fill=HEADER_BG)
|
||||
d.text((44, 24), "PF on Linux", font=bold, fill=HEADER_FG)
|
||||
tw = d.textlength("PF on Linux", font=bold)
|
||||
d.text((44 + tw + 24, 28), "-", font=font, fill=MUTED)
|
||||
d.text((44 + tw + 44, 28), "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)
|
||||
d.text((W - 44 - lw, 30), label, font=small, fill=ACCENT)
|
||||
|
||||
d.text((44, 100), title, font=bold, fill=HEADER_FG)
|
||||
if subtitle:
|
||||
d.text((44, 148), subtitle, font=font, fill=MUTED)
|
||||
|
||||
top, left, right = 196, 44, W - 44
|
||||
lh = 28
|
||||
d.rectangle([left, top, right, H - 78], fill=PANEL, outline=BORDER, width=2)
|
||||
d.rectangle([left, top, right, top + 40], fill=HEADER_BG)
|
||||
for i, c in enumerate([(255, 95, 86), (255, 189, 46), (39, 201, 63)]):
|
||||
d.ellipse([left + 22 + i * 24, top + 14, left + 34 + i * 24, top + 26], fill=c)
|
||||
d.text((left + 100, top + 12), panel_label, font=small, fill=MUTED)
|
||||
|
||||
x0, y0 = left + 26, top + 58
|
||||
cw = d.textlength("M", font=font)
|
||||
for i, line in enumerate(rows):
|
||||
y = y0 + i * lh
|
||||
if y > H - 96:
|
||||
break
|
||||
for x, (ch, fg, b) in enumerate(line):
|
||||
if ch == " ":
|
||||
continue
|
||||
d.text((x0 + x * cw, y), ch, font=bold if b else font, fill=fg)
|
||||
|
||||
if cursor:
|
||||
cy, cx = cursor
|
||||
if 0 <= cy < len(rows) and 0 <= cx < len(rows[0]):
|
||||
d.rectangle([x0 + cx * cw, y0 + cy * lh, x0 + (cx + 1) * cw - 1,
|
||||
y0 + (cy + 1) * lh - 1], fill=ACCENT)
|
||||
|
||||
d.text((44, H - 52), footer, font=small, fill=MUTED)
|
||||
url = "git.labexposed.com/lgcosta/pf-linux-port"
|
||||
uw = d.textlength(url, font=small)
|
||||
d.text((W - 44 - uw, H - 52), url, font=small, fill=MUTED)
|
||||
return img
|
||||
|
||||
|
||||
def render_title(title, subtitle, phase_no, phase_total, bold, font, small, art):
|
||||
W, H = 1920, 1080
|
||||
img = Image.new("RGB", (W, H), TITLE_BG)
|
||||
if art:
|
||||
a = art.resize((W, H)).convert("RGB")
|
||||
img = Image.blend(img, a, 0.55)
|
||||
d = ImageDraw.Draw(img)
|
||||
d.rectangle([0, 0, W, 8], fill=ACCENT)
|
||||
d.text((120, 420), title, font=bold, fill=(250, 252, 255))
|
||||
if subtitle:
|
||||
d.text((120, 490), subtitle, font=font, fill=(190, 200, 214))
|
||||
if phase_total:
|
||||
d.text((120, 570), f"FASE {phase_no} DE {phase_total}", font=small, fill=ACCENT)
|
||||
d.text((120, 630), "PF on Linux - OpenBSD PF 8.0 no kernel Linux",
|
||||
font=small, fill=(150, 160, 175))
|
||||
return img
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--plan", required=True)
|
||||
ap.add_argument("--out", required=True)
|
||||
ap.add_argument("--title", required=True)
|
||||
ap.add_argument("--subtitle", default="")
|
||||
ap.add_argument("--footer", default="PF on Linux - OpenBSD PF 8.0 no kernel Linux - lab libvirt")
|
||||
ap.add_argument("--panel", default="alpine (pf0) - lab libvirt - kernel 7.3.4 + pf.ko")
|
||||
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=118)
|
||||
ap.add_argument("--rows", type=int, default=34)
|
||||
ap.add_argument("--fps", type=int, default=30)
|
||||
ap.add_argument("--title-card", type=float, default=2.5)
|
||||
ap.add_argument("--art")
|
||||
args = ap.parse_args()
|
||||
|
||||
font, bold, small = load_fonts()
|
||||
art = None
|
||||
if args.art and os.path.exists(args.art):
|
||||
art = Image.open(args.art)
|
||||
|
||||
# The plan runs inside the recording shell; the shell itself is a real
|
||||
# bash with our prompt, so what the user sees is a live session.
|
||||
sh = Shell(["bash", "--norc", "--noprofile", "-i"], cols=args.cols, rows=args.rows)
|
||||
sh.pump(1.0)
|
||||
# make the prompt look like the OpenBSD box
|
||||
sh.write("export PS1='\\u@\\h:\\w\\$ ' PS2='> ' TERM=xterm-256color\n")
|
||||
sh.pump(0.6)
|
||||
sh.write("export LC_ALL=C.UTF-8 LANG=C.UTF-8; clear\n")
|
||||
sh.pump(0.8)
|
||||
sh.events = [(0.0, sh.snapshot())]
|
||||
|
||||
# run the plan: each line is typed into the shell
|
||||
plan = open(args.plan).read()
|
||||
for raw_line in plan.split("\n"):
|
||||
line = raw_line.rstrip()
|
||||
if not line.strip() or line.strip().startswith("#"):
|
||||
continue
|
||||
if line.startswith("@sleep "):
|
||||
sh.pump(float(line.split()[1]))
|
||||
continue
|
||||
if line.startswith("@run "):
|
||||
# run a command without showing it typed (used for setup)
|
||||
sh.write(line[5:] + "\n")
|
||||
sh.wait_prompt(timeout=30)
|
||||
continue
|
||||
sh.type_line(line)
|
||||
sh.wait_prompt(timeout=60)
|
||||
sh.pump(0.45)
|
||||
|
||||
sh.write("exit\n")
|
||||
sh.pump(0.5)
|
||||
|
||||
sys.stderr.write(f"[session] {len(sh.events)} events, {sh.events[-1][0]:.1f}s\n")
|
||||
|
||||
# ------------------------------------------------------ frame timeline ---
|
||||
frames_dir = os.path.join(os.path.dirname(os.path.abspath(args.out)), "_sframes")
|
||||
subprocess.run(["rm", "-rf", frames_dir], check=False)
|
||||
os.makedirs(frames_dir, exist_ok=True)
|
||||
|
||||
n = 0
|
||||
title_img = render_title(args.title, args.subtitle, args.phase_no, args.phase_total,
|
||||
bold, font, small, art)
|
||||
for _ in range(int(args.title_card * args.fps)):
|
||||
title_img.save(os.path.join(frames_dir, f"{n:05d}.png"))
|
||||
n += 1
|
||||
|
||||
# the events carry real timestamps: map them onto a uniform frame clock
|
||||
ev = sh.events
|
||||
t0 = ev[0][0]
|
||||
for i, (ts, snap) in enumerate(ev):
|
||||
hold = (ev[i + 1][0] - ts) if i + 1 < len(ev) else 0.04
|
||||
hold = max(0.033, min(0.5, hold))
|
||||
rows, cur = snap
|
||||
img = render(rows, cur, args.title, args.subtitle, args.phase_no,
|
||||
args.phase_total, font, bold, small, args.footer, args.panel, art)
|
||||
cnt = max(1, int(round(hold * args.fps)))
|
||||
for _ in range(cnt):
|
||||
img.save(os.path.join(frames_dir, f"{n:05d}.png"))
|
||||
n += 1
|
||||
|
||||
subprocess.run(["rm", "-rf", frames_dir], check=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user