Films a real claude TUI session once, then freeze-frame splices the static
stretches to fit an authored voice that leads the typing — a deterministic
1920×1080 video where the terminal, the explainshell-style panel, and the voice
stay in lock-step. It even drives Claude's interactive AskUserQuestion
selector on screen. Single ledger.json source of truth; a deterministic lint gate.
pip install -e engine/experimental/session-recorder/live
record-session /tmp/demo engine/experimental/session-recorder/live/script.example.json
# → /tmp/demo/session_panel.mp4→ Full docs, quickstart & architecture: engine/experimental/session-recorder/live/
Which pipeline do I want? This repo has two unrelated engines that share vocabulary (
overlay.py, "verify", "5 gates" vs.check()) — don't cite one when you mean the other.
⭐ claude-session-recorderCLI-lesson engine Entry point engine/experimental/session-recorder/live/engine/src/Does Live-captures a real claudeTUI sessionScaffolds a per-slug demo (fzf/sed/awk/df/…) Verified by lint.py'scheck()verify_sync.py's "5 gates"The "Repo layout" / "Pipeline" sections right below this box describe the CLI-lesson engine, not the starred one above.
Turn terminal activity into a polished 1920×1080 teaching video: a real terminal
on the left, an explainshell-style panel on the right, one continuous
zh-TW narration, J-cut scene transitions, and sidecar .srt subtitles — with
the audio↔video sync machine-verified, never eyeballed. One topic = one
portable, self-contained folder another agent can pick up and rebuild.
Three production paths exist today, plus a fourth in progress:
- CLI lesson — type a command, dissect its flags token-by-token (rsync, jq, awk, sed→gsed, grep, find, tar, fzf, ssh, rg, ifconfig, df, cp …).
- mdp slideshow — a Markdown deck as a terminal slideshow + a detail panel.
- nvim code-demo — drive an editor to write & run code live.
- (WIP) live Claude Code session — record a real
claudeTUI run and narrate it from hook timecodes + transcript. See Roadmap.
engine/ canonical render engine (TEMPLATE / source-of-truth)
src/ clipkit.py(S/R/CLR + load_lesson) build.py overlay.py verify_sync.py
bundle.py envcheck.py
config.toml lesson.skel.py CLAUDE.tmpl.md context/(sync-model etc)
experimental/ slideshow/ (mdp), nvim/ (recipe), session-recorder/ (hooks
+ live/ = sandbox + sentinel + tape-gen for filming claude)
<slug>/ a PORTABLE clip: vendored src/ + lesson.py + config.toml +
setup.sh + CLAUDE.md + <slug>.mp4/.srt/.html + provenance/
.claude/
settings.json session-recorder hooks (timecode logging)
workflows/clip.js the /clip dynamic workflow
material/ drop topic material here for /clip
Each <slug>/CLAUDE.md is a complete per-clip handoff doc; engine/CLAUDE.md
documents the engine; engine/context/sync-model.md is the sync evolution log.
build.py (edge-tts narration + demo.tape + timeline.json) → setup.sh
(demo env) → vhs demo.tape (terminal.mp4) → overlay.py (panel + J-cut + fades
→ <slug>.mp4) → verify_sync.py (the 5 gates) → bundle.py (freeze
provenance/). The /clip workflow automates all of it (design → envcheck →
author → render → verify → deliver).
ONE continuous narration; on-screen actions are pinned to its sentence
boundaries (edge-tts gives a real timestamp per sentence). The terminal's real
clear frames are detected from the video so the panel switches on the exact
frame the screen clears. Sync is loop-engineered: verify_sync.py gates five
things and nothing ships silently —
- structural sync (every scene locked to a real clear),
- narration not cut, 3. duration ≈5±1 min,
- J-cut never overruns the incoming voice, 5. no scene's voice overruns into the next clip. A fixable desync auto-heals (guided/threshold clears → re-overlay).
Set TypingSpeed 45ms≈ 24ms/char real (~half). The timing model usesconfig.timing.ts=0.024; don't derive it from the Set value.Hide/Showcost zero timeline time — use to clear/setoptinvisibly.- zsh doesn't treat
#as a comment interactively →setopt interactive_commentsin a hidden block first, elsezsh: command not found: #. - Non-deterministic TUIs (claude): use
Wait[+Screen]@timeout /regex/, notSleep. Pair with a sentinel the app prints when done. - The terminal eats the FIRST keystroke after launching a TUI → send a
throwaway
Escapebeforei/input (cost us the nvim demo until found).
autoindentis ON even with-u NONE→ typed indentation compounds. Use a tiny init withset noautoindent nosmartindent nocindent indentexpr=.- A leftover swap file makes nvim open the recovery prompt, which silently
eats your keystrokes →
set noswapfile. :set pasteis deprecated/no-op in nvim 0.11 — don't rely on it.
- Slide transitions are redraws, not clears →
detect_clearsdoesn't apply; anchor the panel to the predictedSpacetimes (we drive them, so it's exact; no typing during the show ⇒ no drift). - Anchor the ending to the VOICE, not the terminal length, and don't
q(quitting shows the shell). Hold the last slideEND_HOLDafter the voice, then fade — a fixed tail gave a 7s silent ending / risked clipping the last word.
- J-cut belongs at the TRANSITION, not in-scene. v1 (in-scene, voice ahead of the command) desynced badly. Transition-level (incoming intro voice leads its video into the previous scene's silent hold) is correct.
- Every scene must hold its still frame until ITS OWN voice finishes (adaptive
end_pad), else a long-narration scene bleeds into the next clip. This and the J-cut-overrun are two opposite failures on the same transition — both gated. - Sparse output (
fzf -f, single-line results) makes the screen dip near-empty mid-scene →detect_clearsover-counts. Falls back to guided selection (pick the N drops nearest the predicted scene times).
- It splits the SRT 1:1 on
。!?— so eachS()must be ONE complete sentence ending in。!?, no mid-sentence。. A mismatch aborts the build ("cue/sentence mismatch"). - Every
star/ordpanel token's flag text must appear verbatim in one of that scene'sS()sentences, or the reveal falls out of typing-sync.
- Default
sed/awk/date/stat/grepare BSD/variant and differ from Linux (BSDsedsilently ignores\w/\b,\tin replacements;dfhas no-T; etc.). Teach the GNU variant (gsed/gawk/gdate) or the strict POSIX subset, and probe deterministically withengine/src/envcheck.py(the/clipEnvCheck phase). Never show output that would differ on Linux without flagging it.
- This build has no
libass/drawtext/subtitles→ text (panel + subs) is drawn as Pillow PNGs andoverlay-ed; subtitles ship as a sidecar.srt. - Don't stack dozens of
overlayfilters — flatten each layer to one video first (concat demuxer), then a single overlay pass (~12s vs minutes). ffmpegis aliased toffmpeg-bar; scripts use the absolute/opt/homebrew/bin/ffmpeg.
- No timestamp in the hook payload — capture your own (
time.time()). - Every payload carries
transcript_path→ the full conversation; the Stop hook's last assistant message is the turn's conclusion (the headline to narrate). - Settings are read at startup → new hooks apply to a fresh session, not the running one.
- FOCUS MODE HIDES hook
systemMessages. A Stop hook that prints an on-screen sentinel (the VHS turn-done marker) renders nothing if the session is in focus mode → everyWait+Screen /sentinel/times out. This is why live recording must use the isolated sandbox (live/claude_sandbox.sh), whose freshCLAUDE_CONFIG_DIRstarts with focus off. Same dir also kills the Chrome-extension dialog (cachedChromeExtensionInstalled:false), the unavailable-model banner (model:opus), and MCP/CLAUDE.md noise. Seeengine/experimental/session-recorder/live/. --dangerously-skip-permissionsfor scripted runs —acceptEditsauto-accepts edits but a turn that runs what it wrote hits a Bash prompt and hangs the tape.
build/is in most global gitignores → the provenance folder is namedprovenance/(don't usebuild/).- Track source + small text provenance; media (
*.mp4/.mp3/.srt/.html) and scratch (intermediate/,.venv/) are gitignored and regenerate from the lesson. - Portability cost: each slug vendors a copy of
engine/src→ fix engine bugs inengine/then propagate to slugs. - Two self-inflicted bugs worth remembering: a shared
intermediate/broke parallel renders (now per-folder); andopen(p,"w").write(open(p).read()…)truncates before it reads — always read first, then write.
We have the teaching scaffolding. The remaining piece is filming Claude Code itself live. Plan (VHS-based, reuses everything):
- ✅ Stop sentinel —
live/vhs_stop_sentinel.shprintsVHS_TURN_DONE_Non Stop so VHS canWaiton a specific turn (complementstimelog.py, which logs the JSONL timeline but emits nothing on screen). - ✅ Session tape —
live/gen_session_tape.pylaunchesclaudein the isolated sandbox (live/claude_sandbox.sh), drives prompts, andWait@timeout /sentinel/between turns (no guessedSleep); VHS records the real TUI →terminal.mp4. The sandbox is what makes this filmable at all — seelive/README.mdfor the four config traps it defeats (focus mode first). - ✅ overlay "session mode" —
live/session_panel.py: left terminal (1200px)- a 720px explainshell panel. Launch flags are dissected appearing WITH the
voice; each turn shows its tool actions + the Stop conclusion. Anchored to
VIDEO-DETECTED timecodes, NOT the hook clock — the hook wall-clock drifts ~8s
from VHS video time over a session (non-uniform), so it can't be mapped; tool
events are mapped into each detected
[submit,done]window by wall-clock fraction. (A course-assembly/montage layer is a later nicety.)
- a 720px explainshell panel. Launch flags are dissected appearing WITH the
voice; each turn shows its tool actions + the Stop conclusion. Anchored to
VIDEO-DETECTED timecodes, NOT the hook clock — the hook wall-clock drifts ~8s
from VHS video time over a session (non-uniform), so it can't be mapped; tool
events are mapped into each detected
14 portable clips rendered + verified PASS; engine + /clip workflow + 3 clip-type
prototypes + the session-recorder all built and committed. See git log.
