Communitygithub.com

bigzaqui/videolabs

Build a narrated, ~30-minute technical video course with Remotion + ElevenLabs in an Omarchy / Tokyo Night terminal style (Waybar, tiled window, oh-my-zsh prompt, markdown headings), with every on-screen element revealed exactly when the narrator says it, sentence pauses, plain subtitles, fast remote rendering, and a chaptered web player. For hands-on topics it first builds a real lab (qemu/KVM VMs) and uses the lab's real command output on screen and in the narration. Publishing to the videolabs site (Cloudflare) is done by the videolabs_publish skill. Use when the user wants an explainer/course/training/lab video on a technical topic, or wants to edit, re-voice, re-render or republish one made this way.

What is videolabs?

videolabs is a Claude Code agent skill that build a narrated, ~30-minute technical video course with Remotion + ElevenLabs in an Omarchy / Tokyo Night terminal style (Waybar, tiled window, oh-my-zsh prompt, markdown headings), with every on-screen element revealed exactly when the narrator says it, sentence pauses, plain subtitles, fast remote rendering, and a chaptered web player. For hands-on topics it first builds a real lab (qemu/KVM VMs) and uses the lab's real command output on screen and in the narration. Publishing to the videolabs site (Cloudflare) is done by the videolabs_publish skill. Use when the user wants an explainer/course/training/lab video on a technical topic, or wants to edit, re-voice, re-render or republish one made this way.

Works with✓Claude Code✓Codex CLI✓Cursor
npx skills add https://github.com/bigzaqui/videolabs/tree/HEAD/skills/videolabs_create-video

Ask in your favorite AI

Open a new chat with this agent skill pre-loaded.

Documentation

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).

  1. 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.
  2. 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 the Paced … total X min line right after tts and adjust before rendering. For version-specific commands, check the current release (docs and the project's downloads/image tags) first. Format (parsed by engine/scripts/build-scenes.mjs):
    ## 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").
    
    End a module's last lesson with "Module takeaway: …" — the word takeaway triggers the takeaway window.
  3. node engine/cli.mjs new <slug> scaffolds labs/<slug>/ (config, a three-scene script, a visuals.ts); fill in lab.config.json (title, description, tags for the catalog; brand, user, logo, statusbar, statusCmd, networkLabels, session, tools, sample for the look) and run node engine/cli.mjs <slug> scenes.
  4. Visuals in labs/<slug>/visuals.ts (types in engine/src/types.ts, colors from @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 (statusCmd label, default from the config), and flows (blocks moving between hosts, from cue until until). Any host/daemon/disk/status can have a cue (appears when spoken) and changes: [{cue, state, label}] (disk empty|osd|leader|idle|down|destroyed|new, host ok|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; each cmd types itself when spoken, then its out lines print; keeps the newest rows. host sets the whole prompt of a line (default session.prompt from the config). Optional compact topology above it. The bottom command bar is hidden for session scenes. Other kinds: title (fastfetch card; user, extra rows), layers, cards, flow, tree, compare, gauge, terminal (command output with callouts), incident (symptom/check/action), checklist, states, sites; optional takeaway and commandCues per scene. Use real output from lab/captures/ for every out and symptom.
  5. Review visuals before paying for audio: node engine/cli.mjs <slug> stills (one frame per scene at 60%; S04,S11 --at=12.5 for a moment) and tile into 2×2 contact sheets with ffmpeg xstack to look at them. Check overflow, wrapping, empty layouts.
  6. Voice: repo-root .env with ELEVENLABS_API_KEY / ELEVENLABS_VOICE_ID, then node engine/cli.mjs <slug> tts (run it directly, no approval needed; skips existing; --only=S03,S04 --force to redo only the scenes whose narration changed, which also saves credits). It runs pace automatically. Tell the user the new length when the script grew. Commit labs/<slug>/audio/ (paid output).
  7. Sync audit: node engine/cli.mjs <slug> cues (after any change to a command, also change its commandCues entry: a chip for dmesg still 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.
  8. Preview: node engine/cli.mjs <slug> sample (≈1 min on the render server), spot-check frames with ffmpeg -ss <t> -frames:v 1, volumedetect for levels (no clipping: max < -1 dB).
  9. Render the full course (remote, below), then node engine/cli.mjs <slug> player, make the poster labs/<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 like rados, mon, cephfs are just palette slots). Waybar strip with modules as workspaces 0–7, a status (HEALTH_WARN on the troubleshooting module), lesson counter 07/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 ❯ cmd lines, takeaways as a floating window cat sre-takeaway.md, module dividers as cd 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's visuals.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 in engine/src/components/Visuals.tsx (topology/session use keyed traversal in topologyKeys/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 cues audit.
  • Subtitles: plain phrases, broken at sentence/clause ends, one line ≤ 64 chars, no per-word karaoke highlighting ("exhausting").
  • Breathing: engine/scripts/pace.mjs inserts 250 ms after . ? ! and 120 ms after : ; into the raw ElevenLabs audio and shifts word timings. Cut points come from ffmpeg silencedetect (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). Tune SENTENCE_PAUSE/CLAUSE_PAUSE, rerun node engine/cli.mjs <slug> pace (free; raw audio is kept in audio/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 copy data/ and public/voiceover/ to the render host.
  • Transitions: 15-frame crossfades via @remotion/transitions TransitionSeries; they overlap only the silent lead-in (0.4 s) / tail (0.8 s). engine/src/timeline.ts subtracts overlaps; engine/scripts/player.mjs and stills.mjs mirror those constants — keep them in sync.
  • Voice the user chose: ElevenLabs "Mark" v3p1kjzUvro6S76qmYmH (Voice Library; works for TTS though GET /v1/voices/{id} returns 404), eleven_multilingual_v2, speed 1.08, with-timestamps endpoint, previous_text/next_text for continuity. Always verify a voice id with a 1-sentence TTS call before a full run (an earlier id in .env was 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: run set -a; . /opt/opus/videolabs/.env; set +a before the commands below, and write that variable, not a literal address, in anything you commit. The same host runs the lab VMs.
  • TMPDIR=/var/tmp is set by the CLI: /tmp is 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/videolabs on both machines. Flow:
    rsync -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> player
    
    (To keep timing identical to what you reviewed, rsync labs/<slug>/data and public/voiceover instead of running scenes/pace on the server.) Never copy .env there. Run long renders detached (nohup bash script & started with ssh -f -n) and wait with a Monitor on the real line of time; 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 -; regenerated scenes/durations/captions.json and the cues audit 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: moov before mdat), player/chapters.json and optionally player/lab.json (node engine/cli.mjs <slug> player), and a poster spot.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); minutes and lessons are filled in from chapters.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 and lab/captures/Sxx.txt ([host] cmd / output / [rc=N] blocks written by lab/cap.sh) exist, cli player writes out/player/lab.json and site/lab.js shows: 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. Without lab.json those sections stay hidden. Check the page with node 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:

  1. 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. Add 10.0.0.1N nodeN lines to the host's /etc/hosts and a Host nodeN block (lab key, no host-key checking) to ~/.ssh/config.
  2. 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 a serial= so they appear as sda/sdb (root is vda). Make a clean copy of all VM disks (base/) and a lab-reset.sh that restores them, so any lesson can be replayed.
  3. Record: lab/cap.sh <scene> <node> <cmd…> runs a command over ssh, prints it and appends [node] cmd, output and [rc=N] to lab/captures/<scene>.txt. Run the course in script order, one scene at a time. Long waits (a 10-minute out-interval) go in a background nohup chain plus a Monitor until-loop, never a foreground sleep.
  4. 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).
  5. Diff the script against reality and fix both narration and visuals.ts (outputs, ids, device names, timings, health states). Keep a list in lab/README.md and lab/lab-meta.json (findings), keep the pre-lab files in lab/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).
  6. Destroy the lab when the video is done. Once the final video is rendered and the page data is built (cli player has written lab.json from lab/captures/), tear the lab down: run lab/destroy-lab.sh on the KVM host (stops and undefines the VMs with their disks, removes the libvirt networks, the lab key directory and the host's /etc/hosts and ~/.ssh/config entries) and check with virsh list --all, virsh net-list --all and df -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 a lab/destroy-lab.sh next to create-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 with create-lab.sh).
  7. 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 does setsid nohup ... < /dev/null > log 2>&1 &. A plain ssh 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 filters ps output (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 racing kill -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 DROP on 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.json may 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).
  8. 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).
  • ?? treats null as unset — use pick() in cues.ts so cue: null means "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 900 on the render server: plain chrome-headless-shell hangs (even on about:blank) on some hosts, Remotion's own openBrowser works. Check desktop 1360 and phone 400, hscroll must be 0. A local python3 -m http.server sends 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 the real line of time instead.
  • When a single scene's command list changes, data/scenes.json is rebuilt by cli scenes (word counts follow), but durations.json/captions.json only change after cli tts/pace.
  • remotion.config.ts is bundled as CommonJS: it cannot import the ESM helper engine/scripts/lib.mjs (no import.meta); the @lab/@engine aliases are duplicated inline there and must stay in step with aliases() in lib.mjs.
  • cli typecheck generates engine/.tsconfig.lab.json with the lab's paths; the checked-in tsconfig points editors at one lab.
  • Codex sessions live in ~/.codex/sessions/YYYY/MM/DD/*.jsonl and ~/.codex/history.jsonl (prompts in plain text — warn the user if they pasted API keys there).

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

Related Skills