## StageMaster: demo, launch, feature and tutorial videos

Use this when the user wants a demo, launch, feature or tutorial video of an app, website or
command-line tool, or any narrated screen recording made on this Mac. You write narration lines
and blocking (mouse and keyboard steps). StageMaster voices the lines with Kokoro, performs the
blocking while it films one app window, and exports an mp4. You drive the running app with
`stagemasterctl`.

A production is a folder: `manifest.json`, `versions/vNNNN-*.json` (lines, voice, target app) and
`blocking.stage` (the steps). Audio comes first: each line is voiced and measured, steps anchored
to lines get absolute times, then one take films one window while the runner performs the steps.

### Sandbox

- `stagemasterctl` talks to a loopback port. If it exits 3 ("unreachable") while `pgrep -x
  StageMaster` finds the app, the sandbox is blocking that port. Run it with network access.
- Productions live in `~/StageMaster`, outside the workspace. Writing there needs approval or full
  access.

### Calling convention

- `stagemasterctl <verb> [key=value ...] [--json]`. `stagemasterctl verbs` lists every verb;
  `stagemasterctl help <verb>` explains one.
- Exit codes: 0 ok, 1 the verb failed (reason on stderr), 2 usage, 3 the app is not reachable.
- `--json` prints `{"ok":true,"data":...}` or `{"ok":false,"error":"..."}`.
- Flags read their value: `0`, `false`, `no` and `off` are off. A flag left out is off.
- A relative path starts from your working directory.
- `render`, `run rehearse=1`, `record` and `export` return at once. Poll their status verbs.
- Not on PATH: `/Applications/StageMaster.app/Contents/MacOS/stagemasterctl`.

### 1. Reach the app

```
pgrep -xq StageMaster || open -a StageMaster
until stagemasterctl ping >/dev/null 2>&1; do sleep 1; done
stagemasterctl permissions --json   # "missing" must be []
stagemasterctl camera               # must say proven
```

Missing grant: `stagemasterctl permissions.open which=accessibility` (or `screenRecording`,
`automation`, `chrome`), then ask the user to switch it on. A grant made after launch needs a
StageMaster relaunch; ask first. `camera` blocked: ask the user to answer the macOS screen
capture prompt, then run `camera` again.

### 2. Create the production

```
D=$HOME/StageMaster/demos/recordings/my-film
mkdir -p "$D/versions"
printf '{"name": "Demos"}\n' > $HOME/StageMaster/demos/project.json
```

`$D/manifest.json`:

```json
{"head": "v0001-20261014-090000.json", "versions": ["v0001-20261014-090000.json"]}
```

`$D/versions/v0001-20261014-090000.json` (`v` + 4 digits + UTC `yyyyMMdd-HHmmss`):

```json
{
  "schema": "stagemaster.recording.v1",
  "id": "my-film",
  "title": "My film",
  "description": "What it shows, for whom, in one paragraph.",
  "origin": {"prompt": "<the user's request, verbatim>", "agent": "codex",
             "sessionId": null, "createdAt": "2026-10-14T09:00:00Z"},
  "voice": {"driver": "kokoro", "character": "american-women", "voice": "af_heart"},
  "capture": {"app": "Google Chrome", "fps": 30},
  "gap": 0.25,
  "blocking": "blocking.stage",
  "look": {"on": true, "padding": 0.06, "radius": 0.02, "shadow": true,
           "background": {"preset": "slate"}},
  "timeline": [
    {"id": "l1", "type": "line", "text": "Meet Tasks, a to-do list that keeps up with you.", "start": 1.0},
    {"id": "l2", "type": "line", "text": "Type what needs doing and press return.", "start": 5.2}
  ]
}
```

- Required: `schema`, `id`, `title`, `voice.driver`, `timeline`, and `id` + `type` on each item.
  Unknown keys are dropped without a word.
- `voice.driver` is `kokoro` (any case). A name that is not an engine fails at open. `vaux` is
  not released and every render fails.
- `capture.app` is the app filmed. Left out, it is the app of the blocking's first `app` step,
  else `Google Chrome`. `capture.window` (words in a window title) picks one window of it.
- `stagemasterctl open dir="$D"`. A failure names the file and the field, for example
  `timeline[2].start must be a number`.
- `recording.new prompt=... title=...` seeds a production in the current project with no lines,
  an empty script and a Kokoro cast (`american-women`, `af_heart`).

### 3. Lines

- One idea per line, 15 words or fewer.
- Spell numbers out. Kokoro reads digits badly.
- Say what the screen shows while it shows it.
- Ids `l1`, `l2`, ... with increasing `start`. Estimate words / 2.6 + 0.5 s per line. Start `l1`
  at 0.5 to 1.0 so setup steps finish before the first word. `render` re-measures and re-spaces
  every line `gap` seconds apart.

### 4. Blocking (`blocking.stage`)

```
app "Google Chrome"                         # first: raise the target
js "localStorage.clear(); location.reload()"   # reset state so every take starts the same
wait 1.0

with l2 offset 0.4:                         # 0.4 s after l2 starts speaking
  move 720, 320 over 0.8s
  click 720, 320
  type "Book the venue"
  key return
after l2:                                   # when l2 ends
  hold 1.0
```

Steps:

- `app "Name"`: raises the app (name, `.app` file name or bundle id, any case) and launches it
  when it is not running. An unknown name stops the step. Launch the filmed app before the roll
  anyway: the camera picks its window before the first step.
- `open "url"`: opens with the default browser, in a new tab. 1.0 s.
- `click x, y`: left click at global screen points (origin top-left of the main display). The
  pointer jumps.
- `move x, y over Ns`: glides the pointer. 0.4 s without `over`.
- `scroll px over Ns`: pixel scroll at the pointer. Positive is down. Move over the content first.
  0.6 s without `over`.
- `type "text"`: types the text, 20 characters at a time, near instant. No escapes; `\n` is typed
  literally. Use `key return`.
- `type "text" over Ns`: types one letter at a time over N seconds, at a human pace.
- `key combo`: `cmd+shift+l`, no spaces. Modifiers `cmd` `shift` `alt`/`option` `ctrl`. Keys:
  `a`-`z`, `0`-`9`, `return`, `tab`, `space`, `delete`, `forwarddelete`, `escape`, `left`,
  `right`, `up`, `down`, `home`, `end`, `pageup`, `pagedown`, `f1`-`f12`, and punctuation
  (`equal`, `minus`, `bracketleft`, `bracketright`, `quote`, `semicolon`, `backslash`, `comma`,
  `slash`, `period`, `grave`). An unknown key makes `record` refuse.
- `wait N` / `hold N`: sleep N seconds (let the app catch up / deliberate stillness).
- `js "code"`: JavaScript in Google Chrome's front window, active tab. Chrome only. Needs
  Automation and Chrome's View > Developer > Allow JavaScript from Apple Events.
- `agent "prompt"`: spawns a Claude session mid-take. Never proven in a real take. Do not use it.

Anchor headers end with `:`; indented steps under them share the anchor:

- `with lN [offset S]:` at lN's start (+S). `after lN [offset S]:` at lN's end (+S).
  `at S:` at S seconds. `until lM` is accepted and changes nothing.
- `cue name` at column 0 names a section; indent its body. No timing.

Timing rules:

1. Unanchored steps run in sequence from 0. `wait`, `hold`, `move` and `scroll` move that clock.
2. Once you anchor a step, anchor every later step. An unanchored step after an anchor group runs
   early, at the old sequence time; `blocking.diagnostics` warns about it.
3. Steps in one group run back to back in order. The dry `run` plan shows when each one starts.
   For typing at a human pace, write `type "git status" over 1.2s`, not one step per letter.
4. `#` starts a comment only outside quotes.
5. Back-to-back steps keep 0.15 s apart (`agent.policy pace=`).

Coordinates:

- Fix the window frame first and keep it for every take:
  `stagemasterctl window.frame app="Google Chrome" x=80 y=60 w=1280 h=800`. Any app works, and
  no Automation prompt appears. The reply's `frame` is what the window took; measure against it.
- Measure a web element's centre in screen points (Chrome, 100% zoom):

  ```
  osascript <<'EOF'
  tell application "Google Chrome" to execute front window's active tab javascript "(() => { const r = document.querySelector('.new-todo').getBoundingClientRect(); return Math.round(screenX + r.x + r.width / 2) + ',' + Math.round(screenY + outerHeight - innerHeight + r.y + r.height / 2); })()"
  EOF
  ```

- From a screenshot (`screencapture -x /tmp/s.png`), divide pixels by the display scale (2 on
  Retina).

### 5. Voice

Set it in the version file, or `stagemasterctl cast` then
`stagemasterctl cast.use character=british-men voice=bm_george`. Characters: `american-women`,
`american-men`, `british-women`, `british-men`, `spanish-women`, `spanish-men`, `french-women`,
`hindi-women`, `hindi-men`, `italian-women`, `italian-men`, `japanese-women`, `japanese-men`,
`brazilian-women`, `brazilian-men`, `mandarin-women`, `mandarin-men`. The voice must belong to the
character (`af_*` american-women, `am_*` american-men, `bf_*` british-women, `bm_*` british-men).
English picks: `af_heart`, `af_bella`, `am_michael`, `am_adam`, `bf_emma`, `bm_george`. No
character means every render refuses.

```
stagemasterctl render all=1
until stagemasterctl render.status --json | grep -q '"busy":false'; do sleep 2; done
stagemasterctl render.status        # each line "done", or failed with a reason
stagemasterctl lines                # measured durations, re-spaced starts
```

`render stale=1` voices lines that changed and lines that were never voiced.

### 6. Stage, then rehearse

Before every rehearsal and take: launch the target app, put its window at the measured frame,
keep it on screen (not minimized, current Space, not under StageMaster), reset its state, and tell
the user to keep hands off.

```
stagemasterctl blocking.diagnostics   # zero warnings
stagemasterctl run                    # dry: plan with times, touches nothing
stagemasterctl run rehearse=1         # performs live, films nothing; refuses without Accessibility
until stagemasterctl run.status --json | grep -q '"running":false'; do sleep 1; done
stagemasterctl run.status             # report: "performed all N steps"
```

### 7. Roll

```
stagemasterctl save
stagemasterctl shoot.info             # target and window are yours, canRoll true, preflight empty
stagemasterctl record                 # answers with target and window
until stagemasterctl record.status --json | grep -q '"rolling":false'; do sleep 1; done
stagemasterctl record.status          # report: "take <id> — complete, N.Ns"
```

A take is a 3 s countdown, a 1.5 s slate, the performance, and a 1.0 s tail. `record` fails at
once, with what to do, when the filmed window is not on screen, Accessibility is missing, the
keys could land in your own window, or the camera is blocked. A blocking with no steps to
perform needs `record duration=<seconds>`.

### 8. Inspect

```
stagemasterctl take.list              # status: complete, aborted or failed
stagemasterctl run.status             # failure: "stopped at step N: ... (line M)"
FF=/Applications/StageMaster.app/Contents/MacOS/ffmpeg
"$FF" -v error -y -ss 7.0 -i "$D/takes/<take-id>/take.mov" -frames:v 1 /tmp/sm-7s.png
```

Film time is about production time + 1.5 s. Look at a frame at each line's start: the screen
shows what the line says, clicks hit, the right window is filmed.

### 9. Fix and roll again

- Words: `stagemasterctl edit id=l4 field=text value="New words."`, then `render ids=l4`. A new
  line between two others: `stagemasterctl line.add text="..." after=l3`, then `render stale=1`.
- Steps: edit `$D/blocking.stage`, then `stagemasterctl blocking.set text="$(cat "$D/blocking.stage")"`.
  The draft keeps it; `stagemasterctl blocking.save` or `save` writes the file.
- A missed click: re-measure. Too early: anchor it or add `wait`. Too late: lower the `offset`.
- Reset the app, rehearse, roll again. The newest take is the one exported;
  `stagemasterctl export.take id=<id>` pins another.
- Files changed by hand: `stagemasterctl revert` (drops `draft.json`), then `open dir=` again. A
  draft based on the head silently wins over hand edits. The app does not watch files.

### 10. Export

```
stagemasterctl export                 # subtitles=1 burns the lines in
until stagemasterctl export.status --json | grep -q '"running":false'; do sleep 1; done
stagemasterctl export.status          # phase done, output <dir>/export/<id>.mp4
```

Lines without audio are skipped (`skipped` names them). Unlicensed release builds add a small
"Made with getstagemaster.com" mark bottom right.

### Pitfalls

- The filmed app is `capture.app`, else the first `app` step's app. Its largest on-screen window
  is filmed unless `capture.window` names one, chosen once after the countdown.
- Filming the terminal you run in: keys go to the filmed app's front window, which can be your
  own session. Calling from inside the filmed app, rehearse and record refuse until you open a
  separate window with a title, run `stagemasterctl capture window=<words in its title>`, and
  start the blocking with `app "Terminal"`, which raises that window. See the `git-in-thirty`
  example in the StageMaster agent kit.
- Terminal films: open the shipped profile, path in `stagemasterctl agent.status`
  (`terminalProfile`). It is dark, 20 pt SF Mono, marks off, and shows only the custom title.
  It runs `/tmp/stagemaster-film.command`, so write that first: `stty sane`, then
  `printf '\033]2;<title>\007'`, then
  `exec env -i HOME="$HOME" TERM="$TERM" PATH="$PATH" PS1='$ ' /bin/bash --noprofile --norc`.
  Each open adds one copy of the profile to Terminal's list. If prompt marks still show, use
  View > Hide Marks, and turn it back after the take.
- An unanswered Automation prompt for `js` appears mid-take. Trigger it in a rehearsal first.
- `open` uses the default browser and a new tab. In Chrome navigate with
  `js "location.href = 'https://...'"`.
- App state survives between takes. Reset it at the top of the script.
- Escape cuts only while StageMaster is frontmost. Use `stagemasterctl record.stop`.
- A step error aborts the take; aborted takes still export. Fix, rehearse, roll again.
- Context menus and popovers are separate windows and may be missing from the footage.
