videolabs: create a video (terminal course style)
Everything lives in one repo, /opt/opus/videolabs (github.com/bigzaqui/videolabs, public: never commit credentials; run scripts/check-secrets.sh before committing). This skill's source is skills/videolabs_create-video/ in that repo, linked into ~/.claude/skills by scripts/setup.sh. Its configuration values (ElevenLabs and Cloudflare keys, the render host) are in the git-ignored repo-root .env, see .env.example.
The repo contains: a generic engine (Remotion + ElevenLabs tooling, one npm ci) and one folder per lab under labs/. Used for "kafka - in a nutshell" (26 lessons / 18 min), "ceph - first steps" (14 lessons / 11.6 min) and, earlier, "ceph in a nutshell" (41 lessons / 30 min; its script and visuals are kept in reference/ as the example of cards, compare, gauge, tree, incident, states and sites). Both current labs are hands-on: every command was run on a real lab first (see "Real lab").
/opt/opus/videolabs/
engine/ generic tooling: engine/cli.mjs <lab> <command>, src/ (Remotion), scripts/
labs/<slug>/
lab.config.json catalog entry (title, description, tags) + look (brand, logo, status bar, tool names, sample cut)
script/script.md narration, one scene per ### heading
visuals.ts what each scene shows + cue overrides
lab/ create-lab.sh, destroy-lab.sh, cap.sh, captures/, lab-meta.json
audio/raw/ ElevenLabs output (paid; committed to git)
out/ data/ public/voiceover/ generated, git-ignored
site/ scripts/publish-all.sh state/ the site and its publishing (skill videolabs_publish)
script.md ──cli scenes──▶ data/scenes.json
└─ cli tts (ElevenLabs word timings) ─▶ audio/raw ──pace──▶ public/voiceover/* + data/{durations,captions}.json
visuals.ts + engine/src/cues.ts (reveal = when the word is spoken) ─▶ Remotion ──cli render──▶ out/<slug>.mp4
cli player ─▶ out/player/{chapters.json,lab.json} ──videolabs_publish──▶ videolabs.pages.dev
All commands: node engine/cli.mjs <lab> <scenes|tts|pace|cues|stills|typecheck|studio|sample|render|player>, plus list and new <slug> (README.md of the repo has the table). The lab folder is the only thing that differs between courses; never copy engine code into a lab.
Workflow (agree on the outline and script with the user; everything after that runs without asking)
Standing authorization from the user: once the script is agreed, run the paid ElevenLabs tts, the cue audit, the render on the render host, the player/poster step and the lab teardown without asking for permission at each step. Report results and the new length afterwards. Only stop to ask when something is outside this workflow (e.g. a destructive action on things the lab did not create, or publishing to the site when the user has not asked for it).
- Hands-on topic? Build and run a real lab first (see "Real lab" below). Never write command output from memory: the first draft of the ceph-first-steps script was wrong in ten places once it ran on a real cluster. Then write the script from what really happened.
- Script first, video later. Propose an outline (modules → lessons, with the diagram planned per lesson), get agreement, then write
labs/<slug>/script/script.md. Changing text is far cheaper than regenerating audio. Mark at speed 1.0 with pauses ≈ 160 wpm: 1,500 words ≈ 9.5 min incl. module cards; at 1.08 ≈ 168 wpm. Check the real length with thePaced … total X minline right afterttsand adjust before rendering. For version-specific commands, check the current release (docs and the project's downloads/image tags) first. Format (parsed byengine/scripts/build-scenes.mjs):
End a module's last lesson with "Module takeaway: …" — the word takeaway triggers the takeaway window.## Module 1 — Core Architecture ### S02 · RADOS **Visual:** notes for the designer **Commands:** `ceph -s` `ceph osd tree` (optional, on-screen only) **Narration:** One paragraph, written to be spoken. Spell commands as spoken ("ceph dash s"). node engine/cli.mjs new <slug>scaffoldslabs/<slug>/(config, a three-scene script, a visuals.ts); fill inlab.config.json(title, description, tags for the catalog;brand,user,logo,statusbar,statusCmd,networkLabels,session,tools,samplefor the look) and runnode engine/cli.mjs <slug> scenes.- Visuals in
labs/<slug>/visuals.ts(types inengine/src/types.ts,colorsfrom@engine/theme), one entry per scene id. Diagram kinds (engine/src/components/Diagrams.tsx):topology: hosts (name, ip, daemon chips, disks/replicas) on one or two network lines, an optional status line (statusCmdlabel, default from the config), andflows(blocks moving between hosts, fromcueuntiluntil). Any host/daemon/disk/status can have acue(appears when spoken) andchanges: [{cue, state, label}](diskempty|osd|leader|idle|down|destroyed|new, hostok|spare|down|maint). Elements without a cue are visible from the start. Build lab state with helpers (host(n),built(n)) so the diagram grows lesson by lesson.session: a realistic shell; eachcmdtypes itself when spoken, then itsoutlines print; keeps the newest rows.hostsets the whole prompt of a line (defaultsession.promptfrom the config). Optional compacttopologyabove it. The bottom command bar is hidden for session scenes. Other kinds:title(fastfetch card;user, extrarows),layers,cards,flow,tree,compare,gauge,terminal(command output with callouts),incident(symptom/check/action),checklist,states,sites; optionaltakeawayandcommandCuesper scene. Use real output fromlab/captures/for everyoutandsymptom.
- Review visuals before paying for audio:
node engine/cli.mjs <slug> stills(one frame per scene at 60%;S04,S11 --at=12.5for a moment) and tile into 2×2 contact sheets with ffmpegxstackto look at them. Check overflow, wrapping, empty layouts. - Voice: repo-root
.envwithELEVENLABS_API_KEY/ELEVENLABS_VOICE_ID, thennode engine/cli.mjs <slug> tts(run it directly, no approval needed; skips existing;--only=S03,S04 --forceto redo only the scenes whose narration changed, which also saves credits). It runspaceautomatically. Tell the user the new length when the script grew. Commitlabs/<slug>/audio/(paid output). - Sync audit:
node engine/cli.mjs <slug> cues(after any change to a command, also change itscommandCuesentry: a chip fordmesgstill cued on "smartctl" is a silent mismatch) — lists the word that reveals every element and every command line (MISS= spread between neighbours). Fix wrong matches with cue overrides (below). Re-run after any narration or voice change. - Preview:
node engine/cli.mjs <slug> sample(≈1 min on the render server), spot-check frames withffmpeg -ss <t> -frames:v 1,volumedetectfor levels (no clipping: max < -1 dB). - Render the full course (remote, below), then
node engine/cli.mjs <slug> player, make the posterlabs/<slug>/out/spot.jpg, destroy the lab VMs, and publish the whole site with the videolabs_publish skill.
Design rules the user signed off on
- Look: Omarchy/Hyprland desktop, Tokyo Night palette (
src/theme.ts; semantic names likerados,mon,cephfsare just palette slots). Waybar strip with modules as workspaces 0–7, a status (HEALTH_WARNon the troubleshooting module), lesson counter07/41. One tiled window, 2px square border, no radius/glow/gradient. Each lesson opens with a typed robbyrussell prompt➜ module-1-core-architecture git:(s03) cat the-daemons.md, then a# Heading. Notes render as// comments, commands as❯ cmdlines, takeaways as a floating windowcat sre-takeaway.md, module dividers ascd module-N-…+ huge title, the opener as fastfetch. All JetBrains Mono; Inter only for subtitles. It replaced a "generic" dark-gradient look the user disliked. - Reveal sync: never stagger by time. Each element appears when the narrator first says its name (
engine/src/cues.ts: longest meaningful token, prefix-stemmed, searched forward from the previous cue). Overrides in the lab'svisuals.ts:cue: "=managers"exact spoken word (hyphens/punctuation ignored:"=write-ahead"),cue: null= no word, interpolate.pointCues(compare sides),noteCue(tree),linkCue(sites),cues(title badges),commandCues(command lines, per scene).- Label order in
cueLabels()must match the index order the renderer uses inengine/src/components/Visuals.tsx(topology/session use keyed traversal intopologyKeys/sessionKeys). - One forward cursor runs over all labels of a scene, so a later label reusing a common word ("three", "recovery", "add") can land on a later occurrence. Prefer a word that occurs once near the moment ("=repeat", "=begins"), and always read the
cuesaudit.
- Subtitles: plain phrases, broken at sentence/clause ends, one line ≤ 64 chars, no per-word karaoke highlighting ("exhausting").
- Breathing:
engine/scripts/pace.mjsinserts 250 ms after. ? !and 120 ms after: ;into the raw ElevenLabs audio and shifts word timings. Cut points come from ffmpegsilencedetect(strict -38 dB/40 ms, then -30 dB/20 ms), else the quietest 5 ms window, with 8 ms fades — never from ElevenLabs word boundaries alone (those land inside words). TuneSENTENCE_PAUSE/CLAUSE_PAUSE, rerunnode engine/cli.mjs <slug> pace(free; raw audio is kept inaudio/raw/). Pacing depends on the ffmpeg version (7.1.5 and 8.0.1 gave 17.9 and 17.8 min for the same Kafka audio): pace where you render, or copydata/andpublic/voiceover/to the render host. - Transitions: 15-frame crossfades via
@remotion/transitionsTransitionSeries; they overlap only the silent lead-in (0.4 s) / tail (0.8 s).engine/src/timeline.tssubtracts overlaps;engine/scripts/player.mjsandstills.mjsmirror those constants — keep them in sync. - Voice the user chose: ElevenLabs "Mark"
v3p1kjzUvro6S76qmYmH(Voice Library; works for TTS thoughGET /v1/voices/{id}returns 404),eleven_multilingual_v2, speed 1.08,with-timestampsendpoint,previous_text/next_textfor continuity. Always verify a voice id with a 1-sentence TTS call before a full run (an earlier id in.envwas truncated).
Per-course settings (labs//lab.config.json)
Workspaces in the Waybar and the fastfetch module/lesson counts follow the scenes automatically. Everything else course-specific is data in the config, never code: brand (Waybar name), user (prompt on the title card), logo (five lines of block letters), statusbar (ok/warn text, right text, warnModule = the module that shows the warning), statusCmd and networkLabels (defaults of topology scenes), session.prompt/session.title (terminal sessions), tools (command words skipped when auto-cueing a command line), sample (scene ids of the Sample cut), plus title, description, tags for the catalog. A new lab with a different look needs no engine change; a new diagram kind or state does (engine/src/types.ts, components, cues.ts) and then benefits every lab.
Remotion practices applied (from the remotion-* skills)
Fonts via @remotion/google-fonts (never rely on system fonts); <Audio> from @remotion/media with from; TransitionSeries; calculateMetadata sizes the composition from audio durations; animate with interpolate/Easing.bezier and translate (no CSS transitions). Not adopted on purpose: Interactive.create/connected compositions (only useful for hand-editing in Studio), big social-video text minimums, meme SFX.
Rendering
VIDEOLABS_RENDER_HOST(ssh target of the render and lab server, keys already set up) comes from the repo-root.env: runset -a; . /opt/opus/videolabs/.env; set +abefore the commands below, and write that variable, not a literal address, in anything you commit. The same host runs the lab VMs.TMPDIR=/var/tmpis set by the CLI:/tmpis a small RAM tmpfs on these hosts and a long audio mix fails with "No space left on device" at the very end.- Local box (4 vCPU) ≈ 45 min per 30 min of video. Remote render server $VIDEOLABS_RENDER_HOST (Ubuntu 26.04, 24 threads, 31 GB RAM, RTX 3090 Ti, KVM; Node 22, ffmpeg, rsync and the Chromium libs from apt) renders the 11.6-min ceph course in 3 min and the 18-min kafka course in 5 min at concurrency 12–16. The repo lives at
/opt/opus/videolabson both machines. Flow:(To keep timing identical to what you reviewed, rsyncrsync -az --delete --exclude node_modules --exclude out --exclude .env --exclude .git --exclude site --exclude state ./ $VIDEOLABS_RENDER_HOST:/opt/opus/videolabs/ # from /opt/opus/videolabs ssh $VIDEOLABS_RENDER_HOST 'cd /opt/opus/videolabs/engine && npm ci && cd .. && node engine/cli.mjs <slug> scenes && node engine/cli.mjs <slug> pace && node engine/cli.mjs <slug> render --concurrency=12 --log=error' rsync -a $VIDEOLABS_RENDER_HOST:/opt/opus/videolabs/labs/<slug>/out/<slug>.mp4 labs/<slug>/out/ && node engine/cli.mjs <slug> playerlabs/<slug>/dataandpublic/voiceoverinstead of runningscenes/paceon the server.) Never copy.envthere. Run long renders detached (nohup bash script &started withssh -f -n) and wait with a Monitor on therealline oftime; do not grep the log for "error" (the command line has--log=error). If the Waybar/diagram code changes, check both labs (stills), since they share it. - Verifying an engine change: render the Sample cut with the old and the new code and compare
ffmpeg -i a.mp4 -i b.mp4 -lavfi ssim -f null -; regeneratedscenes/durations/captions.jsonand thecuesaudit must be identical. - When a lab runs on the same box (libvirt VMs ≈ 12–20 GB RAM), shut the VMs down before a full render.
- GPU: Remotion supports NVENC on Linux (
--hardware-acceleration=if-possible) and--gl=angle-egl, but this content is CPU-bound (Chrome layout + capture), expected gain ~10–20%. The 3090 Ti was idle when checked. Benchmark on the Sample before adopting. - Stopping a render: kill by PID;
pkill -f '<pattern>'also matches and kills the calling shell.
Publishing (videolabs on Cloudflare)
Hosting is the videolabs site in the same repo (site/, Cloudflare R2 + Pages, https://videolabs.pages.dev). Use the videolabs_publish skill; it publishes the whole site, every lab at once (scripts/publish-all.sh). What a lab must provide in labs/<slug>/out/:
<slug>.mp4(fast-start:moovbeforemdat),player/chapters.jsonand optionallyplayer/lab.json(node engine/cli.mjs <slug> player), and a posterspot.jpg. Nothing in the pipeline makes the poster: grab a diagram frame, e.g.ffmpeg -ss 322 -i out/<slug>.mp4 -frames:v 1 -q:v 3 out/spot.jpg(not the title card, not a scene that is just starting). The HLS segments are not needed on videolabs (it streams the plain MP4).- The catalog entry comes from
lab.config.json(title, description, tags);minutesandlessonsare filled in fromchapters.json. - Lab resources page: if
lab/lab-meta.json(intro,specs,networks,nodes,findings[{scene,title,body}],failures[{scene,title,cmd}],files,notRun), the lab scripts andlab/captures/Sxx.txt([host] cmd/ output /[rc=N]blocks written bylab/cap.sh) exist,cli playerwritesout/player/lab.jsonandsite/lab.jsshows: an "In the lab" panel under the video that follows the lesson (real commands and output, findings, first 6 commands then "Show all"), the lab layout (one or two networks), "what the real lab changed" cards that seek the video, the scripts with copy buttons, and all recorded output. Withoutlab.jsonthose sections stay hidden. Check the page withnode engine/scripts/shot.mjs(below) before pushing.
Real lab (hands-on courses)
Worked examples: labs/ceph-first-steps/lab/ (4 VMs, two networks, data disks) and labs/kafka-in-a-nutshell/lab/ (3 brokers + a laptop VM + a remote client, one network), both built on the render server. The recipe:
- Host: any KVM box (
/dev/kvm, libvirt,virt-install,xorriso). Check RAM first: 4 VMs × 5 GB ≈ 20 GB; shut the VMs down before a full render. Add10.0.0.1N nodeNlines to the host's/etc/hostsand aHost nodeNblock (lab key, no host-key checking) to~/.ssh/config. - Build with
lab/create-lab.sh: two libvirt networks (public NAT 10.0.0.0/24 so images can be pulled, isolated cluster 10.1.0.0/24), Ubuntu cloud image with qcow2 backing files, cloud-init seed ISO (hostname,/etc/hosts, root key, netplan, chrony/podman/lvm2 packages; MACs fixed per node so the netplan match works), data disks as SCSI with aserial=so they appear assda/sdb(root isvda). Make a clean copy of all VM disks (base/) and alab-reset.shthat restores them, so any lesson can be replayed. - Record:
lab/cap.sh <scene> <node> <cmd…>runs a command over ssh, prints it and appends[node] cmd, output and[rc=N]tolab/captures/<scene>.txt. Run the course in script order, one scene at a time. Long waits (a 10-minute out-interval) go in a backgroundnohupchain plus aMonitoruntil-loop, never a foreground sleep. - Inject failures realistically:
virsh detach-disk <vm> <dev> --live(a disk dies, but an idle service only notices once it does I/O, so generate writes),qemu-img create+virsh attach-disk … --serial …(replacement disk; reboot the VM if the new disk must keep the old device name),virsh destroy <vm>(power loss). - Diff the script against reality and fix both narration and
visuals.ts(outputs, ids, device names, timings, health states). Keep a list inlab/README.mdandlab/lab-meta.json(findings), keep the pre-lab files inlab/orig/for the diff, and tell the user the new length. Say what you did not run. Put the script's real "gotchas" into the narration when they bite (e.g. the replacement disk must keep its device name). - Destroy the lab when the video is done. Once the final video is rendered and the page data is built (
cli playerhas writtenlab.jsonfromlab/captures/), tear the lab down: runlab/destroy-lab.shon the KVM host (stops and undefines the VMs with their disks, removes the libvirt networks, the lab key directory and the host's/etc/hostsand~/.ssh/configentries) and check withvirsh list --all,virsh net-list --allanddf -h /that nothing is left. Do not leave lab VMs around: ceph-first-steps' images grew to 80 GB and filled the host's disk, which paused the next lab's VMs. Every lab keeps alab/destroy-lab.shnext tocreate-lab.sh(copy and adapt one of the two examples), and the lab folder keeps everything needed to replay:create-lab.sh,lab-reset.sh,cap.sh,lab-meta.json,captures/. Only destroy what the lab script created (names in the script), and tell the user what was removed. Wait to destroy until no further re-recording is planned (a script fix that changes a command needs the lab again: rebuild withcreate-lab.sh). - Lab mechanics learned on the Kafka lab (
labs/kafka-in-a-nutshell/lab/, 3 brokers + a laptop VM + a remote client, one NAT network):- Start long-running processes with
ssh -f -n host 'bash /root/start.sh'where the script doessetsid nohup ... < /dev/null > log 2>&1 &. A plainssh host 'nohup cmd &'keeps the session (and your tool call) open until it times out. - Never
pkill -f <name>over ssh (it kills its own shell, rc=255, and the target may survive); use a script that filterspsoutput (lab/kill-broker.sh) and kill by PID. - Make one-shot "crash" demos deterministic inside the program (e.g. the consumer calls
os._exit(1)after processing) instead of racingkill -9; wait out session timeouts between runs. - Cut replication, not the control plane, to demonstrate ISR/quorum effects (
iptables -I INPUT -s <peer> -p tcp --dport <data port> -j DROPon the leader): killing two of three combined broker+controller nodes also destroys the quorum and freezes metadata. - Keep captures short and clean: stop console tools with SIGINT (
timeout -s INT 8 ...), drop or filter huge outputs (internal-topic listings), and remove captures of experiments that did not show what you intended. lab-meta.jsonmay set"networks": {"public": "10.2.0.0/24"}(one network) or{"public", "cluster"}(two), per-node"disks"text, and"files": [[name, about], ...]to choose which scripts appear on the page (default: create-lab.sh, lab-reset.sh, cap.sh). Findings are shown in lesson order.- Disk space: a finished lab can be huge (the Ceph images reached 80 GB and paused the next lab's VMs). Destroy labs as soon as the video is done (step 6).
- Start long-running processes with
- Things that were wrong in the first draft and are worth testing again on any similar course: health state after the first step, id/numbering order (cluster IDs do not follow host order), default object/PG counts (a 1-PG pool makes a rebalance invisible, so write enough data), "evenly balanced" claims right after a change, what the failure actually looks like before the timeout, tools that do not work on virtual hardware (SMART), flags and warnings of maintenance modes, and what automation does after a host is removed.
Gotchas collected
- The web build of JetBrains Mono has no
█glyph — draw block logos as cells. - Long Waybar centre text pushes the left side to wrap: keep
whiteSpace: nowrap, show only the file name. - Gauge bars must be drawn to scale (an early version subtracted label space from bar height).
??treatsnullas unset — usepick()incues.tssocue: nullmeans "no cue".- Single-scene stills/compositions need lesson counters computed over the full course, not the filtered list.
- Check pages with
node engine/scripts/shot.mjs <url> out.png 1360 900on the render server: plainchrome-headless-shellhangs (even onabout:blank) on some hosts, Remotion's ownopenBrowserworks. Check desktop 1360 and phone 400,hscrollmust be 0. A localpython3 -m http.serversends no charset, so·/▸look garbled there; the deployed pages and the Artifact skeleton add it. - Never
pkill -f <pattern>from the same shell, and never grep a render log for "error" (the command line has--log=error); wait for therealline oftimeinstead. - When a single scene's command list changes,
data/scenes.jsonis rebuilt bycli scenes(word counts follow), butdurations.json/captions.jsononly change aftercli tts/pace. remotion.config.tsis bundled as CommonJS: it cannot import the ESM helperengine/scripts/lib.mjs(noimport.meta); the@lab/@enginealiases are duplicated inline there and must stay in step withaliases()in lib.mjs.cli typecheckgeneratesengine/.tsconfig.lab.jsonwith the lab's paths; the checked-in tsconfig points editors at one lab.- Codex sessions live in
~/.codex/sessions/YYYY/MM/DD/*.jsonland~/.codex/history.jsonl(prompts in plain text — warn the user if they pasted API keys there).