captutor #
Screen-recorded, narrated, captioned software tutorials for fuser — assembled from tooling AC already had, plus one new primitive.
node captutor.mjs narrate <screenplay> # just the voice — check pacing (cheap)
node captutor.mjs render <screenplay> # narrate → record → drive → mp4 + vtt
node captutor.mjs render <screenplay> --outbox ~/Desktop/outbox
node captutor.mjs deliver <screenplay> --format docs --outbox ~/Desktop/outbox
node captutor.mjs publish <screenplay> # into apps/docs/public/ + the MDX line
node bin/from-docs.mjs apps/quickstart # a docs page → a screenplay draft
node bin/stage.mjs render <screenplay> # reversible HiDPI clean-stage filming mode
node bin/stage.mjs --vertical render <screenplay> --format vertical
--outbox (or CAPTUTOR_OUTBOX) publishes only the finished, burned-caption
MP4 and its VTT sidecar. A captutor-outbox/v1 JSON manifest lands last, after
the media files have been copied atomically, so Iris's mission runner can treat
the manifest as the completion signal. Set CAPTUTOR_TASK_GID to tie that
delivery back to the Asana assignment that requested it.
deliver --outbox recuts and republishes an existing take without driving the
interface again. Use it for caption or encoding changes: the recorded negative
and measured cue timing stay intact, so the recut costs no credits.
--vertical is Captutor's true portrait stage: Panda's display rotates 90° into
its 720×1280 logical / 1440×2560 physical HiDPI mode, the browser sits in a
630×1190-point window with uniform margins, and the exact previous display
profile is restored afterward. It requires brew install displayplacer on the
recording host.
If Fuser shows Upgrade time! after the reel starts, Captutor stops and keeps
the interrupted negative, appends a captutor-failure/v1 row to
out/failures.ndjson, refreshes Fuser, reruns setup, and retries automatically.
The one idea #
Narrate first, then perform to the click track.
The normal way to make a tutorial is to film it and then fight the voiceover into
sync. captutor inverts that. /api/say (jeffrey PVC) with withTimestamps
returns ElevenLabs' per-character alignment, so every line's exact duration — and
every word's exact position inside it — is known before the camera rolls. The
UI then performs to that clock.
Two consequences worth knowing:
- Captions are free and exact. No whisper pass, no forced aligner, and none
of the transcription-fixup every other pipeline in this repo needs
(
recap/bin/subtitles.mjskeeps atranscriptFixestable because whisper hears "notepat" as "Notepad"). We already know the words — we wrote them. Burned captions use those same timings to highlight the word currently being spoken, while regular white Arial on a translucent black box remains stable. - Overruns cannot desync.
reelreportssince, the wall-clock instant the video's first frame exists. Each beat is stamped where it actually began against that origin, and narration is laid at those measured offsets, never the planned ones. A beat that takes 40s instead of the scripted 6 (an AI generation) delays only itself; every later beat stays glued to its frame.
The smoke test proves this on purpose: its fixture stalls 3.2s on "Fuse", and the final beat lands at 17.0s instead of the planned ~15.1s — in sync.
Pieces #
| Piece | Where | What |
|---|---|---|
reel |
slab/bin/reel.mjs + ScreenRecord.swift |
new. SCStream → SCRecordingOutput → hardware-encoded mp4. Lives in SlabMenubar because that app holds the Screen Recording grant and runs in the GUI session, where an ssh shell cannot go. |
| narration | lib/narrate.mjs |
/api/say, content-hash cached (ElevenLabs bills per character). |
| driving | lib/cdp.mjs |
Trusted CDP input against a real, signed-in Chrome. |
| cursor | lib/cursor.mjs |
The pointer you actually see. |
| compose | lib/compose.mjs |
ffmpeg mux + WebVTT. |
| login | lib/login.mjs + lib/otp-mail.py |
Signs Iris in over email OTP, with nobody in the room. |
| credits | lib/credits.mjs |
The floor under the robot: it spends a client's money. |
reel is deliberately not in this directory. It is general AC infra — the one
screen-video capability the whole repo was missing — so pop, marketing, recap and
AC Native get it too. Only the fuser-specific parts live here.
Why not Playwright's recorder #
Playwright records video and fuser already has an e2e harness, so it looks like
the obvious answer. But it films the browser's internal surface, not the machine:
no real window chrome, no compositor, and a 2560-wide hardware-encoded h264 is
simply a better picture than a VP8 page capture. reel also works for anything
else on the Mac — native apps, AC Native, slab — which Playwright never will.
We still drive with CDP, so we keep Playwright's determinism. Capture and driving are separable; only the capture changed.
Captutor Stage Mode #
CDP clicks do not move the macOS cursor, and reel films the real window — so
without coordination the video would show a dead pointer parked in a corner while
buttons depressed by themselves. bin/stage.mjs is the production path: it films
the real 1.5× macOS pointer and moves it smoothly to the exact same coordinate as
each trusted CDP click. The older shadow-DOM tutorial pointer remains available
outside Stage Mode.
Stage Mode is a reversible transaction around any Captutor command. It saves the
current desk, closes stale QuickTime previews, switches macOS to Light appearance
and the display to 2× HiDPI (1280×720 logical), centers the browser, raises
encoding quality, uses a branded light wallpaper, and temporarily hides desktop
icons, Dock, menu bar, Stats, Macpal's desktop badge, and Slab prompt sigils. The
recorder captures the complete physical desktop, preserving the real rounded
window, shadow, and equal margin. A Swift desktop-level renderer adds a subtle,
deterministic field of rising icon-only production Fuser SVG marks. The
wallpaper contains no wordmark or decorative color field: it renders only the
mark, black-on-white or white-on-black from the active macOS appearance. It is
click-through, runs behind every normal window, and exits inside the same Stage
transaction. Delivery changes only the tiny
ScreenCaptureKit status dot in the extreme top-right, using live pixels sampled
from the adjacent desktop; it never masks or crops the browser window.
Its finally handler restores the saved display mode, pointer size, wallpaper,
processes, and desktop preferences on success, failure, or interruption.
Staying signed in, and not spending the client's money #
Two things have to be true before a take, and neither can wait for a human:
Iris is logged in, and she can afford it. render now checks both before
the camera rolls.
node captutor.mjs login # sign in if needed (render calls this itself)
node captutor.mjs balance # ✦ left, and what recent takes cost
Login. Fuser's session cookie is never written to disk — panda's Chrome
Cookies DB is months stale — so the session lives only in the running browser.
Quit Chrome and Iris is logged out. ensureSignedIn() drives Fuser's own email
OTP path (emailOTP() is enabled in services/auth/auth.ts): email → Continue →
a six-digit code is mailed → type it → in. The code is read straight out of
iris@fuser.studio over IMAP by lib/otp-mail.py — 120 lines of python stdlib,
because panda has /usr/bin/python3 and no mail client, no npm tree, and nothing
we want to provision on a client's machine.
The subtle part is proving a code is ours. The newest Fuser mail is not good enough: a code from ten minutes ago stays the newest one until the new mail lands, and typing it burns the attempt. So we mark the mailbox's UIDNEXT before pressing Continue and accept only a message at or above that mark — "arrived after we asked" becomes an integer comparison, and no clock has to be trusted. Spam is polled too; Fuser's own dialog tells you to check it, and an unattended run can't.
The app password lives on panda at ~/.config/captutor/iris.json (chmod 600), or
in CAPTUTOR_IRIS_APP_PASSWORD. Never in this directory — it gets rsynced to
two office minis. The source of truth is vault/fuser/iris-credentials.md.
Credits. Iris's balance is real money on a client's production account, and generating an app or an image debits it. So an unattended renderer is also a thing that can quietly spend:
| Guard | Default | Override |
|---|---|---|
| warn | below 2,000✦ | CAPTUTOR_CREDIT_WARN |
| refuse to record | below 1,000✦ | CAPTUTOR_CREDIT_FLOOR |
| take cap | 5 takes / 60 min | CAPTUTOR_MAX_TAKES, CAPTUTOR_TAKE_WINDOW_MIN |
The cap is persisted (out/takes.json), not in-process, because the thing to be
afraid of is a shell loop calling captutor five hundred times — an in-process
counter would never see it. That ledger is also the answer to "what does a video
cost": every take records the balance before and after. The current screenplay
stops at pointing to the Generate button, so it costs 0 — the tutorials that
actually generate will not.
Balance comes from account.getQuotas, fuser's own tRPC route, called from inside
the page so the session cookie rides along. (Its REST twin at
/api/v1/account/getQuotas looks nicer and is CORS-blocked from app. — don't.)
Running it #
reel needs SlabMenubar running with the Screen Recording grant
(node slab/bin/frame.mjs doctor).
On a filming host such as Iris/Panda, captutor/bin/install.sh from the
Aesthetic Computer repository installs the source and bundled controller at
~/.local/bin/reel.mjs; Captutor discovers that path automatically. The native
SlabMenubar process remains the recorder because it owns the GUI session and
Screen Recording permission.
Driving needs a Chrome with remote debugging on a profiled user-data-dir —
Chrome ≥136 refuses --remote-debugging-port on the default profile. See
vault/fuser/skills/drive-ui.md; it also carries the fuser selector map and the
hard-won gotchas (synthetic Enter does not submit; Generate is the blue ✦ button
in the App node's bottom-center toolbar).
Captutor writes Fuser's fuser-theme preference before each take. The default
is system, so Panda's macOS appearance controls the result; a screenplay may
set theme: "light" or theme: "dark" only when the brief calls for it.
The self-test needs neither the fuser dev server nor a login:
open -na "Google Chrome" --args --remote-debugging-port=9333 \
--user-data-dir=/tmp/chrome-cdp --no-first-run \
"file://$PWD/fixtures/smoke.html"
CDP_PORT=9333 node captutor.mjs render smoke
Screenplays #
A beat is one spoken line plus what the UI does while it is spoken:
Screenplays also get a small decorative vocabulary. These marks live in an isolated, pointer-transparent layer above the product and self-clear, so they can guide attention without changing or intercepting the interface:
{
say: "Add a node from the left rail.",
do: async ({ point, spotlight, burst }) => {
await point("text=Add a Node");
await spotlight("text=Add a Node", {
label: "Add a Node", dim: 0.48, durationMs: 2200,
});
await burst("text=Add a Node", { glyph: "+" });
},
}
spotlight(selector, options)dims everything outside a padded target and adds an outer accent ring.outline(selector, options)draws the same ring without dimming the frame.burst(selector, options)emits a short deterministic glyph/particle bloom.zoom(selector, options)eases and pans the page camera toward a target;resetCamera()returns it to the exact initial view.clearEffects()removes every active filming mark immediately.
The visual tools are also grouped under effects (effects.spotlight,
effects.outline, effects.burst, effects.zoom, effects.resetCamera,
effects.clear). Selectors accept the same
CSS, text=, and js= forms as point and click.
{ say: "Press Fuse, and the flow runs.",
do: async ({ click, cdp }) => {
await click("[data-testid=fuse]");
await cdp.waitFor("…shelf is open…"); // a real condition, never a sleep
} }
Wait on conditions, not clocks: a sleep that is too short films a spinner, and one that is too long films dead air.
bin/from-docs.mjs drafts a screenplay from a docs page — the page's prose becomes
the narration, and its  screenshots become the blocking hints,
since those are exactly the moments the author thought needed a picture. It cannot
know which button to click; that is the real authoring work, and every do: comes
out as a stub.
Deriving the video from the page is what keeps the two from drifting apart.
apps/ has 13 pages ready to draft. Recipes has no docs page at all — so that
one is authored by hand (screenplays/smoke.mjs is its skeleton), and the page and
the video get written together.
Known gaps #
-
A Chrome relaunched over ssh films badly. On panda,
bin/film-chrome.shstarted from an ssh shell comes up with the bookmarks bar showing (despiteprep-chrometurning it off) and a "Relaunch the browser to load your profile data and keep it encrypted" infobar — Chrome cannot reach the keychain when it is not launched from the GUI session, and both end up in the frame. Signing in does not need a relaunch (ensureSignedInworks in the running browser), so this only bites after a reboot or a crash. Until it is fixed, start the filming browser from panda's own screen once, and leave it up. -
<VideoDocs>ismuted+loop. All 45 existing docs videos are silent clips, so a narrated tutorial plays silent by default. It needs anarratedvariant that dropsmuted/loop, keepscontrols, and wires the already-present-but-empty<track kind="captions" />to our.vtt. That is a change in the client repo — not made yet. -
Long waits are filmed in real time. A 40s generation stays 40s. The offsets are all recorded, so a speed-ramp (ffmpeg
setptsover a marked beat, then recompute) is straightforward — just not built. -
Remote recording assumes one clock.
sinceand the beat stamps must share an epoch, which holds on one machine. Filming onchickenwhile driving from here would need clock-skew correction. -
Captions are a sidecar.
--burnwould need an ffmpeg with libass; the stock Homebrew build has neither libass nor drawtext (this is also whyrecap's composer cannot run on this Mac).