# Wispframe — Agent Guide

Wispframe is a macOS screen recorder and video toolkit. **The running app is
the engine**: it holds the Screen Recording / camera / microphone permissions
and does all the work, so a terminal or an AI agent needs no permissions of its
own. You reach it two ways — a shell command (`wispframe`) and an MCP server
that the app itself serves over loopback HTTP.

Give this file to a coding agent and it can drive Wispframe end to end.

**Completeness:** sections 1–10 are the working guide. **Section 11 is the
playbook** — per command: when to use it, what each value does to the output,
the gotchas, and a worked example. **Section 12 is a machine-generated dump of
`--help` for all 26 commands, so every flag is in this file.** Section 10 lists
the few things that are GUI-only, so an agent knows not to attempt them.

---

## 1. Setup (once)

1. Install Wispframe from the Mac App Store and **launch it once** — grant
   Screen Recording when macOS asks.
2. In the app header click **⌘ CLI & MCP**. The panel gives you:
   - **Install CLI command…** → writes the `wispframe` command to a folder you
     pick (default `~/bin`; make sure it is on your `PATH`).
   - **The MCP config**, with the live port and token filled in. Two formats:
     a JSON block (Claude Desktop, Cursor, VS Code, Windsurf) and a one-line
     `claude mcp add` command.

No npm, no Node, no separate server process.

### MCP connection

```json
{
  "mcpServers": {
    "wispframe": {
      "type": "http",
      "url": "http://127.0.0.1:8763/mcp",
      "headers": { "Authorization": "Bearer <token from the app>" }
    }
  }
}
```

Or, for Claude Code:

```sh
claude mcp add --transport http wispframe \
  http://127.0.0.1:8763/mcp \
  --header "Authorization: Bearer <token from the app>"
```

The port is sticky (8763 by default) and the token persists across restarts, so
a saved config keeps working. Both live in
`~/Library/Application Support/recorder/bridge.env`.

---

## 2. Permission model — READ THIS BEFORE AUTOMATING

Commands are split in two:

| Kind | Examples | Behaviour |
|---|---|---|
| **Capture** (uses a granted permission) | `record`, `record-pip`, `record-camera`, `record-all`, `record-live-grid`, `screenshot`, `scroll-capture`, `probe`, `pick-color` | The app shows a dialog naming the exact command and **blocks until the user answers**: Deny / Allow once / Allow for this session / Always allow. Denied → exit code `77`. |
| **Processing** (files only) | everything else — `trim`, `speed`, `compose`, `compose-grid`, `subtitle`, `transcribe`, `enhance`, `studio`, `remove-bg`, `to-gif`, `burn-timer`, `annotate-*`, `run`, `license` | Runs immediately on the bearer token. |

Practical consequences for an agent:

- A capture call can take as long as the human takes to click. Do not set an
  aggressive timeout; 60 s is the app's own limit, after which it denies.
- If a capture returns exit `77` or "denied in the app", **do not retry in a
  loop** — tell the user to approve it (or to enable *Always allow* in
  ⌘ CLI & MCP).
- Every invocation appears in the app's Activity log with its outcome, and the
  header lights up green while you are working. Assume the user can see what
  you did.

---

## 3. CLI

```sh
wispframe <command> [options]
```

Output streams live; the exit code is the command's. Relative paths resolve
against **your** working directory. If the app is not running, the command
launches it and waits.

### Command inventory

| Command | What it does | Key flags |
|---|---|---|
| `list-sources` | JSON of displays, windows, apps | — |
| `list-cameras` | JSON of cameras (incl. iPhone Continuity) | — |
| `record` | Record a display / window / app to MP4 | `--display N` · `--window ID` · `--app BUNDLE_ID` · `--duration S` · `--fps N` · `--out FILE` · `--system-audio` · `--mic` · `--crop x,y,w,h` · `--record-cursor` · `--record-keys` · `--hide-cursor` · `--delay S` · `--codec h264\|hevc\|prores` |
| `record-pip` | Screen + webcam picture-in-picture | `--camera ID` · `--corner tl\|tr\|bl\|br` · `--scale 0..1` · `--shape circle\|rounded\|…` |
| `record-camera` | Camera ALONE to MP4 (no screen) — webcam, USB or iPhone Continuity | `--camera NAME\|ID` · `--duration S` · `--fps N` · `--delay S` · `--out FILE` |
| `record-all` | Every display at once | `--out-template '/path/shot-[n].mp4'` |
| `record-live-grid` | 2–9 live sources merged during capture | `--sources display:1,window:42` · `--layout` · `--canvas` · `--shape IDX:KIND` |
| `screenshot` | Still PNG | `--display N` · `--window ID` · `--region x,y,w,h` · `--full` · `--delay S` · `--clipboard` |
| `scroll-capture` | Stitch a long page into one PNG | `--region x,y,w,h` · `--max-frames` |
| `pick-color` | Sample a pixel's HEX | `--at x,y` |
| `trim` | Keep / cut ranges | `--keep A-B` (repeatable) · `--cut A-B` |
| `speed` | Global or per-segment retime | `--factor 0.25..4` · `--segment A-B:F` |
| `compose` | Overlay PiP, redact, burn text | `--overlay FILE` · `--rect x,y,w,h` · `--redact "x,y,w,h:blur\|solid[:S-E]"` · `--redact-faces all\|x,y;…` · `--text "TEXT:x,y[:SIZE[:#rrggbb]]"` · `--shape` |
| `compose-grid` | 2–9 videos into one scene | `--sources a.mp4,b.mp4` · `--layout sbs\|grid\|rows\|pip-*\|speaker` · `--place IDX:x,y,w,h` · `--opacity IDX:PCT` · `--audio IDX:GAIN` · `--offset IDX:S` · `--end IDX:S` · `--shape IDX:KIND` · `--layout-key T:LAYOUT[:TRANS[:DUR]]` |
| `studio` | Framed backdrop, rounded corners, shadow, cursor zoom | `--bg blur\|gradient:NAME\|color:#rrggbb\|image:PATH` · `--padding` · `--radius` · `--shadow` · `--canvas WxH` · `--zoom` · `--zoom-follow` · `--click-rings` · `--keys` |
| `transcribe` | Speech → caption file (on-device Whisper) | `--model` · `--lang` · `--words` |
| `subtitle` | Burn styled captions | `--captions FILE` · `--preset` (22 styles) · `--font-size` · `--position top\|middle\|bottom` · `--style JSON` · `--export` |
| `enhance` | 2×/4× on-device upscale | `--scale 2\|4` · `--max WxH` |
| `remove-bg` | Background removal | `--backend vision\|onnx` · `--bg green\|black\|white\|#rrggbb\|image:PATH` |
| `to-gif` | Clip → animated GIF | `--width` (≤4096) · `--fps` |
| `burn-timer` | Burn an elapsed-time clock | `--position top\|bottom\|bottom-left\|bottom-right` |
| `annotate-video` | Timed arrows / boxes / blur on video | `--spec FILE.json` |
| `annotate-image` | Same annotations on a still PNG | `--spec FILE.json` |
| `run` | Execute a settings JSON saved by the app | `<file.json>` · `--out` |
| `license` | Show the licence state | `--action status` |
| `probe` | Capture diagnostics (PTS per frame) | `--display` · `--duration` |

All commands take `--in` / `--out` unless noted. `--help` works per command:
`wispframe compose-grid --help`.

---

## 4. MCP tools

`tools/list` returns these (arguments in parentheses; `*` = required):

| Tool | Arguments |
|---|---|
| `list_sources` | — |
| `list_cameras` | — |
| `record_screen` | `out*`, `display`, `duration`, `fps` |
| `record_camera` | `out*`, `camera`, `duration`, `fps` |
| `screenshot` | `out*`, `display` |
| `scroll_capture` | `out*`, `region*`, `display` |
| `pick_color` | `at*`, `display` |
| `trim` | `in*`, `out*`, `keep*` (e.g. `"2-10"`) |
| `speed` | `in*`, `out*`, `factor*` |
| `to_gif` | `in*`, `out*`, `width`, `fps` |
| `subtitle_transcribe` | `in*`, `out*`, `model` |
| `subtitle_burn` | `in*`, `out*`, `captions*`, `preset` |
| `enhance` | `in*`, `out*`, `scale` |
| `studio` | `in*`, `out*`, `bg` |
| `remove_background` | `in*`, `out*`, `bg` |
| `burn_timer` | `in*`, `out*`, `position` |
| `annotate_image` | `in*`, `out*`, `spec*` |
| `annotate_video` | `in*`, `out*`, `spec*` |
| `compose` | `base*`, `out*`, `overlay`, `redact`, `redact_faces`, `blur_strength`, `text` |
| `compose_grid` | `out*`, `sources*`, `layout`, `width`, `height`, `audio` |
| `subtitle_export` | `captions*`, `out*` |
| `run_settings` | `path*`, `out` |

The tool result is the command's console output as text; `isError` is true when
the exit code is non-zero. **Use absolute paths in MCP calls** — an MCP tool
call has no shell working directory.

For anything not covered by a tool (e.g. `compose-grid` with keyframes), call
the CLI instead: it exposes the full surface.

---

## 5. Recipes

**Record a display and hand back an MP4**
```sh
wispframe record --display 1 --duration 20 --fps 60 --out demo.mp4
```

**Caption an existing clip end to end**
```sh
wispframe transcribe --in demo.mp4 --out demo.json --words
wispframe subtitle --in demo.mp4 --captions demo.json --out demo-sub.mp4 \
  --preset "Highlight Box" --font-size 4.6 --position bottom
```

**Talking-head layout: screen full-frame + camera bubble top-right**
```sh
wispframe compose-grid --sources screen.mp4,cam.mp4 --out talk.mp4 \
  --width 1920 --height 1080 \
  --place "0:0,0,1920,1080" --place "1:1585,40,290,290" \
  --shape 1:circle --audio 1:1.0
```

**Redact a region for a time window, and every face**
```sh
wispframe compose --base raw.mp4 --out safe.mp4 \
  --redact "195,948,390,82:blur:12-18" --redact-faces all:blur --blur-strength 30
```

**Share-ready GIF**
```sh
wispframe to-gif --in demo-sub.mp4 --out demo.gif --width 480 --fps 12
```

**Annotation spec** (`annotate-video` / `annotate-image`)
```json
[
  { "kind": "rect",  "a": [100, 100], "b": [600, 400], "start": 1, "end": 4 },
  { "kind": "arrow", "a": [700, 500], "b": [520, 330] },
  { "kind": "text",  "pos": [80, 380], "text": "Look here", "size": 42 },
  { "kind": "step",  "pos": [540, 120], "n": 1 },
  { "kind": "blur",  "a": [0, 0], "b": [300, 80] }
]
```
Kinds: `arrow`, `rect`, `ellipse`, `line`, `pen` (`points`), `highlight`,
`text`, `step`, `blur`. Geometry is `a`/`b` corner points except `text`/`step`,
which use `pos`. `start`/`end` are output seconds (ignored for images).

---

## 6. Behaviour an agent should expect

- **Free vs Pro.** Recording is unlimited on the free tier; *processed exports*
  carry a small watermark in the bottom-right corner. Pro removes it and is
  bought inside the app through the App Store. `wispframe license` reports the
  state. There is no CLI way to activate Pro — do not try.
- **Atomic outputs.** Every command that produces a video writes `<out>.part` and renames on success (the still-image verbs — `screenshot`, `scroll-capture`, `pick-color` — write straight to `--out`),
  so a failed or cancelled run never destroys an existing file.
- **Long jobs.** Encoding is real work: expect ~1–3× realtime for compose /
  subtitle / studio, and considerably more for `enhance`. Progress lines
  (`PROGRESS: done / total`) stream as the job runs.
- **Sizes are bounded.** GIF ≤ 4096 px per side; canvases ≤ ~35.6 MP (8K).
  Out-of-range values produce a clear error, not a crash.
- **Watchdog.** A capture that receives no frames within 6 s aborts with a
  message about a competing ScreenCaptureKit session, instead of hanging.
- **Exit codes.** `0` success · `77` denied by the user · `69` app not running
  and could not be launched · anything else = command error, message on stderr.

---

## 7. Troubleshooting

| Symptom | Cause / fix |
|---|---|
| `wispframe: the Wispframe app isn't running` | Launch Wispframe once; it holds the permissions. |
| Command hangs for a while, then "denied" | The user did not approve the capture dialog. Ask them, or suggest *Always allow*. |
| `401 unauthorized` on MCP | Stale token in your config — re-copy it from ⌘ CLI & MCP. |
| `no frames after 6s` | Another screen capture is running (or the source is occluded). Stop the other capture. |
| Output has a watermark | Free tier. Expected on processed exports. |
| MCP tool "file not found" | Use absolute paths in MCP calls. |


---

## 8. Value lists (what a flag accepts)

**Caption presets (`subtitle --preset`) — all 22:**
`Clean` · `Bold Outline` · `Karaoke` · `Word Pop` · `Pop Word` · `Pop Duo` ·
`Glow Pop` · `Highlight Box` · `Boxed Pop` · `Boxed Line` · `Aurora` ·
`Sunset` · `Ocean` · `Lower Third` · `Neon` · `Typewriter` · `Minimal Caps` ·
`Serif` · `Editorial` · `Script` · `Impact` · `Creator`

Names are exact and case-sensitive; an unknown one is rejected with the list.
Every field is overridable: `subtitle --dump-style style.json` writes the
resolved style (font, colour, outline, background, animation) — edit it, then
pass it back with `--style style.json`.

**Studio backdrop (`studio --bg`):** `blur` · `gradient:Dusk|Sunset|Mint|Slate|Ocean|Mono`
· `color:#rrggbb` (or `black|white|green`) · `image:PATH`

**Cell shapes (`--shape IDX:KIND`, `record-pip --shape`):**
`rect` · `rounded` · `circle` · `triangle` · `hexagon`

**Layouts (`--layout`):** `sbs` · `grid` · `rows` · `pip-br` · `pip-bl` ·
`pip-tr` · `pip-tl` · `speaker`

**Layout-keyframe transitions (`--layout-key T:LAYOUT:TRANS:DUR`):**
`cut` · `slide` · `ease` · `fade`

**Canvases (`--canvas`):** `1080p` · `1440p` · `4k` · `8k`, or `WxH`
(max ~35.6 megapixels)

**Codecs (`--codec`):** `h264` (compatible) · `hevc` (smaller) ·
`prores` (for editing, `.mov`)

**Background removal (`remove-bg`):** `--backend vision|onnx`,
`--bg green|black|white|#rrggbb|image:PATH`. The 15 bundled backdrops are
pickable in the app; the CLI takes any file via `image:PATH`.

**Transcription models (`transcribe --model`):** `large-v3-turbo` (default) ·
`large-v3` · `medium` · `small` · `base` · `qwen3-0.6b` · `qwen3-1.7b`

**Annotation kinds:** `arrow` · `rect` · `ellipse` · `line` · `pen` ·
`highlight` · `text` · `step` · `blur`

---

## 9. Saved sessions (`run`)

The app's **💾 Save settings** button writes a JSON file that `run` replays.
Six kinds execute headlessly, dispatched on the `tool` field:

| `tool` | What it does | Required fields |
|---|---|---|
| `compose` | Full composition (sources, placement, keyframes, shapes) | `files[]`, `opts[]`, `layout`, `canvas_w/h`, `show[]`, `custom_rects[]`, `rects_touched` |
| `trim` | Cutting | `input`, `duration`, `start`, `end`, `cuts[]` |
| `redact` | Region hiding | `input`, `regions[]`, `modes[]`, `ranges[]`, `blur` |
| `studio` | Framing | `input`, `image_path` |
| `subtitler` | Captions (transcribes first when there is no `transcript`) | `input`, `model`, `style?`, `transcript?` |
| `annotate` | Video annotation | `input`, `anns[]`, `ranges[]` |

```sh
wispframe run compose-settings.json            # with the saved output name
wispframe run trim-settings.json --out cut.mp4 # with an overridden output
```

Safety: the output name stored in the file **cannot escape** the working
directory — `../` segments and absolute paths are stripped down to the bare
filename. An explicit `--out` writes exactly where you point it.

---

## 10. What ONLY the GUI can do

Do not write automation for these — they have no headless equivalent:

- **Measure** (on-screen ruler) and **Pin image** (floating image) — windowed
  tools by their very nature.
- **Live grid with a camera** (`record-live-grid` takes display/window sources
  only).
- The live preview, the menu-bar controls, and the interactive parts of the
  editor windows — though their SAVED settings do run through `run`.

Everything else — recording, composition, captions, upscaling, redaction,
background removal, GIF, screenshots, annotation, timestamps — is reachable
from the CLI and mostly from MCP too.

---

## 11. Playbook — which setting to pick, and why

Section 12 lists every flag. This section is the judgement layer on top of it:
for each command, when to reach for it, what each meaningful value actually
does to the output, and the traps that make a first attempt fail. Everything
here was read out of the source, not inferred from the help text — where the
two disagree, the disagreement is called out.

### Capture

### `wispframe list-sources`

**What it does.** Snapshots `SCShareableContent` (excluding desktop windows) and prints every capturable display, window, and application as pretty JSON on stdout. It is the id lookup that every other capture verb depends on.

**Reach for it when.**
- You need a `--display`, `--window`, or `--app` value and have nothing but a human description ("the Chrome window", "the external monitor").
- You want to confirm the Screen Recording permission is actually granted before starting a long recording — this is the cheapest call that triggers and proves it.
- You are building a `record-live-grid --sources` or `record-all` plan and need each display's id, origin, and size.
- You need a window's own size so you can pass matching `--width/--height` to `record` (the CLI does not auto-size window targets the way the GUI does).

**Use something else when.**
- You want a camera id — `list-cameras` is a different device stack (AVFoundation, not ScreenCaptureKit) and cameras never appear here.
- You only want to verify frames actually flow from a display — `probe` does that end-to-end; `list-sources` only proves enumeration works.

**Choosing the values**

`list-sources` takes no flags. The judgement is which field of the output to feed forward:

| Flag | Pick | Why |
|---|---|---|
| `displays[].id` | The entry with `"is_primary": true` unless the user named another monitor | `is_primary` is computed as `origin_x == 0 && origin_y == 0` — the display anchoring the global desktop space. Ids are CoreGraphics `displayID`s, not 1..N indices, so never assume `--display 2` exists. |
| `displays[].width/height` | Treat as **points**, not encoder pixels | They come from `SCDisplay.width()/height()`. On a Retina panel the backing store is ~2×, so feeding these straight into `record --width/--height` gives you a half-resolution recording. |
| `windows[].id` | Match on `title` **and** `owning_app_bundle_id` together | The list is the full window list (not on-screen-only), so titles repeat across helper/panel windows. Bundle id disambiguates. |
| `windows[].width/height` | Use as the `record --width/--height` for that window | `record` passes your `--width/--height` to the encoder unchanged for every target type; mismatched values scale/letterbox the window. |
| `applications[].bundle_id` | Prefer over per-window ids for multi-window apps | `record --app <bundle_id>` captures all of that app's windows on `--display`; window ids die when the window closes, bundle ids do not. |

**Gotchas.**
- First call triggers the macOS TCC **Screen Recording** prompt. A denial surfaces as `AppError::PermissionDenied` (the completion handler returns a null content snapshot), not as an empty list.
- Blocking call — it waits on a condvar for the async `getShareableContentExcludingDesktopWindows` completion handler.
- `windows[].title` and `windows[].owning_app_bundle_id` are `null`-able; do not index on them blindly.
- The snapshot is taken at process start. Window ids go stale as soon as the user closes or reopens a window — re-run immediately before recording, not minutes earlier.
- Output is pretty-printed JSON with three top-level keys: `displays`, `windows`, `applications`. Nothing else is written to stdout, so it is safe to pipe into `jq`.
- Chromium/Electron windows appear here normally but often cannot be captured directly — see the display-crop notes under `record`.

**Worked example.**
```sh
# Resolve the primary display id and the VS Code window in one pass, so the
# follow-up record uses the window's OWN size instead of the 1920x1080 default.
wispframe list-sources | jq '{
  primary: (.displays[] | select(.is_primary) | .id),
  vscode:  (.windows[] | select(.owning_app_bundle_id == "com.microsoft.VSCode")
                       | {id, width, height})
}'
```

---

### `wispframe list-cameras`

**What it does.** Enumerates video capture devices via an `AVCaptureDeviceDiscoverySession` (built-in wide-angle, external/USB, Continuity Camera, Desk View) and prints them as JSON with `id`, `name`, and `kind`.

**Reach for it when.**
- You need the `--camera` value for `record-camera` or `record-pip`.
- You want to check whether the user's iPhone is currently in Continuity range before promising a webcam PiP.
- You are persisting a camera choice in config — `id` is `AVCaptureDevice.uniqueID`, stable across reconnects, app restarts, and reboots.
- You want to know if a camera exists at all without provoking the Camera permission prompt.

**Use something else when.**
- You want screen/window ids — that is `list-sources`.
- You want to confirm the camera actually delivers frames (permission granted, not held by another app) — only opening it does that, i.e. a short `record-camera` run.

**Choosing the values**

No flags. What to pick out of the output:

| Flag | Pick | Why |
|---|---|---|
| `cameras[].id` | Always pass this to `--camera`, never the name | It is the `uniqueID`; `--camera` resolves it with `AVCaptureDevice.deviceWithUniqueID`. Names are localized and duplicate across identical USB devices. |
| `cameras[].kind` | `Builtin` or `External` for a talking head; skip `DeskView` | `DeskView` is the synthetic overhead crop derived from a Continuity iPhone — it points at the desk, not the face. Picking it for a PiP bubble is almost always a mistake. |
| (omit `--camera`) | When any camera will do | Falls through to `AVCaptureDevice.defaultDeviceWithMediaType(video)`. Fewer moving parts than pinning an id that may unplug. |

**Gotchas.**
- Enumeration alone does **not** trigger the Camera TCC prompt — only opening a device (`AVCaptureDeviceInput`) does, i.e. `record-camera` / `record-pip`.
- An empty `"cameras": []` array is valid output and exits 0. It means no device is attached (or no iPhone in Continuity range), not an error.
- `kind` is best-effort. `Continuity` is only reported when the running bundle's Info.plist opts in via `NSCameraUseContinuityCameraDeviceType`; otherwise AVFoundation reports the iPhone under a coarser type (the device still enumerates and works). Do not branch behaviour on `kind == "Continuity"`.
- Virtual cameras (OBS, NoMachine) are indistinguishable from physical USB hardware — both surface as `External`.
- Ordering is stable run-to-run: devices come back sorted by the discovery session's device-type order (built-in, external, continuity, desk-view).

**Worked example.**
```sh
# Pick a real camera for a PiP, explicitly skipping the Desk View pseudo-device
# (which films the desk, not the presenter).
CAM=$(wispframe list-cameras \
  | jq -r '[.cameras[] | select(.kind != "DeskView")][0].id')
wispframe record-pip --display 1 --duration 60 --camera "$CAM" \
  --shape circle --scale 0.22 --corner br --mic --out demo.mp4
```

---

### `wispframe probe`

**What it does.** Opens a capture stream on one display for N seconds and prints each delivered frame's presentation timestamp, then a summary (elapsed, frames seen, frames expected, average fps). Nothing is encoded and no video file is written — it measures the capture leg only.

**Reach for it when.**
- A `record` run produced a black or empty file and you need to know whether the problem is capture or encode.
- You want to confirm the Screen Recording permission and ScreenCaptureKit actually deliver frames on this machine before scripting a long capture.
- You are measuring the CPU/RAM cost of a capture configuration for the benchmark record (`--bench`).
- You are checking whether a display genuinely reaches the requested fps at a given resolution.

**Use something else when.**
- You want an actual video file — `record`; probe writes nothing.
- You want to probe a window or an application — probe is display-only (`CaptureTarget::Display`); use `record --window` with a short `--duration`.
- You want per-thread CPU attribution during a real recording — `record --cpu-log` breaks it down by worker thread.

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--duration` | `3`–`5` for a smoke test, `30`–`60` with `--bench` | Every frame prints a line: 10s at the default 60fps is ~600 lines of stdout. Short runs stay readable; benchmark averages need enough 1Hz samples to be meaningful (the sampler ticks once per second). |
| `--fps` | Match whatever you will pass to `record` (`60` for UI/cursor work, `30` otherwise) | It sets `SCStreamConfiguration.minimumFrameInterval` to `1/fps`. Probing at 60 and recording at 30 tells you nothing about the recording you actually plan to make. |
| `--width` / `--height` | The display's real backing-pixel size, or the exact size you will record at | These go straight onto the stream configuration. Probing at 1920×1080 while recording at 3840×2160 understates the real GPU/scaler cost by 4×. |
| `--display` | An id from `list-sources`, not the default | The default is the literal value `1`, which is a plausible-looking `displayID` but is not guaranteed to exist on a given Mac. |
| `--bench <label>` | Set it only when you intend to commit a benchmark; use a short kebab-case label | The label is sanitized and timestamped into `memory/benchmarks/<label>-<ts>.{md,csv}` — a **relative** path, so it lands under whatever directory you invoked from. |

**Gotchas.**
- `--bench` writes to the relative path `memory/benchmarks/` (created if missing). Run from the repo root or you scatter reports into random directories.
- There is **no SIGINT handler**: Ctrl-C kills the process outright. Probe always sleeps for exactly `--duration` and then stops.
- There is **no watchdog** either (unlike `record`). A wedged capture prints nothing and still runs the full duration before reporting 0 frames.
- ScreenCaptureKit is change-driven: on a completely static screen, few or no frames are delivered. A low "avg fps" on an idle desktop is expected behaviour, not a fault — move a window while probing.
- Fixed config you cannot change: cursor is always shown, no audio is captured, no crop, and the recorder's own windows are excluded from the display.
- "frames expected" in the summary is simply `duration × fps` — an upper bound, not a target.
- The 1Hz sampler thread under `--bench` is a daemon: it is never joined, it just dies with the process.

**Worked example.**
```sh
# Is capture itself healthy at 4K60 on the primary display? 5s keeps the
# per-frame PTS log readable; the tail shows whether SCK really hit 60.
D=$(wispframe list-sources | jq -r '.displays[] | select(.is_primary) | .id')
wispframe probe --display "$D" --width 3840 --height 2160 --fps 60 --duration 5 \
  | tail -6
```

---

### `wispframe record`

**What it does.** The primary capture verb: streams one display, window, or application through the zero-copy IOSurface → VideoToolbox → AVAssetWriter pipeline and writes an H.264/HEVC MP4 or a ProRes MOV. Optionally captures system audio and/or the mic into a single AAC track, and can emit cursor/keystroke sidecars for later Studio passes.

**Reach for it when.**
- You want a single screen recording of a display, one window, or one app — this is the default answer.
- You need audio: system audio, the mic, or both mixed with independent gains.
- You are recording material you will later feed to `studio` auto-zoom (`--record-cursor`) or the keystroke overlay (`--record-keys`).
- You want a lossless editing master (`--codec prores`) or a small 4K file (`--codec hevc`).

**Use something else when.**
- You need a webcam bubble baked in — `record-pip` (it composites for you; `record` has no camera path).
- You want every monitor at once — `record-all` (one `record` per display would contend for ScreenCaptureKit).
- You want several sources in one frame — `record-live-grid` for a finished merged file, or `record` twice then `compose-grid` for lower CPU.

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--display` / `--window` / `--app` | One only; `--window` wins over `--app`, and both override `--display` | Precedence is resolved silently in `parse_target` — passing two does not error, it just ignores the loser. `--app` also reads `--display` (default `1`), because it captures that app's windows *on that display*. |
| `--fps` | `60` for cursor/UI/scroll demos, `30` for talking-head or long screen-shares | The target bitrate is `width × height × fps × 0.1` bits/s, so 60 literally doubles the bitrate and file size; the keyframe interval is `fps × 2`, i.e. a 2-second GOP either way. Static content gains nothing from 60. |
| `--width` / `--height` | The source's true backing-pixel size, aspect-matched | Unlike the GUI (which auto-sizes window/app targets to the source), the CLI passes these through unchanged for every target. A window recorded at the 1920×1080 default is scaled into that box. `list-sources` reports points — double for Retina. |
| `--codec` | `h264` to share, `hevc` at 4K/8K, `prores` only as an edit master | Container is chosen from the codec, not the filename: ProRes writes a QuickTime MOV, H.264/HEVC write MPEG-4. Name `--out` `.mov` for ProRes or you get a `.mp4` that is really a MOV. ProRes ignores the bitrate heuristic entirely (files are large). |
| `--crop` | `x,y,w,h` whose aspect matches `--width`/`--height` | It becomes the stream's `sourceRect`; the output is still exactly `--width × --height`, so a 16:9 output box with a 4:3 crop rescales. Only non-negative integers parse — floats and negatives are a hard error. |
| `--system-audio` / `--mic` | Both, when narrating over app audio | With both set they are mixed into **one** 48 kHz stereo AAC track (two audio tracks would break players' default-track selection). |
| `--system-audio-gain` / `--mic-gain` | `--system-audio-gain 0.3 --mic-gain 1.0` for narration over a noisy app | Range is validated to 0.0–2.0 (0 = mute, 1 = unchanged, 2 = +6 dB); out-of-range fails at parse time, before any capture starts. Ducking the machine here beats fixing it in post. |
| `--record-cursor` | On whenever `studio` auto-zoom is a possible next step | Writes `<out>.cursor.json`. **Whole-display targets only** — with `--window`/`--app` the recorder silently records nothing (no sidecar), because the tracker returns `None` for those targets. |
| `--record-keys` | Only when a keystroke overlay is actually wanted | Needs the Accessibility permission and captures every key pressed system-wide — privacy-sensitive, and unavailable in the sandboxed App Store build. |
| `--hide-cursor` | On for product-shot style captures with no pointer story | Default is cursor visible. Baked into the stream; there is no way to remove it afterwards. |
| `--delay` | `3`–`5` when a human must switch windows first | Prints a visible per-second countdown before capture opens, so nothing of the terminal leaks into frame 1. |
| `--via-display-crop` | Only for Chromium/Electron windows you already know record black | A plain `--window` already auto-detects this two ways (a blank-thumbnail pre-flight probe, then a 0-frames-after-start fallback). Forcing it skips the probe. Display-crop captures the on-screen region, so anything overlapping the window is recorded through it. |
| `--mask-bg` | `vision` for people, `onnx` for general objects — and only for short clips | It re-runs the whole file through background removal after capture and **overwrites** `--out`. The model is hardcoded to `u2netp` and the background to solid black here; use the standalone `remove-bg` if you need `--bg` or the full `u2net` model. |
| `--cpu-log` | When investigating a performance regression | Prints a per-second per-thread CPU breakdown plus a run average, including the share of all cores — attribution comes from OS thread names, so it stays truthful as the pipeline changes. |

**Gotchas.**
- **The output file must not already exist.** Live recording refuses up front with `output file already exists: <path>` rather than clobbering. Delete or use a fresh name. (A leftover `<out>.part` held open by another process is refused too, as a live-recording guard.)
- Crash-safety: frames go to `<out>.part` in 2-second movie fragments and are renamed onto `<out>` only on clean finish. A `.part` on disk always means "not finalized" — it is still playable up to the last fragment.
- **Watchdog:** if zero frames are encoded after 6 seconds, the session is torn down, `<out>` and `<out>.part` are deleted, and it errors with "another ScreenCaptureKit capture may be running, or the source is occluded/static". Two concurrent captures on one machine is the usual cause.
- Ctrl-C is a *graceful* stop, not a kill: the encoder drains, the muxer finalizes, and the partial recording is saved. `--duration` is polled at 50 ms granularity.
- Free tier does **not** cap recording length — `clamp_duration` is a no-op today and plain `record` output carries no watermark. But `--mask-bg` makes the output a *processed export*, which on Free gets the Wispframe watermark baked in.
- `--record-keys` still starts if Accessibility is not granted; it prints a warning and simply captures nothing. Empty cursor/key tracks write no sidecar file at all.
- After a successful run the command shells out to `ffprobe` (if present on PATH) and prints the stream summary. Absence is non-fatal and silent.
- Input-device selection is not exposed: the CLI always passes `mic_device_id: None`, so `--mic` records the system default input.
- `--mic` triggers the Microphone TCC prompt on first use; `--display`/`--window`/`--app` all trigger Screen Recording.

**Worked example.**
```sh
# 4K60 display capture with narration ducked over app audio, plus the cursor
# sidecar so `studio` can auto-zoom later. 5s countdown to get out of frame.
D=$(wispframe list-sources | jq -r '.displays[] | select(.is_primary) | .id')
wispframe record --display "$D" \
  --width 3840 --height 2160 --fps 60 --codec hevc \
  --duration 300 --delay 5 \
  --system-audio --system-audio-gain 0.3 --mic --mic-gain 1.0 \
  --record-cursor \
  --out ~/Recordings/demo-4k.mp4
```

---

### `wispframe record-pip`

**What it does.** Records a display and a webcam in parallel, then composites the webcam as a picture-in-picture bubble onto the screen recording, leaving one finished MP4. Capture and compose run in-process; the two intermediate files are cleaned up either way.

**Reach for it when.**
- You want the classic "screencast with a presenter bubble" in a single command, with no post step.
- You want a round webcam bubble (`--shape circle`) baked in.
- The presenter's voice must be in the file (`--mic`), optionally mixed with app audio (`--system-audio`).

**Use something else when.**
- You only need the camera — `record-camera` skips screen capture entirely and is far cheaper.
- You need control over screen fps, codec, crop, cursor visibility, or a countdown — `record-pip` exposes none of those; `record` + `compose --overlay` gives you all of them.
- You want the webcam as a full grid cell alongside several screens — `record-live-grid`.

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--scale` | `0.18`–`0.30`; the `0.3` default is already at the ceiling of "sharp" | The webcam session runs at the 640×480 VGA preset, so on a 1920-wide base `0.3` → 576 px wide, right at native. Larger values upscale VGA and look soft; a value that leaves no room for the margin is rejected before the compose stage runs. |
| `--corner` | `br` (default) for most screencasts, `bl` when the app's own UI lives bottom-right | Must be exactly `tl`, `tr`, `bl`, or `br`. Checked before capture starts, so a typo costs nothing. |
| `--margin` | `24`–`48` px on a 1920 base | Pixels of the base frame, not a fraction — the same 24 that reads as generous at 1280 wide is a hairline at 3840. |
| `--shape` | `circle` for a presenter bubble, `rect` for a screen-in-screen | Applies an alpha mask to the overlay cell. Valid values: `rect`, `rounded`, `circle`, `triangle`, `hexagon`. Validated up front alongside `--corner`. |
| `--width` / `--height` | Match the display's real aspect; keep 1920×1080 unless you need more | This is the base frame the bubble is placed into, and every `--margin`/`--rect`-style number is in these pixels. |
| `--camera` | An id from `list-cameras`, or omit for the system default | A stale id (unplugged device, iPhone out of Continuity range) does **not** fail — it silently falls back to the default and logs a `tracing` warning you will only see with `RUST_LOG=warn`. |
| `--mic` | On for any presenter recording | The webcam's own microphone is never recorded: overlay audio is always discarded by the compose step. Audio is captured into the *screen* leg and passed through. |
| `--duration` | Slightly longer than you need | The compose stage runs after capture, so the wall-clock cost is capture time plus a full re-encode pass. |

**Gotchas.**
- Screen capture is hardcoded to **30 fps** and cursor-visible; the webcam thread polls the latest frame at ~30 Hz and stamps synthetic PTS (`frame_idx / 30`). There is no `--fps`, `--codec`, `--crop`, `--hide-cursor`, or `--delay`.
- The webcam is opened at the **640×480 VGA preset** — deliberate, since the bubble is small and the session is cheap. Plan `--scale` around that, not around the camera's spec sheet. (`record-camera` asks for the device's best preset instead, because there the camera is the whole picture.)
- Two temp files are used: `<out>.screen.mp4` and `<out>.cam.mp4`. Both are deleted before the run (stale leftovers would fail AVAssetWriter) and after it, on success *and* on error.
- Unlike `record`, an existing `--out` is fine: compose writes `<out>.part` and renames only on success, so a failed run never destroys the previous file.
- If the camera produces no frame within **10 seconds**, the run aborts with a Camera-permission hint — and the camera thread cancels the shared stop flag, so the screen capture stops immediately instead of running out the clock.
- Ctrl-C stops both captures early but **still composes**: "stop" means "stop capturing, then compose". The compose stage uses a fresh cancel flag and always runs to completion.
- The output is a *processed export*: on the Free tier the compose stage bakes a discreet Wispframe watermark into the bottom-right corner. Recording itself is unlimited.
- With `--system-audio` and `--mic` both set they mix into one track, exactly as in `record`, but `record-pip` exposes no gain flags — both run at 1.0.

**Worked example.**
```sh
# Presenter screencast: small circular bubble bottom-right, voice only
# (no app audio), explicit camera so a Desk View device can't be picked.
CAM=$(wispframe list-cameras | jq -r '[.cameras[] | select(.kind != "DeskView")][0].id')
wispframe record-pip --display 1 --duration 120 \
  --width 1920 --height 1080 \
  --camera "$CAM" --shape circle --scale 0.2 --corner br --margin 40 \
  --mic \
  --out ~/Recordings/walkthrough.mp4
```

---

### `wispframe record-camera`

**What it does.** Records a single camera — built-in webcam, USB device, or an iPhone over Continuity Camera — straight to an H.264 MP4. No screen capture, no compositing, no audio.

**Reach for it when.**
- You need a talking-head clip to merge later with `compose-grid` or `compose --overlay`.
- You want to verify a camera actually works (permission granted, device not held by another app) before committing to a `record-pip` session.
- You want the presenter track as a separate file so you can reposition or reshape it in post rather than baking a bubble in.

**Use something else when.**
- You want screen + webcam in one file — `record-pip` (this command never touches the screen).
- You need the presenter's audio — `record-camera` writes a silent video; record narration through `record --mic` or capture the two separately.
- You want the camera small in the corner of a screencast — `record-pip` keeps the cheap VGA preset for that.

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--camera` | An explicit `uniqueID` from `list-cameras` for anything scripted | Omitting it takes `defaultDeviceWithMediaType(video)`, which changes as devices come and go. Unlike `record-pip`, an unresolvable id here also just falls back to the default (with a `tracing` warning), so a typo yields the wrong camera rather than an error. |
| `--fps` | `30` for talking heads (the default); `60` only for motion | Silently clamped to `1..=60` — `--fps 120` becomes 60 with no message. The target bitrate scales linearly with fps, and a VGA talking head gains nothing above 30. |
| `--duration` | The full take plus slack; stop early with Ctrl-C | The loop compares whole elapsed **seconds**, and PTS is synthetic (`frame_idx / fps`) rather than the camera's own timestamps. Over-provision and cut later rather than clipping the tail. |
| `--delay` | `3`–`5` for an unattended start | Prints a per-second countdown before the camera is even opened, so the subject has time to settle. |

**Gotchas.**
- **The resolution comes from the device, not from a flag.** The session asks for `AVCaptureSessionPresetHigh` and the encoder is configured from the first frame's dimensions — a Continuity iPhone gives 1920×1080 (1920×1440 on the Desk View lens), a built-in FaceTime camera gives whatever it tops out at. A device that cannot honour the preset keeps its own default rather than failing. There is still no way to *request* a specific size.
- **No audio track at all** — there is no `--mic`/`--system-audio` flag and none is wired in.
- **H.264 / MP4 only** — no `--codec`.
- Writes to `<out>.part` and renames onto `--out` on success, deleting the part file on failure. So unlike `record`, an existing output *is* overwritten (on success only).
- If the camera yields no frame within **10 seconds**, it fails with `SourceUnavailable` and a pointer to System Settings → Privacy & Security → Camera. This is the prompt-triggering call, so expect the TCC dialog on first use.
- Ctrl-C is a graceful stop: the encoder is drained, the muxer finalized, and the partial clip renamed into place.
- `latest_frame` returns the most recent frame on every poll without consuming it — a camera slower than `--fps` produces duplicated frames rather than a sped-up clip.
- The output is a pure capture, so no Free-tier watermark is applied; recording length is not capped either.

**Worked example.**
```sh
# Separate presenter track to be composited later, so the bubble's size,
# shape and position stay editable instead of baked in by record-pip.
CAM=$(wispframe list-cameras | jq -r '.cameras[0].id')
wispframe record-camera --camera "$CAM" --fps 30 --duration 180 --delay 3 \
  --out ~/Recordings/presenter.mp4

# then, after recording the screen separately:
wispframe compose --base screen.mp4 --overlay ~/Recordings/presenter.mp4 \
  --shape circle --scale 0.22 --corner br --out final.mp4
```

---

### `wispframe record-all`

**What it does.** Starts one independent recording session per connected display, all running concurrently, and writes each to its own file derived from a path template. Every file gets the same optional audio.

**Reach for it when.**
- The user works across two or three monitors and you cannot know in advance where the interesting thing happens.
- You are capturing a bug repro that may span displays and want to pick the right file afterwards.
- You want per-display files you will later arrange yourself with `compose-grid`.

**Use something else when.**
- You know which display matters — `record` costs a fraction of the CPU and gives you fps, codec, crop, and cursor-sidecar control that `record-all` does not expose.
- You want the displays merged into one frame as they are recorded — `record-live-grid`.
- You need a window or app rather than whole displays — `record` (`record-all` is display-only).

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--out-template` | Must contain `[n]` or `{n}`, e.g. `/tmp/multi-[n].mp4` | The placeholder is replaced with the **display id**, not a 0..N index — expect `multi-1.mp4` and `multi-3.mp4`, not `-0`/`-1`. Without a placeholder every session targets the same path and the second one fails with a "held open by a running process (live recording?)" refusal. |
| `--fps` | Leave at `30` unless you are certain of the machine | The default is deliberately half of `record`'s 60: you are running N hardware encoders at once, each at its display's full native size. Raising it multiplies across every monitor. |
| `--hide-cursor` | On when the cursor only exists on one display anyway | Applies to *every* recording uniformly; there is no per-display control. |
| `--system-audio` / `--mic` | Enable at most for a two-monitor setup you will trim later | The same machine audio is captured into **every** file. On three displays you get three copies of the identical track — fine for picking a winner, wasteful otherwise. |
| `--system-audio-gain` / `--mic-gain` | `0.3`/`1.0` for narration over app audio, same as `record` | Validated to 0.0–2.0 up front; both sources mix into one AAC track per file. |
| `--duration` | Generous — stop early with Ctrl-C | All sessions stop together; there is no per-display duration. |

**Gotchas.**
- **No resolution control.** Each display is captured at its own reported size (from the ScreenCaptureKit display list, i.e. points), and there is no `--width`/`--height` flag. Retina displays therefore record at logical, not native, resolution.
- **No codec control** — every file is H.264 MP4.
- Every output path inherits `record`'s "output file already exists" refusal, and each writes crash-safe 2-second fragments into `<path>.part` before renaming.
- **Watchdog is all-or-nothing:** it only aborts if *every* display has produced zero frames after 6 seconds. One dead display among several will not trip it — check the printed byte sizes at the end.
- A `stop()` failure on one display prints an error and moves on; the other files are still finalized. Read the per-display "wrote <path> (N bytes)" lines to spot a zero-byte casualty.
- Errors out with `no displays found` if the display list is empty.
- Ctrl-C stops all sessions gracefully and each partial file is finalized.
- No cursor or keystroke sidecars, no crop, no `--delay`, no `--mask-bg` — those exist only on `record`.

**Worked example.**
```sh
# Capture every monitor for a cross-display bug repro, mic on for a running
# commentary; [n] expands to each display's id so nothing collides.
wispframe record-all --duration 180 --fps 30 \
  --mic --mic-gain 1.0 \
  --out-template ~/Recordings/repro-[n].mp4

# afterwards: keep the one that actually shows the bug
ls -S ~/Recordings/repro-*.mp4
```

---

### `wispframe record-live-grid`

**What it does.** Captures 2–9 live sources (displays and/or windows) and composites them into a single MP4 *during* recording, so the merged file is finished the moment capture stops. The live counterpart of `compose-grid`, with no offline merge step.

**Reach for it when.**
- You need the merged file immediately — a live demo hand-off, or an agent loop with no room for a second pass.
- You are recording a "main screen + reference window" layout and want it pinned as one artifact.
- You want a webcam-style circular cell on a live source (`--shape 1:circle`) without a post step.

**Use something else when.**
- CPU headroom matters, or you have many sources at 4K60 — record separately (`record` / `record-all`) then `compose-grid`. Live merge is explicitly documented as the more expensive path, and offline compose gives you offsets, opacity, free placement, and timed layout keyframes that this verb does not.
- You want per-source audio, mic capture, or a camera cell — the CLI wires none of those into live grid.
- You only have one source — `record`.

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--sources` | 2–4 in practice; `display:1,window:42` (or the short `d1,w42`) | Each id is resolved against the live source list at start, so a wrong id fails fast with `no display <id>` / `no window <id>` rather than recording black. Fewer than 2 or more than 9 is a hard error. Every extra source adds a full GPU→CPU frame readback per output tick. |
| `--layout` | `sbs` for two, `grid` for 3–4, `speaker` when one source dominates, `pip-br` for a small inset | Exact lowercase strings only: `sbs\|grid\|rows\|pip-br\|pip-bl\|pip-tr\|pip-tl\|speaker`. Cells aspect-fit their source, so a mismatched layout wastes canvas as black bars rather than cropping. |
| `--canvas` | `1080p` for two 16:9 sources; `1440p`/`4k` only if text must stay readable after the shrink | Accepts `1080p\|1440p\|4k\|8k` or explicit `WxH`. Each source is scaled *down* into its cell, so a 4K display in a half-width 1080p cell loses most of its detail — raise the canvas rather than the source resolution. |
| `--fps` | `30` (default); go to `60` only for a single-screen-plus-inset motion demo | Silently clamped to `1..=240`. The output runs on a synthetic constant-rate clock: when compositing falls behind wall-clock, `frame_idx` jumps forward and output frames are *dropped* to keep audio in sync. Asking for 60 on a loaded machine buys dropped frames, not smoothness. |
| `--codec` | `h264` to share; `hevc` for large canvases; `prores` only as an edit master (and name `--out` `.mov`) | Container follows the codec, not the extension — ProRes writes QuickTime MOV. |
| `--shape` | `IDX:KIND`, 0-based into `--sources`, e.g. `--shape 1:circle` | Applied live. Index ≥ source count errors; unknown kind errors and lists the valid set (`rect`, `rounded`, `circle`, `triangle`, `hexagon`). |
| `--system-audio` | On when the merged file needs machine audio | One global track at fixed gain 1.0 — there is no `--mic`, no per-source gain, and no gain flag at all on this verb. |
| `--seconds` | Note the name: it is `--seconds`, not `--duration` | The only capture verb that spells it differently; `--duration` is rejected by clap. |

**Gotchas.**
- **Not zero-copy, by design.** Every source frame is read back GPU→CPU each output tick so the compositor can arrange them, then the canvas is re-uploaded to the encoder. This is why it is opt-in and why record-then-`compose-grid` remains the recommendation for many sources or 4K60.
- Source sizes come from the live source list (points), and each source is captured at its own size then aspect-fit into its cell — uncovered cell area stays opaque black.
- **No cameras from the CLI.** The live-grid engine supports camera sources, but this subcommand always passes `camera_id: None`; only `display:`/`d` and `window:`/`w` tokens parse.
- Writes `<out>.part` and renames on clean finish, so an existing output is safely replaced.
- **Watchdog:** zero composited frames after 6 seconds tears everything down, deletes `<out>` and `<out>.part`, and errors. A canvas of `0` in either dimension is rejected at parse time.
- If ScreenCaptureKit kills one source mid-run (window closed, display unplugged), it prints `⚠ source <idx> stopped by ScreenCaptureKit: <msg>` and stops the whole recording early — but still finalizes the file.
- Ctrl-C stops and finalizes. The final line reports frames encoded and effective fps — read it: an effective fps well under `--fps` means the machine could not keep up and frames were dropped.
- Output is a live capture, so no Free-tier watermark is applied.

**Worked example.**
```sh
# Main display plus a reference window, side by side, merged live so the file
# is done the instant capture stops. 1440p keeps the text in each half legible.
D=$(wispframe list-sources | jq -r '.displays[] | select(.is_primary) | .id')
W=$(wispframe list-sources \
     | jq -r '.windows[] | select(.owning_app_bundle_id == "com.apple.Safari") | .id' \
     | head -1)
wispframe record-live-grid \
  --sources "display:$D,window:$W" \
  --layout sbs --canvas 1440p --fps 30 --codec hevc \
  --system-audio --seconds 120 \
  --out ~/Recordings/side-by-side.mp4
```


### Stills and annotation

### `wispframe screenshot`

**What it does.** Captures one still frame — a whole display, one window, or a sub-rectangle of a display — and writes it as a PNG, optionally putting it on the clipboard as well.

**Reach for it when.**
- You need a deterministic "before/after" image of an app you just changed, from a script with no GUI interaction.
- You want a single window at its native size without cropping the desktop around it (`--window <id>` from `list-sources`).
- You need a fixed sub-rect of the screen (a toolbar, a chart, a terminal pane) captured repeatedly at identical coordinates.
- You want the image on the clipboard to paste straight into a chat/issue tracker (`--clipboard`).

**Use something else when.**
- The content is taller than the screen and must be scrolled — `scroll-capture` stitches the scrolled frames into one tall PNG; `screenshot` only ever grabs what is on screen.
- You only need one pixel's colour — `pick-color` prints HEX/RGB directly instead of making you decode a PNG.
- The thing you're documenting moves — record video and use `annotate-video`/`burn-timer`.

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--window <id>` vs `--region x,y,w,h` | `--window` whenever the target is a single app window | Window capture goes through `SCScreenshotManager` at the window's own size, so it survives the window being moved or partially covered; a region is a crop of the whole-display grab and silently captures whatever happens to be at those coordinates. `--region` and `--window` are declared mutually exclusive in clap — passing both is a usage error. |
| `--region` coordinates | Take one full-display shot first, read the `(WxH)` it prints, and derive the rect from that | The rect is in the display's **native backing pixels**, not points. `list-sources` reports display geometry from SCDisplay's point-space frame, so on a Retina display those numbers are half the pixel numbers and a rect derived from them lands in the wrong place. Out-of-bounds rects are clamped to the display (`crop_rgba`), so a wrong rect fails quietly with a smaller image, not an error. |
| `--display <id>` | `list-sources` → `displays[].id`; leave at `1` only for a single-monitor Mac | The default is literally `1`; a bad id makes the CGImage grab return NULL and you get `SourceUnavailable`, which is indistinguishable from a missing permission. |
| `--delay <secs>` | `2`–`3` when you must trigger a hover/menu state by hand; `0` in automation | The countdown is a plain sleep in the CLI process (one `⏱ starting in N…` line per second) before any capture happens — it buys you time to open a menu that would close if you switched to the terminal. |
| `--clipboard` | Add it when a human will paste the result | Non-fatal: a clipboard failure prints to stderr and the command still exits 0 with the PNG written. |

**Gotchas.** Precedence in `dispatch` is `--window` → `--region` → whole display; `--full` is accepted but never read (it is the default behaviour), so `--full --region ...` still captures the region. Output is always PNG bytes regardless of the `--out` extension. First run triggers the Screen Recording TCC prompt — until it is granted, every capture path returns `SourceUnavailable` with a message that lists both causes. Window capture establishes an `NSApplication` connection first (headless processes otherwise trip `CGS_REQUIRE_INIT`). A `--region` whose clamped size is empty yields `None` → the same `SourceUnavailable` error. On success it prints one line: `Screenshot: <what> → <out> (WxH)` — parse the `WxH` if you need the real pixel size.

**Worked example.**
```sh
# Window shot: ids come from list-sources, and a window grab is stable even if
# the user moves the window between runs — unlike a fixed --region.
wispframe list-sources | jq '.windows[] | select(.title | test("Safari"))'
wispframe screenshot --window 41253 --out /tmp/safari.png --clipboard
```

---

### `wispframe scroll-capture`

**What it does.** Repeatedly grabs a region while posting synthetic scroll-wheel events into it, aligns each new grab against the previous one, and appends only the genuinely new rows — producing one tall PNG of a page far longer than the screen.

**Reach for it when.**
- You need the whole of a long web page, chat log, changelog, or document as a single image.
- A full-page screenshot is needed from an app with no "export as image" feature.
- You want a full-length diff artefact of a scrolling view before/after a change.

**Use something else when.**
- Everything fits on screen — `screenshot --region` is one grab with no cursor warping and no Accessibility dependency.
- The content animates or auto-scrolls itself — the stitcher's overlap matcher assumes the only thing moving is the scroll offset; record video instead.

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--region x,y,w,h` | The **scrollable viewport only** — exclude sticky headers, sidebars, and the window chrome | The synthetic wheel event is posted at the region's centre, so the centre must sit inside the scroll view or the page never moves. A sticky header inside the region repeats identically in every grab and confuses the overlap search. |
| `--step-frac` | Leave at `0.66`; drop to `0.4`–`0.5` for pages with large uniform blocks | It is the fraction of the region height scrolled per step; the remainder is the overlap band the matcher locks onto. Too large a step leaves too little repeated content to align and rows get double-appended or lost. The value is clamped to `0.1..=0.95`. |
| `--max-frames` | Raise to `150`–`300` for a genuinely long page; keep `60` for anything ordinary | It is a safety cap, not a target — the run normally ends on bottom-detection. Hitting the cap is reported as `MaxFrames` and the printed line says `STOPPED EARLY … page may be truncated`. Values are floored at 1. |
| `--clipboard` | Skip for very tall results | The whole stitched image is PNG-encoded a second time for the pasteboard; a 20 000 px canvas is not a cheap paste. |

**Gotchas.** It **warps the mouse cursor** onto the region centre before each scroll (`CGWarpMouseCursorPosition`) — this is a foreground, interactive operation, not something to run behind a user's back. Without the Accessibility permission the command still runs and prints a warning; the grabs succeed but the page may never move, and you get a tall image of the same content. Screen Recording is separately required for the grabs themselves. Stopping is heuristic: two consecutive appends of fewer than 4 new rows ⇒ `BottomReached`; the canvas is otherwise hard-capped at 20 000 px (`MaxHeight`) or `--max-frames`. The settle delay after each scroll is a fixed 140 ms and is not exposed on the CLI, so an app that repaints slower than that will feed the matcher stale frames. If the region's grab size changes mid-run (window resized) the loop stops early with whatever was stitched. Output width always equals the region width. Progress prints `PROGRESS: frame N — H px tall` per grab.

**Worked example.**
```sh
# Region is the article column only (no sticky nav), max-frames raised because a
# changelog page is long; 60 would truncate it and say so.
wispframe scroll-capture --display 1 --region 400,200,2000,1600 \
  --max-frames 200 --out /tmp/changelog-full.png
```

---

### `wispframe pick-color`

**What it does.** Grabs the display and prints the colour of one pixel as `#RRGGBB  rgb(r, g, b)`, optionally copying the HEX to the clipboard.

**Reach for it when.**
- You need to check a rendered colour against a design token without eyeballing a screenshot.
- You're asserting in a script that a status indicator actually turned green.
- You want the exact background colour of an app to match it in generated assets.

**Use something else when.**
- You need more than one pixel, or want to inspect the sample later — `screenshot --region` gives you an image you can measure repeatedly and offline.

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--at x,y` | Native backing-pixel coordinates, derived from a full-display `screenshot`'s printed `WxH` | The sample is read from the full-resolution grab; a point outside it is a hard error (`pixel (x,y) is outside the WxH display`). Both components parse as `u32`, so negatives and decimals are rejected at parse time. |
| `--display` | From `list-sources`; matters as soon as a second monitor exists | Coordinates are display-local, not global desktop space — the same `x,y` names a different pixel on each display. |
| `--clipboard` | On for human hand-off | Copies only the HEX form (`#RRGGBB`), not the `rgb()` string; failure is non-fatal and only warns on stderr. |

**Gotchas.** Every invocation captures the entire display and then reads one pixel — sampling N points costs N full grabs, so for a palette sweep take one `screenshot` and read the PNG instead. Alpha is discarded (only R, G, B are returned). Requires Screen Recording; without it the grab returns nothing and you get `SourceUnavailable` naming both the invalid-display and missing-permission cases. Output HEX is uppercase.

**Worked example.**
```sh
# Verify the build banner really renders the brand red. Coordinates come from the
# WxH that a prior full-display screenshot printed, not from list-sources points.
wispframe pick-color --display 1 --at 1720,84   # → #FF3B30  rgb(255, 59, 48)
```

---

### `wispframe annotate-image`

**What it does.** Rasterises a JSON list of annotations (arrows, boxes, ellipses, lines, freehand, highlight, text, numbered steps, blur) onto a still image and writes a PNG. Same spec format as `annotate-video`, with all timing ignored — every annotation in the file is drawn.

**Reach for it when.**
- You want to point at the thing you changed in a screenshot inside a PR or bug report, reproducibly and from code.
- You're generating a numbered walkthrough ("1 → 2 → 3") over a UI screenshot.
- You need to redact a token, email, or customer name before sharing (`blur`).

**Use something else when.**
- The subject is a video — `annotate-video` takes the same spec and honours `start`/`end` per annotation.
- All you want is an elapsed clock on a clip — `burn-timer` does exactly that with no spec file.

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--in` | The full-resolution original, not a downscaled copy | Spec coordinates are in the input image's own pixel space, so re-scaling the input silently moves every annotation. Input is decoded by the `image` crate (PNG/JPEG). |
| `--out` | Name it `.png` | The encoder is hard-wired to PNG (`ImageFormat::Png`) whatever the extension says — a `.jpg` name would produce a PNG file with a lying extension. |
| `--spec` | Author it as a file; see the reference below | Read via `std::fs::read_to_string`, so it must be a real path; a missing file errors with `read <path>: …` before any image work. |

**Gotchas.** Written atomically through `<out>.part`, which is removed on failure, so a failed run never truncates a pre-existing output. Annotations are drawn strictly in list order onto one buffer — a `blur` listed after an arrow blurs the arrow too, so put redactions first. Colour blending honours the alpha byte and the destination is kept opaque (a translucent stroke never punches a hole). Unlike the video ops, this path applies the free-tier watermark, same as its video twin. `start`/`end` in the spec are parsed and then ignored. Missing required geometry is a precise error naming the index and field, e.g. `annotation 2 (text) needs \`pos\``.

**Worked example.**
```sh
# Redact first, then point at the button: list order is paint order, so the blur
# can't smear the arrow that comes after it.
cat > /tmp/spec.json <<'JSON'
[
  {"kind":"blur","a":[120,300],"b":[640,340],"radius":18},
  {"kind":"rect","a":[900,420],"b":[1180,500],"color":[255,59,48,255],"width":6},
  {"kind":"arrow","a":[700,600],"b":[900,470]},
  {"kind":"step","pos":[880,460],"n":1,"size":56}
]
JSON
wispframe annotate-image --in /tmp/safari.png --out /tmp/safari-annotated.png --spec /tmp/spec.json
```

**Spec file reference**

The spec is a **JSON array** of objects (a bare object is rejected). Each object selects a shape with `kind` and supplies that kind's geometry. Unknown keys are ignored (no `deny_unknown_fields`); an unknown `kind` is a hard error that lists the valid set.

Coordinate space: **frame/image pixels, top-left origin**, floats allowed. For `annotate-image` that is the input image's pixel size; for `annotate-video` it is the first decoded frame's `width`×`height`.

| Field | Type | Applies to | Default | Meaning |
|---|---|---|---|---|
| `kind` | string | all — **required** | — | One of `arrow`, `rect` (alias `box`), `ellipse`, `line`, `pen`, `highlight`, `text`, `step`, `blur`. |
| `a` | `[x, y]` | arrow, rect, ellipse, line, highlight, blur — **required** | — | First point. For rect/ellipse/highlight/blur, one corner of the box (normalised, so order doesn't matter); for arrow/line, the tail. |
| `b` | `[x, y]` | same as `a` — **required** | — | Second point / opposite corner. For `arrow` this is the **tip** (the head is drawn here). |
| `points` | `[[x, y], …]` | pen — **required** | — | Freehand polyline. A single point renders as a filled dot of radius `width/2`. |
| `pos` | `[x, y]` | text, step — **required** | — | `text`: **top-left** of the rendered string. `step`: **centre** of the badge. |
| `text` | string | text — **required** | — | The label. Rasterised as one CoreText line — no wrapping, no multi-line; use one annotation per line. |
| `n` | integer | step | `1` | The number shown in the badge. |
| `color` | `[r, g, b, a]` (0–255) | arrow, rect, ellipse, line, pen, highlight, text, step | `[255, 59, 48, 255]` (system red) | Straight-alpha stroke/fill colour. `highlight` uses the alpha directly for its marker blend (e.g. `[255,214,10,90]`). Ignored by `blur`. |
| `width` | number | arrow, rect, ellipse, line, pen | `6.0` | Stroke width in pixels. For `arrow` it also sizes the head: `max(width × 3.5, 10)` px. |
| `size` | number | text, step | `48.0` | `text`: glyph size in px. `step`: badge **diameter** — radius is `max(size/2, 6)` and the numeral is auto-sized to `radius × 1.15`. |
| `radius` | integer | blur | `12` | Box-blur radius in px; `0` is floored to `1` (a zero radius would divide by zero). Bigger = less recoverable. |
| `start` | number (seconds) | video only | `0.0` | Inclusive first second the annotation is visible. Negative values clamp to `0`. |
| `end` | number or `null` | video only | `null` | Exclusive last second. `null`/omitted = visible to the end of the clip. An `end` ≤ `start` collapses to open-ended rather than making the annotation invisible. |

Notes an author needs: the `step` numeral colour is chosen automatically for contrast (dark `[20,20,20]` on a light fill when luminance > 140, otherwise white) — you only pick the badge fill. Annotations are composited in array order. `annotate-image` ignores `start`/`end` entirely.

Complete, valid spec:

```json
[
  {
    "kind": "blur",
    "a": [80, 640], "b": [560, 686],
    "radius": 20,
    "start": 0.0, "end": null
  },
  {
    "kind": "highlight",
    "a": [96, 220], "b": [880, 268],
    "color": [255, 214, 10, 90],
    "start": 1.5, "end": 6.0
  },
  {
    "kind": "rect",
    "a": [900, 180], "b": [1260, 300],
    "color": [48, 209, 88, 255], "width": 8,
    "start": 2.0, "end": 6.0
  },
  {
    "kind": "ellipse",
    "a": [1300, 420], "b": [1560, 560],
    "color": [255, 59, 48, 255], "width": 6,
    "start": 6.0, "end": 9.0
  },
  {
    "kind": "arrow",
    "a": [640, 760], "b": [1000, 320],
    "color": [255, 59, 48, 255], "width": 10,
    "start": 2.0, "end": 6.0
  },
  {
    "kind": "line",
    "a": [100, 900], "b": [1800, 900],
    "color": [0, 122, 255, 255], "width": 4,
    "start": 0.0, "end": 3.0
  },
  {
    "kind": "pen",
    "points": [[300, 500], [340, 540], [420, 500], [500, 560]],
    "color": [175, 82, 222, 255], "width": 5,
    "start": 3.0, "end": 8.0
  },
  {
    "kind": "step",
    "pos": [940, 240], "n": 1,
    "color": [0, 122, 255, 255], "size": 64,
    "start": 2.0, "end": 6.0
  },
  {
    "kind": "text",
    "pos": [960, 330], "text": "Click Deploy",
    "size": 52, "color": [255, 255, 255, 255],
    "start": 2.0, "end": 6.0
  }
]
```

---

### `wispframe annotate-video`

**What it does.** Bakes the same annotation set into a video, drawing each annotation only on the frames whose time falls inside its `[start, end)` range, and re-encodes to H.264 MP4 with the audio re-timed 1:1.

**Reach for it when.**
- You're turning a screen recording into a walkthrough where callouts appear as the action reaches them.
- You need a permanent redaction over a region for part of a clip (a token visible only between 4 s and 9 s).
- You want the annotations burned in for platforms that strip overlays or subtitle tracks.

**Use something else when.**
- The source is a still — `annotate-image` skips the whole decode/encode round trip and the watermark path.
- You only want an elapsed clock — `burn-timer` needs no spec and no coordinates.

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--in` | The already-trimmed/sped-up clip, i.e. annotate **last** | `start`/`end` are times on the **output** timeline, which equals the input's — so trimming after annotating shifts every callout out of sync, while trimming first lets you read times straight off the final clip. |
| `--out` | A new path, never the input | The op decodes and re-encodes into `<out>.part` and renames on success; pointing it at the input is a self-overwrite. |
| `--spec` timings | Give each annotation a real `end` unless it should genuinely persist; ranges may overlap freely | `start` is inclusive (with a 1e-6 epsilon so a range starting exactly on a frame tick lights that frame), `end` is exclusive. Omitted `end` = to the end of the clip. Overlapping annotations simply all draw. |
| `--spec` coordinates | Sample them from a `screenshot` of the same source resolution, or a frame of the video | Coordinates are in decoded-frame pixels — the recording's own resolution, which on a Retina capture is the backing-pixel size, not the window's point size. |

**Gotchas.** Output is always **H.264** at the source's nominal fps (rounded, clamped to 1–240; 30 fps if the source reports nothing usable), on a synthetic constant-frame-rate clock — VFR sources are resampled by PTS, which is deliberate (raw VFR timestamps make VideoToolbox fail at `finishWriting`). Audio, if present, is decoded to PCM and re-encoded as AAC at the mixer's rate; a file with no audio track produces a video-only output. On a **Free** license this op stamps the `⌘ Wispframe` watermark bottom-right at 55 % opacity into every frame and tags the output with a provenance marker — it re-marks unconditionally, so chaining processed exports does not avoid it; Pro removes both. Sources under 64 px in either dimension get no mark. The whole file is re-encoded, so cost scales with duration × resolution, not with the number of annotations; progress is `PROGRESS: done / total` frames. Invalid spec JSON, an unknown `kind`, or missing geometry all fail before any decoding starts. On failure the `.part` file is deleted.

**Worked example.**
```sh
# Trim first, then annotate — spec times are read off the trimmed clip's timeline.
wispframe trim --in raw.mp4 --out demo.mp4 --keep 12-38
wispframe annotate-video --in demo.mp4 --out demo-annotated.mp4 --spec /tmp/spec.json
```

**Spec file reference**

Identical to `annotate-image`'s spec (see the table and example above) — the same `parse_annotation_spec` in `crates/cli/src/lib.rs` serves both. The only difference is that here `start` and `end` are honoured:

| Field | Type | Default | Meaning |
|---|---|---|---|
| `start` | number (seconds) | `0.0` | Inclusive start on the output timeline; negatives clamp to `0`. |
| `end` | number or `null` | `null` | Exclusive end; `null`/omitted means "to the end of the clip"; an `end` ≤ `start` is coerced to open-ended, not to an empty range. |

All geometry fields, defaults (`color` `[255,59,48,255]`, `width` `6.0`, `size` `48.0`, `radius` `12`, `n` `1`), coordinate space (frame pixels, top-left origin), paint order (array order), and the nine kinds (`arrow`, `rect`/`box`, `ellipse`, `line`, `pen`, `highlight`, `text`, `step`, `blur`) are exactly as documented in the `annotate-image` **Spec file reference**, and the JSON example there is a valid `annotate-video` spec as written.

---

### `wispframe burn-timer`

**What it does.** Re-encodes a video with a running `MM:SS` (or `H:MM:SS` past an hour) pill burned into a chosen corner, counting from `00:00` at the clip's own frame times.

**Reach for it when.**
- You're submitting a bug/perf video where a reviewer must cite "at 01:12 the UI stalls".
- You need timing evidence that survives re-upload, screen-recording of the recording, or a player without a timeline.
- You're stitching a long session and want a visible clock that matches the clip's real duration.

**Use something else when.**
- You need a caption or arbitrary label rather than a clock — `annotate-video` with a `text` annotation lets you choose the string and the time band.
- You want the timer only during part of the clip — `burn-timer` draws on every frame; there is no time range.

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--position` | `bottom-right` (default) for most clips; `top` when the app's own status bar or a dock sits along the bottom | The pill is placed with a margin of `clamp(height × 0.03, 8, 48)` px from the edge, so it will overlap whatever content lives in that corner — pick the quiet corner. Only `top`, `bottom`, `bottom-left`, `bottom-right` are accepted; anything else is rejected up front with the valid list (the CLI validates before the silent `from_key` fallback to bottom-right can apply). |
| `--in` | The final-length clip — burn the timer **last** | The clock is the output frame's own time, so trimming or speed-ramping afterwards produces a clip whose burned numbers no longer match its playback position. |
| `--out` | A new `.mp4` path | Encoded into `<out>.part` and renamed on success; the `.part` is deleted on failure so an existing output survives a crash. |

**Gotchas.** Font size is derived, not configurable: `clamp(height × 0.032, 16, 72)` px, white text on the standard pill — on a 720 p clip that's ~23 px. The label is re-rasterised only once per distinct second and cached, so cost is dominated by the re-encode, not the drawing. Same pipeline constraints as `annotate-video`: H.264 output on a synthetic CFR clock at the source's nominal fps (30 if unreadable), audio re-encoded to AAC, whole file re-encoded. A source with no readable duration or no frames is an error before any work. Note this op applies the free-tier watermark like every other processed export, unlike `annotate-video`. Elapsed formatting clamps non-finite/negative times to `00:00`.

**Worked example.**
```sh
# Last step in the chain: trim to the interesting window, then burn the clock so
# the numbers a reviewer quotes match the clip they're watching.
wispframe trim --in session.mp4 --out repro.mp4 --keep 40-95
wispframe burn-timer --in repro.mp4 --out repro-timed.mp4 --position bottom-right
```


### Edit and export

### `wispframe trim`

**What it does.** Keeps only the time ranges you name and concatenates them into one shorter clip — the gaps are dropped and everything after a gap slides earlier. Video is fully decoded and re-encoded to H.264 on a synthetic constant-frame-rate clock; audio is decoded to PCM, re-timed onto that clock, and re-encoded to AAC.

**Reach for it when.**
- You want dead air, mistakes, or a slow intro gone from a finished recording.
- You need frame-exact cut points — the output timeline is a synthetic CFR grid, not keyframe-snapped, so cuts land where you asked.
- You want the cuts baked into a single file that every downstream tool can read.

**Use something else when.**
- You need a lossless/stream-copy cut. `trim` always re-encodes (decode → BGRA → VideoToolbox H.264), so it costs a generation of quality and a full decode pass.
- You want to *reorder* segments. Ranges are sorted ascending before use; you cannot put 8-10 before 2-5.
- You only want to speed through a boring stretch rather than delete it — use `speed --segment`.

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--keep A-B` | When the good parts are few (a 2-minute highlight out of 20 minutes) | Repeatable; ranges are sorted and merged, so overlapping/touching `--keep` values collapse instead of erroring |
| `--cut A-B` | When the bad parts are few (three coughs in a clean take) | Repeatable; the kept output is the complement over `[0, duration]`. Also merged first, so overlapping cuts are fine |
| range syntax | `2-5`, `2.5-8.25` | Split on the **first** `-`; both sides are non-negative finite seconds, `end` must be strictly `> start`. Whitespace is trimmed |
| neither flag | — | Hard error: "give at least one --keep A-B or --cut A-B range" |

**Gotchas.**
- `--keep` and `--cut` are mutually exclusive at the clap level; you cannot mix them in one run. Chain two `trim` calls if you need both idioms.
- Ranges are clamped to the source duration and any range that clamps to empty is dropped. If *no* keep range survives the clamp → error. If the cuts remove the whole clip → error.
- Overlap is legal and silently merged (unlike `speed`, where overlapping segments are a hard error). `2-5` plus `4-9` becomes `2-9`.
- Output is always H.264 at the source's nominal fps (falling back to 30 when the track reports no clean rate), regardless of the input codec. A ProRes `.mov` in gives an H.264 `.mp4` out.
- Audio splices are hard cuts: chunks landing inside a removed gap are dropped and the rest are re-timed at unit gain. There is no crossfade, so cutting mid-word gives an audible click.
- A source with no audio track produces a video-only file (the muxer is built without an audio track rather than with an empty one).
- Written atomically via `<out>.part`, renamed onto `--out` only on success — a failed or cancelled run never destroys a pre-existing output.

**Worked example.**
```sh
# Two good demo takes out of a 12-minute session, in one pass.
# --keep beats --cut here: naming 2 keepers is shorter than naming the 3 gaps,
# and the ranges concatenate seamlessly into a ~3-minute clip.
recorder trim --in session.mp4 --out demo.mp4 \
  --keep 95-210 \
  --keep 402-468
```

---

### `wispframe speed`

**What it does.** Re-times a clip — one global factor for the whole thing, or per-segment ramps ("play 10–20 s at 4×"). Video is resampled onto a CFR output clock by inverting the time-warp map: speeding up samples the same source span across fewer output ticks (frames dropped), slowing down holds each source frame across more ticks.

**Reach for it when.**
- You want a whole clip tightened uniformly (`--factor 1.5`).
- You want to blast through a build/install/typing stretch while keeping the rest at real time (`--segment`).
- You want a slow-motion beat on one action without touching the rest of the timeline.

**Use something else when.**
- You need more than 4× or slower than 0.25× — the factor is validated against a hard `[0.25, 4.0]` range and anything outside errors out. Chain two `speed` passes for 16×.
- You need pitch-corrected fast audio. There is no time-stretch/phase vocoder: your only choices are pitch-shifted or silent.
- You want to delete the boring part outright — `trim --cut` is cheaper and shorter.

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--factor F` | `1.25`–`2.0` for tightening a talk-through; `0.5` for a slow-mo review | Applies to the entire clip. Output duration is `duration / F`. Range `0.25`–`4.0` inclusive |
| `--segment A-B:F` | `--segment 40-95:4` for an npm install | Repeatable; source-second ranges. Uncovered regions (and the head/tail) stay 1× |
| `--audio pitch` | Global speed-ups where you still want to hear the voice | Linear-interpolation resample onto the output clock — pitch shifts (chipmunk up, deep down). Default when `--factor` is set |
| `--audio mute` | Ramps in a narrated tutorial | Audio survives verbatim in the 1× regions and is dropped to silence in every non-1× segment. Default when `--segment` is used. Aliases: `silence`, `drop` |

**Gotchas.**
- Segments **must not overlap** — this is a hard error ("speed segments overlap: …"), unlike `trim`, which merges overlapping ranges. Touching segments (`0-4:2`, `4-8:0.5`) are fine.
- Segments compose by concatenation, not multiplication: the warp compiles into contiguous spans over `[0, duration]` where each span's output length is `src_len / factor`, and 1× filler spans are inserted for every uncovered gap. Total output = the sum. Two segments never stack their factors on the same instant, because overlap is rejected.
- `--factor` and `--segment` are mutually exclusive; supplying neither errors.
- `--factor 2 --audio mute` yields a file with **no audio track at all** — mute mode only keeps 1× regions, a global factor leaves none, and the pipeline omits the track rather than writing an empty one (an empty track crashes `finishWriting`).
- Slowdowns duplicate frames rather than interpolating them: `0.25×` holds each source frame across ~4 output ticks at the source's nominal fps. Expect judder, not smooth slow motion.
- Segment ranges are clamped to the clip; segments entirely past the end are silently dropped.
- Output is H.264 on a CFR clock at the source's nominal fps (fallback 30), atomic via `<out>.part`.

**Worked example.**
```sh
# Tutorial: real-time narration, but rip through the 55-second dependency install.
# --segment (not --factor) because only that stretch should move;
# audio defaults to "mute", so the install is silent and the narration stays intact.
recorder speed --in tutorial.mp4 --out tutorial-tight.mp4 \
  --segment 62-117:4
```

---

### `wispframe to-gif`

**What it does.** Downsamples the frame rate, nearest-neighbour resizes to a target width, and encodes an infinitely-looping animated GIF with the pure-Rust `gif` crate (no ffmpeg). Height is derived from the source aspect ratio; audio is inherently gone.

**Reach for it when.**
- You need a sub-15-second clip to autoplay inline in a GitHub issue, PR, README, or Slack message.
- The destination refuses video but renders GIF.

**Use something else when.**
- The clip is longer than ~15 s or full-screen — GIF size grows linearly with frame count and quadratically with width, and 256 colours per frame wrecks gradients and subpixel text. Ship the MP4.
- You need audio, seeking, or a scrub bar.

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--width 640` (default) | Terminal/editor demos in a README | 640×360 at 16:9 = 230,400 px/frame. This is the sweet spot where code is still legible |
| `--width 480` | Chat/Slack, or when the file is too big | 480×270 = 129,600 px/frame — **44% of the pixels of 640** for the same duration. Halving width quarters the bytes per frame |
| `--width 960`+ | Only for UI motion where detail matters more than size | 960×540 = 518,400 px/frame, 2.25× the default |
| `--fps 15` (default) | Almost everything | `frame_skip = round(src_fps / fps)`, where `src_fps` is the input's real rate, and `delay = round(100/15) = 7` centiseconds per frame. A 10 s clip → 150 GIF frames whatever the source rate |
| `--fps 10` | Size-constrained, mostly-static screen content | 100 frames for the same 10 s — one third fewer frames than 15fps |
| `--fps 24`–`30` | Cursor-motion-heavy demos | `round(30/24) = 1`, so *every* source frame is written; size roughly doubles vs. 15fps |

**Gotchas.**
- The frame-skip divisor comes from the container's own frame rate, falling back to 30 only when the track reports nothing usable — so a 60fps source at `--fps 15` yields a correctly-timed GIF. (Before this was fixed the source rate was assumed to be 30, and a 60fps input played back at half speed with double the frames.)
- Hard size guard: `--width` and the derived height must both be in **1–4096**. 16:9 tops out at `--width 4096` (→ 2304 high); a 9:16 portrait clip fails above `--width 2304` because the height crosses 4096. The error names the limit and tells you to pick a smaller `--width`.
- Palette: each frame is quantized to its own local ≤256-colour palette via `gif::Frame::from_rgba`. There is no global palette, no dithering knob, and no exposed palette flags — heavy gradients will band and shimmer frame to frame.
- Downscaling is **nearest-neighbour**, not area-averaged. Small `--width` values alias thin UI lines and small text badly; drop the resolution of the recording instead of relying on the GIF resize.
- Alpha is forced opaque and the loop is set to infinite; neither is configurable.
- Progress is counted in *input* frames consumed, not GIF frames written. Atomic via `<out>.part`.

**Worked example.**
```sh
# 8-second CLI demo for a README, from a 30fps recording.
# 640 keeps monospace text readable; 15fps halves the frame count and the
# source really is 30fps, so the skip math and the frame delay agree.
recorder to-gif --in cli-demo.mp4 --out cli-demo.gif --width 640 --fps 15
```

---

### `wispframe enhance`

**What it does.** Super-resolves every frame on-device (Real-ESRGAN via ONNX Runtime + CoreML, or Apple's `VTSuperResolutionScaler`) and re-encodes at 3× the normal bitrate budget. The model is fetched from Hugging Face and cached on first run. The audio track is preserved (decoded → re-timed → AAC), not stream-copied.

**Reach for it when.**
- You recorded at a lower resolution than you now need to deliver (720p capture → 1440p deliverable).
- Old or downscaled footage needs detail and sharpening back before it goes on a marketing page.

**Use something else when.**
- The source was already captured at or above the delivery resolution — see the gotcha below; you are only burning time and adding artifacts.
- You need this in a fast iteration loop. Every frame goes through the model; nothing is skipped or cached across frames.

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--scale 2` (default) | 1080p → 2160p, or any "one step up" | Output dims = `src × scale`, rounded to even. The backend runs its native 4× and the pipeline bilinear-downsamples to your target, so 2× is 4× quality resampled down — visibly cleaner than 4× |
| `--scale 4` | Genuinely small sources (720p and below) | Native factor, no downsample step. On a 1080p source this asks for 7680×4320 — usually a mistake without `--max` |
| `--max WxH` | Whenever you have a hard delivery ceiling | Applied *after* `scale`: shrinks proportionally so both axes fit, aspect preserved, dims forced even. `--scale 4 --max 3840x2160` = "as much as the model can give, capped at 4K" |
| `--model general` (default) | Screencasts, UI, mixed content | Compact general-x4v3, fp16, ~5 MB, exposes the denoise input |
| `--model sharp` (aliases `photo`, `hq`) | Photographic/video footage where you want maximum detail | Full RRDBNet x4plus fp16 — a much larger download, ignores `--denoise` (that graph has no denoise input) |
| `--model apple` (aliases `native`, `vt`) | macOS 15.4+, whole-frame speed | Apple's `VTSuperResolutionScaler`: whole-frame passes up to a 1920 max edge (1536-px tiles with a 64-px halo above that), no ONNX tiling overhead |
| `--denoise 0.3` (default) | — | 0 = sharpest/noisiest, 1.0 = smoothest. Clamped to `[0,1]`. Only honoured by `general` |

**Gotchas.**
- **Upscaling is pointless when the source already has the pixels.** A native 4K screen recording upscaled to 8K, or a 1080p capture "enhanced" for a 1080p player, invents detail rather than recovering it — you pay the whole runtime and get softer edges plus tiling artifacts. Enhance only when the delivery resolution genuinely exceeds the capture resolution.
- **Runtime cost is per-tile, per-frame, and does not scale down.** The ONNX path feeds fixed 256×256 tiles with a 16-px halo, keeping each tile's inner 224-px core. That's `ceil(w/224) × ceil(h/224)` model invocations **per frame**: **45 per frame at 1080p, 180 per frame at 4K**. A 60-second 1080p30 clip is 1,800 frames × 45 = ~81,000 model runs. Plan for this to be the slowest command in the toolchain by a wide margin, and prefer `--model apple` when the whole-frame path is available.
- `--scale` is clamped to `[2, 4]` in the CLI, not restricted to the two values in the help text: `--scale 1` becomes 2, `--scale 9` becomes 4, and `--scale 3` is accepted and produces a genuine 3× output.
- First run downloads weights from Hugging Face into the `hf-hub` cache (`~/.cache/huggingface/hub`). No token required, but it needs network. `RECORDER_MODEL_DIR` overrides the lookup with a local directory; a copy next to the binary is also honoured.
- Output is H.264 on a CFR clock at the source's nominal fps (fallback 30). Atomic via `<out>.part`.

**Worked example.**
```sh
# A 1280x720 screencast that has to ship as a 4K hero video.
# --scale 4 (720p genuinely needs the full native factor) with --max as the
# ceiling, so the result lands on exactly 3840x2160 instead of 5120x2880.
recorder enhance --in screencast-720.mp4 --out screencast-4k.mp4 \
  --model general --scale 4 --max 3840x2160
```

---

### `wispframe remove-bg`

**What it does.** Decodes an MP4, masks the subject out of every frame, composites it onto a flat colour or a still image, and re-encodes. H.264 has no alpha channel, so there is always a backdrop — the "removal" is really a replacement.

**Reach for it when.**
- You have a webcam or talking-head clip and want a chroma-key-free green screen, brand colour, or custom backdrop.
- You need this on-device with no upload and no service.

**Use something else when.**
- You need a real alpha channel for compositing elsewhere — this pass cannot produce one.
- You want a webcam bubble over a screen recording: `compose --shape circle` places the overlay without touching pixels inside it.
- The subject is not a person and you cannot afford the slower backend.

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--backend vision` | Human subjects: webcam, talking head, presenter | `VNGeneratePersonSegmentationRequest` on the Neural Engine — **person-only**, no model download at all, synchronous per-frame mask in a few ms. Fastest option by far |
| `--backend onnx` (default) | Non-human subjects: products, objects, hands, pets | U²-Net general saliency segmentation. Requires a model download and is materially slower per frame |
| `--model u2net` (default, onnx only) | Best onnx quality | Full U²-Net, ~176 MB, downloaded from Hugging Face on first use |
| `--model u2netp` (onnx only) | Speed, or an offline/first-run-friendly box | U²-Net lite, ~4 MB, can ship bundled next to the binary. Slightly lower quality |
| `--bg green` | Output that goes into another editor for keying | Pure `[0,255,0]` |
| `--bg black` (default) / `--bg white` / `--bg "#rrggbb"` | Direct delivery in a brand colour | Hex must be exactly `#rrggbb`, 7 ASCII chars |
| `--bg image:/path/bg.jpg` | Anything that needs a real backdrop | Decoded once, cover-fit to the frame with **no distortion**, centred on the image's Apple Vision saliency focal point (centre if no salient region is found), then reused for every frame |

**Gotchas.**
- The input's audio is carried through (decoded to PCM, re-encoded as AAC), matching every other processing pass; a silent input produces a video-only file. Earlier builds dropped the track entirely — if you are on one of those, a narrated clip comes back mute.
- `--model` is ignored entirely with `--backend vision`; an unknown model name only errors on the onnx path. An unknown `--backend` errors immediately.
- The ONNX mask is computed at **320×320** and upscaled to full resolution, so fine edges (hair, glasses arms, thin cables) are soft no matter the source resolution. Vision's person segmentation runs at its own resolution with matting refinement and generally gives cleaner edges on people — try `vision` first for any human subject.
- `--bg` here does **not** accept the Studio syntax: no `gradient:NAME`, no `color:` prefix. It is a bare colour word/hex, or `image:PATH`.
- Unlike `trim`/`speed`/`studio`, this pass keeps each frame's original PTS (no CFR resample). The encoder's bitrate hint follows the source's nominal fps, falling back to 30.
- Atomic via `<out>.part`; the input is never modified.

**Worked example.**
```sh
# Presenter webcam clip onto a brand-colour card.
# --backend vision because the subject is a person: no 176 MB download,
# Neural-Engine speed, and cleaner hair edges than the 320x320 U^2-Net mask.
recorder remove-bg --in webcam.mp4 --out webcam-branded.mp4 \
  --backend vision --bg "#0f172a"
```

---

### `wispframe studio`

**What it does.** Turns a plain recording into the framed "Screen Studio" look: the video shrunk and aspect-fit into the centre of a padded canvas, over a background, with rounded corners and a soft drop shadow — plus optional cursor-tracked auto-zoom, click rings, and keystroke pills. Video and audio are both re-timed onto a CFR clock and re-encoded.

**Reach for it when.**
- A raw screen recording needs to look like a product demo before it goes on a landing page or into a launch video.
- You want automatic zoom-to-the-action and visible keyboard shortcuts in a tutorial without editing a timeline.

**Use something else when.**
- You need a webcam PiP or redactions on top — that's `compose`.
- Your source has no `.cursor.json` / `.keys.json` sidecar and you were only after the zoom/keys features: those flags become silent no-ops and you get plain framing.

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--bg blur` (default) | Any clip, zero decisions | A blurred, darkened, scaled-up copy of the video itself, rebuilt **per frame** — always colour-matched, but the most expensive background |
| `--bg gradient:NAME` | Branded, static, cheap | `Dusk`, `Sunset`, `Mint`, `Slate`, `Ocean`, `Mono` (case-insensitive). Rendered once and reused. Unknown names error and list the valid set |
| `--bg color:#rrggbb` | Exact brand colour | Also accepts `color:black`, `color:white`, `color:green`. Rendered once |
| `--bg image:PATH` | Wallpaper / marketing backdrop | Cover-scaled to the canvas, loaded once |
| `--padding 8` (default) | — | Percent of the **shorter canvas side**; clamped to `0..=30` internally, so `--padding 90` silently becomes 30. `0` = video fills the canvas and the background never shows |
| `--radius 16` (default) | — | Pixels, clamped to at most half the *framed video's* shorter side — so the radius is relative to the shrunk video, not the canvas |
| `--shadow 60` (default) | — | 0–100 (clap-enforced). `0` disables the shadow entirely. At 1080p short-side, strength 100 = 18 px down-offset, 40 px blur, 0.55 alpha; all three scale linearly with the canvas's shorter side |
| `--canvas WxH` | Delivering to a fixed frame (1920x1080, 1080x1920) | Defaults to the (cropped) source's own dimensions. The video is aspect-fit, never distorted, so a mismatched canvas aspect just shows more background |
| `--crop x,y,w,h` | Isolating one window/region from a full-display capture | Applied to source pixels **before** framing; the crop rect becomes the effective source for canvas, layout, and blur backdrop |
| `--zoom` + `--zoom-max 2.0` | Click-driven emphasis in a demo | Eases in on each click, holds ~1.2 s, eases out; idles at 1× in between. `--zoom-max` is clamped to `>= 1.0` |
| `--zoom-follow` | Fast cursor work where the action never sits still | Stays at full `--zoom-max` continuously and glides with the cursor instead of pulsing per click |
| `--click-rings true` (default) | — | Expanding white ring at each click. Explicit value required (`--click-rings false`) |
| `--keys` | Keyboard-shortcut tutorials | Fading key-combo pills (⇧⌘4 …) at the bottom-centre of the framed video, ~1.3 s each, up to 3 stacked, newest at the bottom |

**Gotchas.**
- **`--zoom`, `--zoom-follow`, and `--click-rings` all need `<in>.cursor.json`, and `--keys` needs `<in>.keys.json`** — sidecars written only when the recording was made with `record --record-cursor` / `record --record-keys`. Without the file, every one of these flags is a silent no-op: you get plain framing and no warning. `--click-rings` alone does nothing, because the cursor track is only read when `--zoom` is set.
- The sidecar is found by literal path concatenation: `<value of --in>.cursor.json`. If you rename or move the MP4, the sidecar must travel with it under the matching name.
- `record --record-cursor` **only works for whole-display targets** — `--window`, application, and window-via-display-crop captures return no recorder at all, so no sidecar is written.
- `record --record-keys` needs the **Accessibility** permission (System Settings ▸ Privacy & Security ▸ Accessibility) and is unavailable in the sandboxed MAS build. Without it you get a warning at record time and no `.keys.json`.
- **Do not trim or re-time before studio if you want zoom.** Cursor samples carry `t_ns` relative to *recording* start and `(x, y)` in the *recorded frame's* pixel space; a trimmed or speed-ramped file no longer matches either.
- `--crop` and `--zoom` compose: the cursor samples are shifted by the crop origin before the planner sees them, so zoom centres and click rings land where the cursor actually was. (Earlier builds skipped that shift and aimed the zoom at the wrong part of the picture, the more so the further the crop sat from the top-left.)
- `--canvas` is capped at 35,651,584 pixels (8K); above that (or with a zero side) it errors rather than allocating a ~40 GB frame buffer.
- `--bg` must be `blur` (the only bare word), or `kind:value` — a bare `gradient` or `#ff0000` errors out.
- Output duration matches the source; output is H.264 on a CFR clock at the source's nominal fps (fallback 30), audio decoded → re-timed → AAC. Atomic via `<out>.part`.

**Worked example.**
```sh
# Record a display with the cursor + key sidecars, then frame it for a launch page.
recorder record --display 1 --duration 60 --out raw.mp4 \
  --record-cursor --record-keys

# gradient (not blur) because a static preset is cheaper per frame and matches
# the brand; --zoom without --zoom-follow so the frame only pushes in on clicks;
# --keys works here only because raw.mp4.keys.json exists next to the input.
recorder studio --in raw.mp4 --out launch.mp4 \
  --bg gradient:Dusk --padding 10 --radius 20 --shadow 70 \
  --canvas 1920x1080 --zoom --zoom-max 1.8 --keys
```


### Composition

### `wispframe compose`

**What it does.** Bakes a picture-in-picture overlay video, static text/emoji labels, and/or redaction regions (box blur, solid black, tracked faces) onto a single base video and re-encodes it to H.264 MP4. One base, at most one overlay — the base's own timestamps and frame rate are preserved.

**Reach for it when.**
- You have exactly one background video and want a webcam/second clip pinned into a corner or an explicit rect.
- You need to hide something: a password field (`--redact …:solid`), a chat pane (`--redact …:blur`), or faces (`--redact-faces`).
- You need a burned-in label ("Demo 🎬") that never moves.
- You want the base's audio carried through untouched (bit-faithful passthrough, no re-encode).

**Use something else when.**
- You have 2+ videos that should share the canvas as equals, or you need per-source timing/opacity/shapes → `compose-grid`.
- You need the overlay to move, resize, or swap places mid-video → `compose-grid --layout-key`.
- You need audio from more than one file → `compose-grid --audio` (compose always discards the overlay's audio).

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--base` | required `.mp4` | Defines the output resolution (first decoded frame's `width`×`height`) and the output timeline; PTS pass through unchanged. |
| `--overlay` | optional | Omit for a redaction/text-only pass. Dispatch errors out unless at least one of `--overlay` / `--redact` / `--redact-faces` / `--text` is given. |
| `--scale` | `0.2`–`0.35` (default `0.3`) | Overlay width = `base_width * scale`; height derives from the overlay's own aspect. A value that leaves no room for `--margin` is a hard error, not a silent mis-placement. |
| `--corner` | `br` (default), `tl`, `tr`, `bl` | Anything else is a hard error (`unknown corner …; use tl\|tr\|bl\|br`). |
| `--margin` | `24` (default) | Inset in base pixels from the pinned corner. |
| `--rect X,Y,W,H` | when you need exact placement | Mutually exclusive with `--scale/--corner/--margin` (clap `conflicts_with`). The overlay is **stretched to fill** this rect (CoreGraphics `draw_image` scales to the destination rect) — match the overlay's aspect yourself or it distorts. Validated to fit inside the base frame. |
| `--shape` | `rect` (default), `rounded`, `circle`, `triangle`, `hexagon` | Alpha mask multiplied into the overlay frame, then drawn as `Overlay::Masked`. `circle` is inscribed in the **center square of the overlay frame**, so on a 16:9 webcam the bubble's diameter equals the frame height and the sides go transparent. |
| `--start-at` / `--end-at` | base-timeline seconds | Both must be finite, `0 ≤ start ≤ end ≤ 86400`. Default `end_at` = `start_at` + the overlay's own duration (one natural-speed pass). |
| `--on-end` | `hold` (default) or `stop` | Only matters when the overlay runs out of frames before `--end-at`. |
| `--redact` | `"x,y,w,h:blur\|solid[:S-E]"`, repeatable | `solid` = opaque black (passwords); `blur` = box blur. `:2.5-8` limits to a window, `:3-` runs to the end. Active test is `t + 1e-9 >= start && t < end`. |
| `--blur-strength` | `4`–`60`, default `20` | clap-enforced range. Applies to every `:blur` redaction *and* to blur-style `--redact-faces`. No effect on `solid`. |
| `--redact-faces` | `all[:blur\|solid]` or `x1,y1[;x2,y2;…][:blur\|solid]` | Apple Vision, on-device. Seed mode tracks each clicked face independently. Style defaults to `blur`. |
| `--text` | `"TEXT:x,y[:SIZE[:#rrggbb]]"`, repeatable | Parsed from the right, so the label may contain `:`. SIZE default 48, color default white. Rasterized once via CoreText, blitted 1:1 (no scaling). |
| `--keep-audio` / `--no-audio` | keep is the default (`default_value_t = true`) | Pass `--no-audio` to drop it. The overlay's audio is always ignored. |

**Gotchas.**
- **Draw order is fixed and privacy wins**: overlay video → `--text` labels → `--redact` rects → `--redact-faces` boxes → free-tier watermark. You cannot put text over a redaction.
- **The overlay must fit with its margin.** `overlay_width + margin > base_width` (or the same on the vertical axis) is rejected up front with a message naming the offending `--scale`. Before that guard existed the corner arithmetic `bw - ow - margin` wrapped on unsigned integers and the overlay landed off-canvas, silently invisible.
- **`--rect` stretches, corner-pin does not.** Corner mode preserves the overlay's aspect (`oh = ow * overlay_h / overlay_w`); `--rect` does not.
- **Rect validation is strict.** `--redact`, `--rect`, `--text` origins, and `--redact-faces` seeds must all lie inside the base frame (checked with `checked_add`); a spilling rect is an error, not a clip. A `--text` label whose *bitmap* extends past the edge clips gracefully — only its origin must be in-frame.
- **Timestamps are passed through, not resampled.** Unlike `compose-grid`, compose keeps the base's own (possibly VFR) PTS and encodes at `round(nominal_fps)` (0 → 30). Duration is preserved exactly.
- **The video is always re-encoded** to H.264 even when nothing is drawn; only audio is passthrough.
- **Atomic output**: frames go to `<out>.part`, renamed onto `--out` only on success. A stale `.part` from a crashed run is deleted first. AVAssetWriter refuses to overwrite, hence the temp file.
- **Face detection has an adaptive stride**: if the detector averages > 15 ms/frame over the first 10 runs, it drops to every 2nd frame and holds the previous boxes (a lost face holds ~15 frames, then reacquires).
- Free (non-Pro) licences composite a `⌘ Wispframe` label at 55% opacity in the bottom-right of every frame.

**Worked examples.**
```sh
# Webcam bubble bottom-right for the whole video, base audio kept (default).
# scale 0.25 + margin 24 is safely under the wrap threshold; circle masks the
# 16:9 cam into a bubble whose diameter = the cam frame's height.
recorder compose --base screen.mp4 --overlay cam.mp4 --out demo.mp4 \
  --corner br --scale 0.25 --margin 24 --shape circle

# Redaction-only pass (no --overlay): black out an API key for the whole clip,
# blur a chat pane only between 12s and 30s, and blur every face.
# --blur-strength 40 because 20 still left the chat text half-legible.
recorder compose --base screen.mp4 --out clean.mp4 \
  --redact "1420,80,480,44:solid" \
  --redact "0,600,520,480:blur:12-30" \
  --redact-faces all:blur --blur-strength 40

# Intro sting: overlay appears at 5s in an exact rect, ends at 12s, and the
# last frame holds if the sting is shorter. --rect stretches, so 640x360 was
# chosen to match the sting's 16:9 aspect exactly.
recorder compose --base talk.mp4 --overlay sting.mp4 --out talk-final.mp4 \
  --rect 1240,660,640,360 --start-at 5 --end-at 12 --on-end hold \
  --text "Chapter 2: Setup:80,60:64:#ffcc00"
```

---

### `wispframe compose-grid`

**What it does.** Arranges 2–9 videos onto one black canvas — preset layout, free `--place` rects, or a timed `--layout-key` track — and encodes the result as a constant-frame-rate H.264 MP4 with an optional mixed AAC audio track. This is the multi-source video mixer; `compose` is the single-base overlay tool.

**Reach for it when.**
- Two or more clips must be visible at once (comparison, reaction, grid).
- A source must appear late (`--offset`), disappear early (`--end`), be semi-transparent (`--opacity`), be shaped (`--shape`), or move mid-video (`--layout-key`).
- You need audio from several sources mixed with per-source gain.

**Use something else when.**
- One base + one PiP, no timing, no shape changes → `compose` (cheaper, keeps the base's audio bit-faithfully and its native timeline).
- You need blur/solid redaction or face tracking — `compose-grid` has no `--redact`; run `compose` before or after.
- You need burned-in text — also `compose` only.

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--sources a.mp4,b.mp4,…` | 2–9 comma-separated paths | Fewer or more is a hard error. Index order is everything: it drives layout slot, draw order (higher index on top), and every `IDX:` flag. |
| `--width` / `--height` | default `1920`/`1080` | `width*height` must be ≤ 35,651,584 px (H.264 level 6.2), checked before any allocation. Prefer a canvas no *larger* than your sources — see the no-upscale gotcha. |
| `--layout` | `sbs` \| `grid` \| `rows` \| `pip-br` \| `pip-bl` \| `pip-tr` \| `pip-tl` \| `speaker` (default `sbs`) | Exact geometry below. Anything else errors. `custom` is **not** a `--layout` value — it only exists inside `--layout-key`. |
| `--place IDX:X,Y,W,H` | when a preset can't express it | Switches that source to manual placement. Repeatable, duplicate index = error, rect must be ≥1×1 and fully inside the canvas (u64-checked, no wrap). |
| `--opacity IDX:PCT` | 1–100, default 100 | Alpha-blends that source over whatever is beneath. Duplicate index = error. Works in preset, `--place`, and keyframe modes. |
| `--shape IDX:KIND` | `rect`\|`rounded`\|`circle`\|`triangle`\|`hexagon` | Alpha mask applied to the *source frame* before compositing. No duplicate check — the last entry for an index wins. |
| `--offset IDX:SECONDS` | 0–3600 | Delays the source: black cell until then, its audio delayed identically. All-zero offsets ≡ no offsets. |
| `--end IDX:SECONDS` | `(0, 7200]` | Output-timeline out-point; the cell goes black and that source's audio mutes from then on. Clamped into `[offset, offset+duration]` — can shorten, never extend. |
| `--audio IDX:GAIN` | gain 0.0–2.0, repeatable | All selected tracks are decoded to PCM, gained, mixed and re-encoded to one 48 kHz stereo AAC track. Duplicate index = error; a source with no audio track is dropped with a `⚠` warning. No `--audio` at all → silent output. |
| `--audio-from N` | legacy alias for `--audio N:1.0` | Cannot be combined with `--audio` (explicit error). |
| `--layout-key T:LAYOUT[:TRANS[:DUR[:i=x,y,w,h;…\|show=i,j]]]` | for mid-video arrangement changes | `TRANS` ∈ `cut` (default) \| `slide` \| `ease` \| `fade`; `DUR` default 0.5s. Repeatable, order-independent (sorted internally). |

**Layout geometry (exact, at a 1920×1080 canvas).** Rects come from `recorder_core::layout_rects`; each source is then aspect-fit **inside** its cell via `fit_rect_no_upscale`, centered, with the uncovered cell area left canvas-black.

| `--layout` | Geometry |
|---|---|
| `sbs` | N equal full-height columns, `col_w = W/N`; the last column absorbs the remainder so columns tile W exactly. n=2 → `0,0,960,1080` + `960,0,960,1080`. n=3 → three 640-wide columns. |
| `rows` | N equal full-width rows, `row_h = H/N`, last row absorbs the remainder. n=2 → `0,0,1920,540` + `0,540,1920,540`. |
| `grid` | `cols = ceil(sqrt(n))`, `rows = ceil(n/cols)`, row-major, 8 px gutter. 2→2×1, 3–4→2×2, 5–6→3×2, 7–9→3×3. n=4 cells are 956×536 at (0,0),(964,0),(0,544),(964,544). n=9 cells are 634×354. n=3 leaves the bottom-right hole empty. |
| `pip-br` / `pip-bl` / `pip-tr` / `pip-tl` | Source 0 gets the **full canvas**; sources 1.. are fixed 16:9 tiles of `0.28*W` wide (537×302 at 1080p), 16 px margin, stacked from that corner inward. At 1080p 3 tiles fit per column, then they wrap into the next column toward the center. `pip-br` tile 1 sits at `1367,762`; `pip-tl` tile 1 at `16,16`. |
| `speaker` | Source 0 = left 70% (`1344×1080`); sources 1.. stack equally in the right `576`-wide column, last tile absorbs the remainder. n=4 → three 576×360 side tiles. |

Fit rules: preset/keyframe cells are **aspect-fit and never upscaled** (a source smaller than its cell sits at native size, centered, with black margin). `--place` rects are used **as given — stretched to fill, no letterboxing** (same semantics as `compose --rect`); an explicit rect is treated as intent. Unplaced sources keep their preset cell. Overlays are drawn in index order, so higher indices paint on top; only `pip-*` overlaps by design.

**Layout picker.**

| You want … | Use |
|---|---|
| Side-by-side comparison of two clips | `--layout sbs` (default). n=2 gives exact 960/960 halves. Add `--audio 0:1.0` to keep only the left track. |
| Talking head + screen share, screen dominant | `--layout speaker --sources screen.mp4,cam.mp4` — screen takes the left 70%, cam stacks right. Order matters: index 0 is the speaker slot. |
| Reaction bubble floating over a demo | `--layout pip-br` for the built-in 537×302 tile, **or** `--place 1:1440,720,420,420 --shape 1:circle` for an exact circular bubble. Use `--place` (not `pip-*`) when you need a specific size/position; add `--opacity 1:85` for a soft look. |
| 4-up grid | `--layout grid --sources a,b,c,d` → 2×2 of 956×536 cells with an 8 px gutter. |
| A clip that appears partway through | `--offset 1:8` (black cell until 8s, its audio delayed identically). Pair with `--end 1:20` to also drop it at 20s. The output automatically runs to the last source's end. |
| A mid-video layout change | `--layout-key 0:sbs --layout-key 12:speaker:ease:0.6`. **Always include a keyframe at `0`** — with any keyframe present the `--layout` flag no longer drives the picture. Use `fade` for a cross-dissolve, `slide`/`ease` to morph the cells, `cut` for an instant switch. |
| One source alone, full-frame, from some point on | `--layout-key 0:sbs --layout-key 10:sbs:cut:0:show=1` — hidden sources aren't drawn, and the preset is laid out for the visible subset only, so source 1 fills the canvas. |

**Gotchas.**
- **Any `--layout-key` overrides `--layout` for the whole video, and silently disables `--place`.** Before the first keyframe the *first keyframe's* cells apply (there is nothing earlier to morph from), so a lone `--layout-key 12:grid` makes the video a grid from t=0. Anchor the track with `0:<layout>`.
- **`custom` keyframes default unlisted sources to the full canvas.** `40:custom:slide:0.5:0=0,0,960,1080` leaves sources 1..n-1 with a full-canvas cell. List every source you care about.
- **Keyframe `custom` rects are aspect-fit, `--place` rects are stretched.** Same-looking coordinates, different result — the keyframe path always runs `fit_rect_no_upscale`.
- **No upscaling, ever, in cell mode.** Four 640×480 clips in a 1920×1080 `grid` render at native 640×480 inside 956×536 cells — small, with black margin. Either shrink the canvas (`--width 1280 --height 720`) or use `--place` to force the size.
- **Output length = `max(end)` over all sources**, where `end` = out-point if it trims, else `offset + duration`. It is *not* source 0's length (the help text's "keeps source 0's length" line is stale). A source that reaches EOS leaves its cell **black** for the remainder — it does not freeze on its last frame.
- **`--audio` never passes through.** Despite the help text, `GridAudioPlan` has only `Silent` and `Mix`: every selected track is decoded → gained → mixed → re-encoded as 48 kHz stereo AAC, because the video runs on a synthetic CFR clock that the source's own audio timestamps no longer match. Expect a generational loss even for `--audio 0:1.0`.
- **Constant frame rate, taken from source 0.** Output PTS are `frame_idx / fps` where `fps = decoders[0].nominal_fps()` (fallback 30, encoder clamps to 1–240). Other sources are resampled onto that clock by PTS: a slower source holds its frame, a faster one drops frames. Put the clip whose cadence should win at index 0. Mismatched frame rates are handled; N-way PTS *alignment* is not — sources are paired by their own timestamps, not synced to a common wall clock.
- **Audio muxing is slower.** With `--audio` the job uses the realtime muxer (the AAC input would otherwise stall the fast offline path), so an audio-bearing compose-grid takes noticeably longer than a silent one.
- **Canvas ceiling**: `width*height > 35,651,584` is rejected up front with an explicit message.
- **Atomic output** via `<out>.part` + rename, same as `compose`. Free licences get the `⌘ Wispframe` bottom-right mark, re-composited onto the new canvas.

**Worked examples.**
```sh
# Screen + webcam, cam as a circular bubble at an exact spot, both audio tracks
# mixed with the cam louder than the screen. --place is used instead of pip-br
# because the built-in tile is 537x302 (16:9) and a circle needs a square rect.
recorder compose-grid --out final.mp4 --width 1920 --height 1080 \
  --sources screen.mp4,cam.mp4 \
  --place 1:1420,660,420,420 --shape 1:circle --opacity 1:90 \
  --audio 0:0.7 --audio 1:1.0

# Four-way review grid where clip 2 joins at 8s and clip 3 is cut at 20s.
# Canvas dropped to 1280x720 so the 640x480 sources are not stranded at native
# size inside oversized cells.
recorder compose-grid --out review.mp4 --width 1280 --height 720 \
  --layout grid --sources a.mp4,b.mp4,c.mp4,d.mp4 \
  --offset 2:8 --end 3:20 --audio-from 0

# Timed edit: side-by-side intro, eased switch to speaker at 12s, then only the
# demo on screen from 40s. The 0:sbs anchor is mandatory — without it the very
# first keyframe would apply from t=0 backwards.
recorder compose-grid --out talk.mp4 --sources demo.mp4,host.mp4 \
  --layout-key 0:sbs \
  --layout-key 12:speaker:ease:0.6 \
  --layout-key 40:sbs:fade:0.8:show=0 \
  --audio 1:1.0
```


### Captions

### `wispframe transcribe`

**What it does.** Runs on-device speech-to-text over a video's audio track (whisper.cpp via Metal, or Qwen3-ASR via MLX) and writes the result as `.srt`, `.vtt`, or `.json` — the format is chosen purely by `--out`'s extension. No network call except a one-time model download to the Hugging Face cache.

**Reach for it when.**
- You need a transcript, a caption file, or the timing sidecar that `subtitle` burns.
- You want word-level timing for karaoke / word-pop / highlight styles — pass `--words` and write `.json`.
- The audio is in a language you can name (`--lang hu`) or want auto-detected (omit `--lang`).

**Use something else when.**
- The input has no audio track — the command errors with `<file> has no audio track to transcribe` (`crates/pipeline/src/subtitle.rs:44`).
- You already have an SRT/VTT from elsewhere; feed it straight to `subtitle --captions`.
- You only want to convert between caption formats — that's `subtitle --export`, no ASR pass.

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--in` | the `.mp4`/`.mov` itself | Audio is decoded, downmixed to mono and linearly resampled to 16 kHz mono in-process (`crates/sck-ffi/src/whisper.rs:20`). No pre-extraction needed. |
| `--out` | `.json` when captions will be burned; `.srt`/`.vtt` only for delivery | Extension picks the serializer (`crates/cli/src/lib.rs:1660`). SRT and VTT **drop `words[]`**; `.json` is the only format that keeps per-word timing. Anything not `.json`/`.vtt` is written as SRT. |
| `--words` | **always**, unless you are certain the output will never drive a burn | Turns on `set_token_timestamps(true)` and populates `words[]` per segment. See the gotcha block — this is the flag that breaks burns when omitted. |
| `--model large-v3-turbo` (default) | the default for essentially everything | q5_0-quantized turbo, ~574 MB (`crates/sck-ffi/src/whisper.rs:58`). Best speed/accuracy ratio of the Whisper set. |
| `--model large-v3` | hard audio: accents, noise, code-switching | Full unquantized `ggml-large-v3.bin` — the largest download and the slowest pass, marginally better on difficult speech. |
| `--model medium` / `small` / `base` | in that order as you trade accuracy for speed and disk | `base` is the smallest and fastest and visibly worse on names, numbers and non-English. |
| `--model qwen3-0.6b` / `qwen3-1.7b` | non-Whisper second opinion, or when you want word timing unconditionally | Shells out to `mlx-qwen3-asr` (Apple Silicon), auto-installed into a private venv on first use (`crates/pipeline/src/qwen.rs`). Aliases: `qwen3`, `qwen3-asr`, `qwen3-asr-0.6b`, `qwen3-asr-1.7b`. |
| `--lang` | the ISO code when you know it (`hu`, `en`, `de`) | Omitted ⇒ `Some("auto")` is passed to whisper.cpp, i.e. language detection. Naming the language removes a detection failure mode on short or noisy clips. |

**Gotchas.**
- **The repo's binary is `recorder`** (`crates/cli/Cargo.toml`), e.g. `./target/release/recorder transcribe …`. `wispframe` is the shipped/app-bundle name.
- **`--words` is not the default and nothing warns you.** Without it, `seg.words` stays empty and the `.json` you get has `"words": []` for every segment — the burn then estimates the boundaries by splitting each cue evenly. See the dedicated block below.
- `--model` is validated against the seven known keys, so a typo like `tiny` is refused with the list instead of quietly downloading and running turbo. (`model_filename` itself still defaults to turbo for anything it doesn't recognise, which is why the check lives at the CLI boundary.)
- **`--words` is ignored on the Qwen path.** `crate::qwen::transcribe(input, model_key, language)` doesn't take the flag — Qwen always emits word timing and groups words into lines on a >0.6 s pause or every 10 words (`crates/pipeline/src/qwen.rs:145`).
- **The `language` field in the JSON is an echo of `--lang`, not the detected language.** `transcript.language = opts.language.clone()` (`whisper.rs:128`) — with auto-detect it serializes as `null` even though the model did detect something.
- **Word grouping keys off leading spaces.** Tokens are joined into a word only when the next token starts with `' '` (`whisper.rs:162`). For scriptio-continua languages (Chinese, Japanese) an entire segment collapses into a single "word", so word-level styles have nothing to animate.
- **Cue boundaries quantize to 10 ms** — whisper t0/t1 are centiseconds divided by 100 (`whisper.rs:135`).
- **Segments are sanitized before writing**: overlaps are pushed forward and negative durations clamped (`Transcript::sanitize`, `crates/core/src/subtitle.rs:90`).
- Whisper runs greedy (`best_of: 1`) on `available_parallelism()` threads clamped to 1–16 (`whisper.rs:102`, `:114`). It is CPU/Metal-bound and not cancellable mid-run.
- First run of any model downloads it; subsequent runs are cache hits.

**Worked example.**
```sh
# Word timing + .json out: the only combination that can drive karaoke/word-pop
# burns later. --lang hu skips detection on a short clip where it often guesses wrong.
recorder transcribe --in demo.mp4 --out demo.json --model large-v3-turbo --lang hu --words

# Delivery-only sidecar for a player (word timing intentionally discarded):
recorder transcribe --in demo.mp4 --out demo.srt --lang en
```

---

### `wispframe subtitle`

**What it does.** Burns styled captions from an `.srt`/`.vtt`/`.json` cue file into a video (one of 22 presets, overridable field-by-field via a style JSON), or — with `--export` — only converts a cue file between SRT, VTT and the word-timed JSON without touching video.

**Reach for it when.**
- You want captions baked into the pixels (social video, silent autoplay).
- You want to convert `.srt` ↔ `.vtt` (`--export`), no video required.
- You want a machine-editable style file to tweak: `--dump-style` writes one and exits.

**Use something else when.**
- You want soft/selectable subtitles — this command only burns; muxing a subtitle track is not implemented here.
- You don't have cues yet — run `transcribe` first.
- You want to hand-fix cue text and timing interactively — that's the GUI caption editor; this command consumes the file it produces.

**Choosing the values**

| Flag | Pick | Why |
|---|---|---|
| `--captions` | `.json` whenever the preset animates per word | `.json` goes through `from_json` and keeps `words[]`; `.srt`/`.vtt` go through `parse_srt` which recovers segment timing only (`crates/cli/src/lib.rs:1605`). |
| `--in` / `--out` | both required for a burn | Omitting `--in` on a non-export run errors: `subtitle burn needs --in <video> (use --export to only convert)`. |
| `--export` | pure format conversion | Skips decode/encode entirely and re-serializes `--captions` into `--out`'s format: `.json` → `to_json`, `.vtt` → `to_vtt`, anything else → SRT. Exporting an SRT to `.json` yields `"words": []` — it cannot invent word timing. |
| `--preset` | see the picker table | Exact, case-sensitive match, validated against the list of 22 — an unknown name (including a lowercase `karaoke`) is rejected with the valid names in the message rather than falling through to `Clean`. |
| `--font-size` | percent of frame height, e.g. `6.5` | Not points. Clamped to `1.0..=30.0` by the CLI, then `font_px = round(size_pct/100 × out_h)` clamped to `10..=400`. Resolution-independent by construction. |
| `--position` | `top` \| `middle` \| `bottom` | `center` is an alias for `middle`; anything else is rejected with the valid list. |
| `--font` | a family key, not a font file | Mapped to a system PostScript face by `mac_font` (`crates/pipeline/src/subtitle.rs:254`). Recognised keys: `Bebas Neue`/`Anton`/`Oswald` → Helvetica Neue Condensed; `Impact` → Impact; `Serif`/`Georgia`/`Editorial`/`Elegant`/`Times` → Georgia; `Script`/`Cursive`/`Signature`/`Snell`/`Handwriting` → Snell Roundhand; `Mono`/`Menlo`/`Courier`/`Space Grotesk`/`Fira Sans` → Menlo; `Montserrat`/`Poppins`/`Nunito`/`DM Sans`/`Archivo` → Avenir Next. **Everything else → Helvetica Neue**, no error. |
| `--style` | full control | Overrides `--preset` completely (the preset is not even consulted). `--font-size`/`--position`/`--font` are applied *on top* of it afterwards. |
| `--dump-style` | to author a `--style` file | Resolves `--preset` + the flags, writes the JSON, prints `style → <path>`, and **exits before** requiring `--in`/`--out`/`--captions`. |

**Every field in the `--dump-style` JSON**

| Field | Type / values | Effect at burn time |
|---|---|---|
| `font` | string family key | Resolved through `mac_font` (table above). |
| `weight` | `"Regular"` \| `"Medium"` \| `"Bold"` \| `"Black"` | Selects the concrete face within the family. |
| `italic` | bool | **Inert** — `mac_font` never reads it; no italic face is selected. |
| `size_pct` | f32 | Font pixel size = `round(size_pct/100 × output height)`, clamped 10–400. |
| `letter_spacing` | f32 | CoreText tracking in points = `letter_spacing × size_px / 100`. |
| `line_height` | f32 | Line advance = `font_px × line_height`. |
| `transform` | `"None"` \| `"Uppercase"` \| `"TitleCase"` | Applied to each word before rasterizing. |
| `fill` | `[r,g,b,a]` | Base text colour. |
| `gradient_fill` | `null` \| `[r,g,b,a]` | Vertical gradient `fill` (top) → `gradient_fill` (bottom). **Skipped on the active word** unless `active_word` is `"None"` — the spoken word then renders flat in `active_fill`. |
| `active_fill` | `[r,g,b,a]` | Colour of the currently-spoken word for `Color`/`Scale`/`Box`/`Karaoke`. Default is yellow `[255,214,10,255]`, inherited by presets that don't override it. |
| `keyword_fill` | `null` \| `[r,g,b,a]` | **Inert** — declared in the model, never read by the renderer. |
| `outline` | `null` \| `[width_px, [r,g,b,a]]` | 8-direction offset blit. Width is `width × word_animation_scale`, min 1 — **not scaled by frame height**, so it is thinner-looking at 4K than at 1080p. |
| `shadow` | `null` \| `{color, blur, dx, dy}` | Offset copy of the glyph at `color`, alpha `color[3]/255`. **`blur` is never applied** — the shadow is hard-edged regardless of the value. |
| `line_box` | `null` \| BoxStyle | One rounded box behind the **whole caption block** (not per line — per-line boxes would overlap). |
| `word_box` | `null` \| BoxStyle | Rounded pill behind the active word; only drawn when `active_word` is `"Box"`. |
| BoxStyle `.color` / `.color2` | `[r,g,b,a]` / `null`\|`[r,g,b,a]` | `color2` set ⇒ vertical gradient top→bottom; `null` ⇒ solid. |
| BoxStyle `.radius` / `.padding` | f32, px at a **1080p reference** | Both multiplied by `output_height / 1080`, so boxes scale with resolution (unlike `outline`). |
| BoxStyle `.full_width` | bool | `true` = bar spanning margin-to-margin; `false` = hug the text ("pill"). |
| `position` | `"Top"` \| `"Middle"` \| `"Bottom"` | Vertical placement of the block within the margins. |
| `anchor` | `null` \| `[x, y]` fractions 0..1 | The **centre** of the caption block. When set it overrides both `position` and horizontal centring. |
| `align` | `"Left"` \| `"Center"` \| `"Right"` | Horizontal alignment of each line inside the block. |
| `margin_pct` | f32 | Safe margin as a percent of the **shorter** frame side (`min(w,h)`), floored at 4 px. |
| `max_chars_per_line` | usize | **Inert for burn-in.** The renderer wraps by measured pixel width; `wrap_lines()` has no production caller. |
| `max_lines` | usize | **Inert for burn-in.** The renderer deliberately never truncates lines (dropping them would hide spoken words). |
| `max_words_visible` | usize | `>0` ⇒ show only an N-word window centred on the active word. **Requires word timing** — see gotchas. `0` = whole segment. |
| `reveal` | `"None"` \| `"Fade"` \| `"SlideUp"` \| `"Pop"` \| `"Bounce"` \| `"Typewriter"` | Entrance motion over 0.20 s from each word's onset. Any value other than `"None"` also enables a 150 ms fade-in at segment start; `"None"` pins opacity to 1. |
| `active_word` | `"None"` \| `"Color"` \| `"Scale"` \| `"Box"` \| `"Karaoke"` | What marks the spoken word. **Everything except `"None"` requires word timing.** `Scale` adds a 0.12 s pop kick; `Karaoke` fills the glyph left-to-right by `(t-start)/(end-start)`. |

**⚠️ `--words` and word timing — the production failure mode**

This is the flag that silently ruins burns. **Always pass `--words` to `transcribe` when the transcript will drive burned captions or any word-level style.**

What `--words` adds: for each segment, a `words` array of `{text, start, end}` — Whisper token timestamps grouped into words (`crates/sck-ffi/src/whisper.rs:138,150`). Without it, every segment serializes as `"words": []`.

What happens at burn time when `words` is empty (`crates/pipeline/src/subtitle.rs`): the words are re-derived from `seg.text.split_whitespace()` and each is given an **even slice of the cue's span** — `start + span * i/n` to `start + span * (i+1)/n`. Every word is still drawn and the highlight still advances, but the boundaries are a guess: "a" and "extraordinarily" get identical slots, so on a long sentence the highlight drifts progressively further from the audio. The app's caption editor does the same thing in `rebuild_words`.

Earlier builds left those timings `None` instead, which was much worse: `active_idx` never matched, so with `max_words_visible > 0` the visible window froze at `0..N` and **every word past the N-th was silently dropped for the whole cue**. If you are on such a build, treat `--words` as mandatory rather than merely recommended.

Even with the estimate in place the rule stands: capture real word timing at transcription time, because no downstream step can recover it.

**Gotchas.**
- **A `.json` cue file with `"words": []` is indistinguishable from a properly timed one until you watch the render.** Check it programmatically before burning.
- `language` and `model` are **required** top-level keys in the cue JSON (verified: `{"segments":[…]}` alone fails with `could not parse transcript JSON`). `words` is the only optional field (`#[serde(default)]`).
- **Hand-authored / imported cues are not sanitized.** `Transcript::sanitize()` runs inside the ASR backends, but the CLI's `from_json`/`parse_srt` path skips it. Overlapping cues survive, and the renderer draws only the **last** segment containing `t` (`.rev().find(…)`, `crates/pipeline/src/subtitle.rs:378`) — earlier overlapping cues vanish.
- An empty cue list is a hard error: `no caption cues parsed from <file>`.
- **Burning re-encodes the whole video** through the frame pipeline. Audio is preserved and re-muxed; output dimensions are forced even (`src & !1`). On the Free tier a watermark is baked in (`crates/pipeline/src/frameproc.rs:116`).
- Output is written atomically via a `.part` file, then renamed.
- Presets `Pop Word` and `Glow Pop` set `position: Middle`, not bottom — they will not sit where the other presets do.
- Because `active_fill` defaults to yellow, presets that set `active_word` without overriding `active_fill` (`Word Pop`, `Pop Word`, `Glow Pop`) turn the spoken word **yellow**; in `Glow Pop` that also suppresses its pink→purple gradient on that word.
- `--export` still requires `--out` (the extension is the format switch) and `--captions`, but not `--in`.

**Worked example.**
```sh
# 1. Transcript WITH word timing — mandatory for a karaoke burn.
recorder transcribe --in demo.mp4 --out demo.json --lang en --words

# 2. Verify word timing actually landed before spending an encode pass on it.
python3 -c "import json,sys; t=json.load(open('demo.json')); \
  assert all(s['words'] for s in t['segments']), 'no word timing — re-run with --words'"

# 3. Start from a preset, dump it, hand-tune, burn with the tuned file.
#    --dump-style exits immediately; no video is touched.
recorder subtitle --preset Karaoke --font-size 6.5 --position bottom --dump-style karaoke.json
#    …edit karaoke.json (e.g. active_fill → brand colour, margin_pct → 10)…
recorder subtitle --in demo.mp4 --captions demo.json --out demo-captioned.mp4 --style karaoke.json

# Format conversion only — no video, no burn, no ASR.
recorder subtitle --captions demo.json --out demo.vtt --export
```

---

### Preset picker

All 22 names are exact and case-sensitive, and an unknown one is an error. "Needs `--words`" means the preset's defining effect runs on estimated, evenly-split timings without it — visible on short cues, drifting on long ones.

| You want … | Preset | Look (font key → weight, size %, key decoration) | Needs `--words` |
|---|---|---|---|
| A safe default that reads anywhere | `Clean` | Inter → Bold, 5.5%, soft black shadow, bottom-centre | no |
| Maximum legibility over busy footage | `Bold Outline` | Inter → Black, 5.5%, 6 px black outline, no shadow | no |
| Classic karaoke left-to-right fill | `Karaoke` | Montserrat → Black, UPPER, 5.5%, 5 px outline, yellow fill sweep | **yes** |
| TikTok-style 3-word window with bounce | `Word Pop` | Poppins → Black, UPPER, 7%, 5 px outline, scale pop, 3 words visible | **yes** (else only the first 3 words of each cue ever show) |
| One giant word at a time, centre screen | `Pop Word` | Poppins → Black, UPPER, 9%, middle, 6 px outline, 1 word visible | **yes** (else only the first word of each cue shows) |
| Two-word rhythm with a colour accent | `Pop Duo` | Poppins → Black, UPPER, 7.5%, 5 px outline, yellow active word, 2 words visible | **yes** |
| Neon-glow single word, gradient letters | `Glow Pop` | Poppins → Black, UPPER, 8.5%, middle, pink→purple gradient, purple glow, 1 word visible | **yes** |
| Yellow highlighter pill under the spoken word | `Highlight Box` | Poppins → Bold, UPPER, 5.5%, black text on a yellow→orange gradient pill | **yes** |
| Black bar caption with a red marker word | `Boxed Pop` | Inter → Medium, 5.5%, tracking 1.5, black block box + red word pill | **yes** (the red pill is the whole point) |
| A plain readable box behind the line | `Boxed Line` | Inter → Bold, 5.5%, translucent black rounded box | no |
| Purple→pink gradient pill, premium feel | `Aurora` | Poppins → Bold, 5.5%, radius-28 gradient pill | no |
| Warm orange→pink gradient pill | `Sunset` | Poppins → Bold, 5.5%, radius-28 gradient pill | no |
| Cool cyan→blue gradient pill | `Ocean` | Poppins → Bold, 5.5%, radius-28 gradient pill | no |
| Broadcast lower-third, left-aligned bar | `Lower Third` | Archivo → Medium, 5.5%, full-width dark navy gradient bar, margin 6% | no |
| Retro neon signage | `Neon` | Bebas Neue → Regular (condensed), UPPER, 7.5%, mint→blue gradient letters, teal glow | no |
| Text typing in as it's spoken | `Typewriter` | Space Grotesk → Menlo Medium, 5.5% | **yes** (without it the whole line appears at once) |
| Understated, editorial, small caps feel | `Minimal Caps` | Inter → Medium, UPPER, 4%, tracking 2.0, faint shadow | no |
| Documentary / serif tone | `Serif` | Georgia → Regular, 5.5% | no |
| Bolder serif, magazine pull-quote | `Editorial` | Georgia → Bold, 6.5% | no |
| Handwritten / signature | `Script` | Snell Roundhand → Bold, 8.5%, line-height 1.4 | no |
| Meme / shout | `Impact` | Impact → Regular, UPPER, 8%, 5 px black outline | no |
| Purple branded lower-third block | `Creator` | Poppins → Black, UPPER, 5%, solid purple box, white caps | no |

Presets that ignore word timing render byte-identically from an `.srt`, a `.vtt`, or a `.json` — for those, `--words` costs nothing but buys nothing.

---

### Caption JSON schema

`--captions foo.json` deserializes directly into `Transcript` (`crates/core/src/subtitle.rs:63`). Same schema `transcribe --out foo.json` writes, so the round-trip is lossless.

```
Transcript {
  language: string | null   // REQUIRED key (may be null). Echo of --lang, not detection output.
  model:    string          // REQUIRED. Free-form provenance ("whisper-large-v3-turbo", "hand").
  segments: [ CaptionSegment ]   // REQUIRED. Non-empty or the burn errors.
}

CaptionSegment {
  start: number   // seconds, f64
  end:   number   // seconds, f64
  text:  string   // full cue text; used verbatim when `words` is empty
  words: [ Word ] // OPTIONAL (defaults to []). Supply it — see the --words section.
}

Word {
  text:  string   // one word, no leading/trailing space
  start: number   // seconds
  end:   number   // seconds
}
```

Rules an agent must respect when generating this:
- `language` and `model` **must be present** — omitting either fails with `could not parse transcript JSON <file>`. `language` may be `null`.
- Times are absolute seconds from the start of the video, `f64`, not frames and not milliseconds.
- Keep `words[i].text` consistent with `text`: when `words` is non-empty the renderer draws **only** `words[*].text` and ignores `text` entirely. Anything missing from `words` never appears on screen.
- Keep segments non-overlapping and ordered. Nothing sanitizes a hand-written file, and overlapping cues mean only the last match at any instant is drawn.
- Word spans should tile the segment: `words[0].start ≈ seg.start`, `words[n-1].end ≈ seg.end`, each `end ≥ start`. `Karaoke` divides by `(end - start)`; a zero-length word snaps straight to fully filled.
- Do not synthesize word times by splitting a cue evenly — that is exactly the drift described above. Re-run `transcribe --words` instead.

A minimal file that burns correctly with any word-level preset:

```json
{
  "language": "en",
  "model": "hand-authored",
  "segments": [
    {
      "start": 0.0,
      "end": 1.62,
      "text": "zero copy capture",
      "words": [
        { "text": "zero",    "start": 0.00, "end": 0.42 },
        { "text": "copy",    "start": 0.42, "end": 0.88 },
        { "text": "capture", "start": 0.88, "end": 1.62 }
      ]
    },
    {
      "start": 1.62,
      "end": 3.40,
      "text": "at one hundredth of a percent CPU",
      "words": [
        { "text": "at",        "start": 1.62, "end": 1.78 },
        { "text": "one",       "start": 1.78, "end": 1.99 },
        { "text": "hundredth", "start": 1.99, "end": 2.51 },
        { "text": "of",        "start": 2.51, "end": 2.63 },
        { "text": "a",         "start": 2.63, "end": 2.71 },
        { "text": "percent",   "start": 2.71, "end": 3.10 },
        { "text": "CPU",       "start": 3.10, "end": 3.40 }
      ]
    }
  ]
}
```

Validate it before burning with `recorder subtitle --captions cues.json --out /tmp/check.srt --export`, which parses the file and prints `captions → /tmp/check.srt (N cues)` without running an encode pass. It does not, however, tell you whether `words[]` is populated — check that yourself.


### The bridge, consent, sessions and licence

### Talking to the bridge

Wispframe is the engine. The Mac App Store build is sandboxed and may not spawn the bundled CLI, so every command a client asks for runs **in-process inside the running app** (`recorder_cli::run_argv`), in the process that already holds the Screen Recording, camera and microphone grants. An agent therefore needs no permissions of its own — but it also cannot do anything while the app is not running.

The app opens a loopback HTTP server on `127.0.0.1` with a bearer token. Two surfaces, one server:

| Route | Method | Purpose |
| --- | --- | --- |
| `/v1/ping` | GET | Liveness. Returns `{"name":"wispframe","version":"…"}`. Still requires the token. |
| `/v1/run` | POST | argv, **one argument per line**, `text/plain` body. Streams stdout+stderr live, then an exit sentinel. This is what the `wispframe` shell stub speaks. |
| `/mcp` | POST | JSON-RPC 2.0 (MCP streamable-HTTP): `initialize`, `tools/list`, `tools/call`, `ping`. |

Anything else is `404`. A missing or wrong `Authorization` header is `401 unauthorized` (the token is compared with a length-checked, data-independent byte compare).

**Where the port and token live.** The token is 128 bits from `/dev/urandom`, persisted at `~/Library/Application Support/recorder/bridge-token` with mode `0600`, and re-used across launches so a copied MCP config keeps working. The port is sticky: the app tries `8763, 8764, … 8770` in order and only falls back to an ephemeral port if all eight are taken. Discovery files (also `0600`) are written at startup:

| File | Written when |
| --- | --- |
| `~/Library/Application Support/recorder/bridge.env` | always (writable even when sandboxed) |
| `~/.wispframe/bridge.env` | always (legacy location, for already-installed stubs) |
| `<stub dir>/wispframe-bridge.env` | when the user installs the CLI stub, next to it |

Each contains `PORT=`, `TOKEN=`, `PID=`, `VERSION=` and, when resolvable, `APP=<path to Wispframe.app>`.

**Over MCP.** Point an HTTP MCP client at `http://127.0.0.1:<PORT>/mcp` with header `Authorization: Bearer <TOKEN>`. The app's "⌘ CLI & MCP" panel renders exactly this as a JSON `mcpServers` block or as a `claude mcp add --transport http wispframe …` one-liner. `initialize` reports protocol version `2024-11-05`, `serverInfo.name = "wispframe"`, and tool capabilities only. `tools/list` returns 16 tools built from a static `TOOLS` table (`list_sources`, `list_cameras`, `record_screen`, `record_camera`, `screenshot`, `trim`, `speed`, `to_gif`, `subtitle_transcribe`, `subtitle_burn`, `enhance`, `studio`, `remove_background`, `burn_timer`, `annotate_image`, `run_settings`). Each tool is a thin mapping: its JSON arguments become `--flag value` pairs appended to a fixed CLI verb (`run_settings` additionally passes `path` positionally). A JSON-RPC message with no `id` is treated as a notification and answered `202` with an empty body; malformed JSON is `400`.

**Over the CLI stub.** The stub is a dependency-free `/bin/sh` + `curl` script the app installs (default name `wispframe`, default suggested directory `~/bin`). It reads `wispframe-bridge.env` beside itself first, then `~/.wispframe/bridge.env`; pings `/v1/ping`; and if that fails it `open -g`s the app (by `APP=` path, else bundle id `app.wispframe.recorder`, else by name) and re-pings every 0.5 s for up to 12 s. It then POSTs `printf '%s\n' "$@"` to `/v1/run` with `Authorization: Bearer $TOKEN` and `X-Cwd: $PWD`.

**`X-Cwd` matters, and only `/v1/run` sends it.** The app `chdir`s to the header value for the duration of the command (and only if it is an existing directory), then restores its previous directory. MCP `tools/call` passes **no** cwd — relative paths in an MCP tool call resolve against the app's own working directory, which an agent does not control. **Over MCP, always pass absolute paths** for `in`, `out`, `spec`, `captions` and `path`.

Commands are serialized: a process-wide lock guards the run, because the implementation redirects fds 1/2 and changes the working directory. Only one command executes at a time; concurrent calls queue.

### The consent flow (what an agent must handle)

The token authorizes *file processing*. It does **not** authorize using the app's screen/camera/microphone grants. Those verbs stop and ask the human, on both surfaces.

| Verb (CLI) | Blocks on consent |
| --- | --- |
| `record`, `record-pip`, `record-camera`, `record-all`, `record-live-grid` | yes |
| `screenshot`, `scroll-capture` | yes |
| `probe`, `pick-color` | yes |
| everything else (`trim`, `speed`, `to-gif`, `transcribe`, `subtitle`, `enhance`, `studio`, `remove-bg`, `burn-timer`, `annotate-image`, `run`, `license`, `list-sources`, `list-cameras`, …) | no — runs on the token alone |

MCP tools inherit this through their underlying verb: `record_screen`, `record_camera` and `screenshot` prompt; `run_settings` and every processing tool do not. Note `list_sources` does **not** prompt at the bridge, even though the underlying capture enumeration may itself raise the OS-level Screen Recording prompt inside the app.

**What happens.** If the user has already ticked "Always allow captures on this Mac" or "Allow for this session", the command runs immediately. Otherwise the app raises a modal — "⌘ Allow this command?" — showing the exact command line (`wispframe record --out …`, or `MCP: record_screen`) and a line saying whether the request came from a terminal or from an AI agent over MCP. The bridge thread wakes the UI so the prompt is visible even on an idle app, and blocks the HTTP request while it waits. The user has four buttons:

| Button | Effect | Lifetime |
| --- | --- | --- |
| Deny | this invocation is refused | this call only |
| Allow once | this invocation runs | this call only |
| Allow for this session | runs, and no further capture prompts | until the app quits (`allow_capture_session`, in memory) |
| Always allow | runs, and no further capture prompts | persisted in app settings (`bridge.always_allow` via eframe storage); survives restarts until the user unticks it in the ⌘ CLI & MCP panel |

**Denial and timeout.** The request waits at most **60 seconds**; no answer within that window is treated as a denial. On denial over `/v1/run`, the response is HTTP 200 with the body:

```
wispframe: denied in the app (the user declined this capture)
__WISPFRAME_EXIT__:77
```

so the stub exits **77**. Over MCP the result is a normal `tools/call` result with `isError: true` and the text `Denied in the Wispframe app: the user declined this capture.` Either way the attempt is recorded in the app's Activity list with outcome `Denied`.

**What an agent must do when denied.** Treat exit code 77, or that denial text with `isError`, as a *human decision*, not a transient failure. **Do not retry, and never loop.** Report to the user in plain language: the capture was declined in the Wispframe app, and offer the options — approve the next prompt, or tick "Allow for this session"/"Always allow" in ⌘ CLI & MCP — then stop and wait for them. The same applies to a 60-second timeout: the user was not at the machine; say so rather than firing the command again.

Every accepted invocation is also appended to the app's Activity list (capped at 100 entries) with its outcome, so the human can audit what an agent has been doing.

### Exit codes

Exit codes are meaningful on the CLI-stub path. MCP has no exit code — it reports `isError` plus the captured text.

| Code | Meaning | Agent action |
| --- | --- | --- |
| 0 | success | continue |
| 1 | the command returned an error (message printed as `error: …`); also the stub's fallback when no sentinel arrived | read the message; fix arguments or report |
| 69 | the stub could not reach the app after launching it and waiting ~12 s — "the Wispframe app isn't running" | tell the user to launch Wispframe once; do not loop |
| 77 | the user denied the capture in the app (or the 60 s consent window expired) | tell the user; **do not retry** |
| 101 | the in-process command panicked; prints `error: the command failed unexpectedly` | report as a bug; retrying is unlikely to help |

The bridge itself never uses HTTP status to convey command failure: a run that fails still returns `200` with the failure in the streamed body and in the sentinel. HTTP statuses only cover transport problems — `401` (bad/missing token), `404` (unknown route), `400` (malformed JSON on `/mcp`).

### Reading progress

`POST /v1/run` streams the command's stdout and stderr interleaved, as produced, over a chunked response with no framing of its own — plain bytes. The stream ends with a line-anchored sentinel:

```
__WISPFRAME_EXIT__:<code>
```

It is always preceded by a newline, so it always starts a line, and it is always the last thing sent. Parse it by checking that a line starts with `__WISPFRAME_EXIT__:` and taking the remainder as the integer exit code; everything else is command output. That is exactly what the stub's `awk` does (it strips the sentinel line, prints the rest, and `exit`s with the digits it found, defaulting to 1).

Long pipeline operations emit two machine-readable line formats, deliberately kept sparse for piped output (there is no TTY behind the bridge, so the interactive progress bar is invisible and every printed line is visible):

| Line | When |
| --- | --- |
| `PROGRESS_TOTAL: <n>` | once, on the first tick, only when the total frame count is known |
| `PROGRESS: <done> / <total>` | roughly every 5% of total, and always on the final frame |
| `PROGRESS: <done>` | same cadence (every 30 frames) when no total is known |

An agent parsing progress should: read the stream line by line, treat `PROGRESS_TOTAL:`/`PROGRESS:` as status, buffer everything else as output, and stop at the sentinel. Body reads are capped at 1 MiB on the request side, so keep argv and JSON-RPC frames small.

MCP does **not** stream. `tools/call` runs the command to completion, collects the same stdout/stderr into one string (progress lines included), and returns it as a single `content[0].text` with `isError = (exit code != 0)`. An agent over MCP should expect one blocking response with no intermediate updates.

### `wispframe run` — saved sessions

`run` executes a **settings JSON** — the file the app's "💾 Save settings" button writes from an editor. It is the way an agent replays a composition a human built by hand.

```
wispframe run <SETTINGS.json> [--out <path>]
```

Every settings file carries a `tool` tag that selects the pipeline. High-level, a settings file contains the input path(s) plus that editor's full state:

| `tool` | Contents (high level) | Default output when `--out` is absent |
| --- | --- | --- |
| `compose` | list of source files; per-source (audio on/off, volume, start, end, opacity); per-source cell shape; layout keyframes (time, layout name, per-source placement rects, transition + duration, visibility); canvas size; `out_name` | `out_name` from the file, **sanitized** (fallback `compose-run.mp4`) |
| `trim` | input, duration, keep window (`start`/`end`), list of cut ranges | `<input minus .mp4>.trimmed.mp4` |
| `redact` | input, rectangles, per-rect time ranges and blur/solid modes, face count, seeds | `<input>.redacted.mp4` |
| `studio` | input, optional backdrop image path (else blurred backdrop) | `<input>.studio.mp4` |
| `subtitler` | input, Whisper model key, volume, caption style, optional embedded transcript | `<input>.subtitled.mp4` |
| `annotate` | input, annotation list, per-annotation time ranges | `<input>.annotated.mp4` |

Execution translates session values into the same spec strings the app builds internally and calls the corresponding pipeline (compose grid, trim, compose-with-redactions, studio polish, subtitle burn, annotate). Useful specifics an agent should know: a `compose` session with fewer than 2 sources is rejected; a canvas smaller than 320×240 falls back to 1920×1080; a `trim` session whose cuts consume everything is rejected ("trim session keeps nothing"); a `redact` session with no regions is rejected; an `annotate` session with no annotations is rejected; and a `subtitler` session with no stored transcript transcribes first (default model `large-v3-turbo`), which is slow. A file with no `tool` tag, or a tag outside `compose | trim | redact | studio | subtitler | annotate`, is refused with an explanatory error — those editors are app-only for now.

**`--out` and the path restriction.** `--out` is the operator's own argument and is honoured exactly as typed, for any tool. The file-supplied name is not trusted. `sanitize_session_out` applies to the one output path that comes from inside the settings file — `compose`'s `out_name` — and rejects escape: if the name is absolute or contains any `..` component, only its final path component is kept, so a hostile name degrades to a plain file in the current working directory (empty name → `compose-run.mp4`). This was a real pre-launch finding: `../` and absolute paths inside a shared or downloaded session silently overwrote unrelated files. The consequence for an agent: **you cannot make a session write outside the intended directory by editing `out_name`** — pass `--out` (or the MCP `out` argument) if you need a specific destination, and remember that the "current directory" over `/v1/run` is your `X-Cwd`, while over MCP it is the app's, so use an absolute `--out` there.

Note the non-compose tools derive their default output from the session's own `input` path, next to that input — so for those, the output lands wherever the input lives, not in the cwd.

Over MCP this is the `run_settings` tool (`path` required, `out` optional). It does not require consent — a saved session only processes files.

### `wispframe license`

```
wispframe license [--action status|activate|deactivate] [--product monthly|annual|lifetime]
```

`status` (the default) is the only action an agent should use. It prints the license label and then, depending on state:

| State | Output |
| --- | --- |
| Free | `Recording is unlimited; processed exports carry a small watermark.` plus an upgrade hint |
| Pro | the active product id, and `Watermark-free exports.` |

That is the whole reporting surface: free vs Pro, the active product id when Pro, and the watermark consequence. It is worth checking before a processing job if watermarking would matter to the user — recording itself is unlimited on Free, but *processed exports* are watermarked. Duration limits are also clamped against license state on the record paths.

**Activation is not possible from the CLI in release builds.** `--action activate` and `--action deactivate` are compiled to hard errors when `debug_assertions` is off: activate returns "Pro is purchased in the app through the App Store — `license --action activate` is a development-only command", and deactivate points the user at System Settings ▸ Apple Account ▸ Subscriptions. This is deliberate — the command was previously a one-line paywall bypass. Purchases go through the App Store via the in-app StoreKit sheet. An agent asked to "upgrade" or "activate Pro" should say so and direct the user to the app's paywall; retrying the command, or trying it against a debug build, is not an acceptable workaround. `status` remains available in every build.

---

## 12. Complete flag reference (from the binary's `--help`)

This section is machine-generated — EVERY flag of EVERY subcommand is here.


### `wispframe run`

```
Execute a Wispframe SETTINGS FILE headless: the JSON the app's "💾 Save settings" writes (compose / trim / redact sessions). Reads the file's `tool` tag and runs the matching pipeline — so a saved composition (sources, offsets, layout keyframes, shapes) renders with one command, scriptable from the terminal or the MCP server

Usage: recorder run [OPTIONS] <SETTINGS>

Arguments:
  <SETTINGS>  Path to the settings .json (tool tag: compose | trim | redact)

Options:
      --out <OUT>  Output file. Default: the session's own output name (compose) or `<input>.<tool>.mp4` next to the input
  -h, --help       Print help
```


### `wispframe list-sources`

```
List capturable displays, windows, and applications as JSON. Implements `memory/1_3.md`. Triggers the Screen Recording permission prompt

Usage: recorder list-sources

Options:
  -h, --help  Print help
```


### `wispframe list-cameras`

```
List camera devices (built-in, USB, iPhone Continuity Camera) as JSON. Implements `memory/3_2.md`. Enumeration alone does not trigger the Camera permission prompt; an empty list (no camera attached) is valid output, not an error

Usage: recorder list-cameras

Options:
  -h, --help  Print help
```


### `wispframe probe`

```
Capture a display for N seconds, printing each frame's PTS. Implements `memory/1_4.md`. Verifies the capture pipeline end-to-end

Usage: recorder probe [OPTIONS]

Options:
      --display <DISPLAY>    Display id from `list-sources` [default: 1]
      --duration <DURATION>  Capture duration in seconds [default: 10]
      --fps <FPS>            Target FPS [default: 60]
      --width <WIDTH>        Capture width [default: 1920]
      --height <HEIGHT>      Capture height [default: 1080]
      --bench <BENCH>        If set, also sample CPU/RAM during the run and write a Markdown + CSV report to `memory/benchmarks/<label>-*.{md,csv}`. Implements `memory/1_5.md`
  -h, --help                 Print help
```


### `wispframe record`

```
Record a display to an MP4 (H.264) file. Implements `memory/2_3.md`. The full capture → encode → mux pipeline

Usage: recorder record [OPTIONS] --out <OUT>

Options:
      --display <DISPLAY>
          Display id from `list-sources`. Used unless `--window` or `--app` overrides the target [default: 1]
      --window <WINDOW>
          Target a single window by its id (from `list-sources`), overriding `--display`. Implements `memory/3_3.md` window capture
      --app <APP>
          Target an application by its bundle id (from `list-sources`), overriding `--display`. Captures all of the app's windows on the display given by `--display`. Implements `memory/3_3.md` app capture
      --duration <DURATION>
          Capture duration in seconds [default: 10]
      --fps <FPS>
          Target FPS [default: 60]
      --width <WIDTH>
          Capture width [default: 1920]
      --height <HEIGHT>
          Capture height [default: 1080]
      --out <OUT>
          Output file path (.mp4 for H.264/HEVC, .mov for ProRes)
      --codec <CODEC>
          Codec: "h264" (default), "hevc" (smaller files for 4K/8K), or "prores" (lossless for editing; writes .mov). Implements `5_1`/`5_2` [default: h264]
      --crop <CROP>
          Optional source crop as "x,y,w,h" in source pixels. Captures only that sub-rect of the display. Implements `memory/3_5.md`
      --mask-bg <MASK_BG>
          If set, after recording, remove the background (post-process) and overwrite the output with the masked version. Implements the "record with background removal" convenience of `memory/3_6_7.md`
      --system-audio
          Capture system audio (what the machine is playing) into an AAC audio track. Combine with --mic to mix both into ONE track
      --mic
          Capture the default microphone into an AAC audio track (prompts for Microphone permission on first use). Combine with --system-audio to mix both into ONE track
      --system-audio-gain <SYSTEM_AUDIO_GAIN>
          Volume applied to the system audio (0.0 = mute, 1.0 = unchanged, 2.0 = double). Useful with --mic to balance the two sources [default: 1]
      --mic-gain <MIC_GAIN>
          Volume applied to the microphone (0.0–2.0, 1.0 = unchanged) [default: 1]
      --record-cursor
          Record cursor position + clicks into a `<out>.cursor.json` sidecar for Studio auto-zoom (Task 27). Whole-display targets only
      --record-keys
          Capture pressed keys into a `<out>.keys.json` sidecar (Task 31) for the Studio keystroke overlay. Needs the Accessibility permission; opt-in, privacy-sensitive. Not available in the sandboxed MAS build
      --cpu-log
          Log a per-second CPU breakdown by worker thread while recording (encode/mux, cursor + key tracking, capture callbacks…), then print a per-thread average for the run. Shows WHERE the CPU goes
      --hide-cursor
          Hide the mouse cursor in the recording (the GUI's "Show cursor" toggle, inverted; default: cursor visible)
      --delay <DELAY>
          Self-timer: count down N seconds before recording starts (the GUI's ⏱ Countdown) [default: 0]
      --via-display-crop
          Force recording a `--window` via a cropped, window-tracking DISPLAY capture instead of the direct window filter. Use for GPU-composited Chromium/Electron windows (Antigravity, VS Code, Chrome, …) that yield 0 frames / a black video through the normal window path. Plain `--window` auto-detects this case via a blank-thumbnail probe; this flag skips the probe and forces it. Captures the on-screen region, so keep the window in front (an overlapping window shows through)
  -h, --help
          Print help
```


### `wispframe remove-bg`

```
Remove the background from an existing MP4 (post-processing). Implements `memory/3_6.md`. Decodes → masks each frame → re-encodes. Shows a progress bar throughout

Usage: recorder remove-bg [OPTIONS] --in <IN> --out <OUT>

Options:
      --in <IN>            Input video file (.mp4)
      --out <OUT>          Output video file (.mp4) with backgrounds removed
      --backend <BACKEND>  Backend: "vision" (Apple Neural Engine, person-only, fast) or "onnx" (U²-Net, general objects, slower) [default: onnx]
      --model <MODEL>      ONNX model variant: "u2net" (full, ~176MB, best quality) or "u2netp" (lite, ~4MB, faster). Only used with --backend onnx [default: u2net]
      --bg <BG>            Background to composite onto (H.264 has no alpha). A color — "green", "black", "white", or a hex "#rrggbb" — or an IMAGE: "image:/path/to/bg.jpg", cover-fit to the frame (aspect preserved, no distortion) centered on the image's Vision-detected focal point [default: black]
  -h, --help               Print help
```


### `wispframe compose`

```
Compose an overlay video onto a base video (picture-in-picture), and/or redact regions of it (blur / solid black). Implements `memory/3_1.md` (overlay) and `memory/4_3.md` (redaction). The overlay is scaled and pinned to a corner of the base, or placed at an explicit `--rect`, optionally only during a `--start-at`/`--end-at` time window. Outputs an MP4 with the overlay/redactions baked in

Usage: recorder compose [OPTIONS] --base <BASE> --out <OUT>

Options:
      --base <BASE>                    Base video file (.mp4) — the background
      --overlay <OVERLAY>              Overlay video file (.mp4) — the picture-in-picture source. Optional when at least one --redact is given (redaction-only pass)
      --out <OUT>                      Output video file (.mp4). Written atomically: frames go to '<out>.part' and are renamed onto --out only on success, so a failed run never destroys a pre-existing output file
      --redact <REDACT>                Redact a region of the base frame: "x,y,w,h:blur|solid[:S-E]" (base-frame pixels). Repeatable. Redactions are baked into the encoded stream ON TOP of everything (they cover the overlay too). "solid" fills opaque black (use for passwords); "blur" applies a strong box blur (soft redaction: faces, chat previews). The optional ":S-E" suffix limits the region to a time window in seconds — "…:blur:2.5-8" hides it from 2.5s to 8s, "…:blur:3-" from 3s to the end; without it the whole video is covered
      --blur-strength <BLUR_STRENGTH>  Blur strength (box-blur radius in pixels) for every ":blur" redaction, including --redact-faces. 4 = light smudge, 20 = default (text unreadable), 60 = heavy fog. Solid redactions are unaffected [default: 20]
      --redact-faces <REDACT_FACES>    Automatically blur (or black out) FACES, tracked frame by frame (Apple Vision, on-device — no model download): "<mode>[:blur|solid]" where mode is "all" (every detected face) or one-or-more seed points "x1,y1[;x2,y2;…]" in base-video pixels — click-selected faces, each tracked INDEPENDENTLY (a briefly lost face holds its last box ~15 frames, then reacquires near its last position). Style defaults to blur. Face redactions draw ON TOP of everything, like --redact. Implements `memory/4_3.md`
      --text <TEXT>                    Burn a static text/emoji label into every frame: "TEXT:x,y[:SIZE[:#rrggbb]]" — e.g. --text "Demo 🎬:100,50:64:#ffff00". x,y = top-left position in base-frame pixels; SIZE = font size in pixels (default 48); color defaults to white (emoji keep their own colors). Repeatable. Rasterized once (CoreText) and composited above the --overlay video but BELOW --redact regions, so redactions always win. Implements `memory/4_4.md`
      --scale <SCALE>                  Overlay size as a fraction of the base width (0.0–1.0) [default: 0.3]
      --corner <CORNER>                Corner to pin the overlay: "tl", "tr", "bl", "br" [default: br]
      --margin <MARGIN>                Margin from the edge, in base-frame pixels [default: 24]
      --rect <RECT>                    Explicit overlay rect "x,y,w,h" in base-frame pixels (mutually exclusive with --corner/--scale/--margin). The overlay is scaled into this rect
      --start-at <START_AT>            Base-video time (seconds) at which the overlay appears [default: 0]
      --end-at <END_AT>                Base-video time (seconds) at which the overlay disappears (inclusive). Default: --start-at + the overlay's duration
      --on-end <ON_END>                If the overlay video ends before --end-at: "hold" the last overlay frame on screen, or "stop" the overlay early [default: hold]
      --keep-audio                     Copy the BASE video's audio track through to the output without re-encoding (default ON). Overlay audio is always ignored
      --no-audio                       Drop the base video's audio: the output has no audio track
      --shape <SHAPE>                  Overlay SHAPE: rect|rounded|circle|triangle|hexagon. `circle` turns the overlay into a webcam-style circle bubble [default: rect]
  -h, --help                           Print help
```


### `wispframe record-pip`

```
Record the screen WITH a live webcam picture-in-picture overlay. Implements `memory/3_2.md`. Captures the screen and the webcam in parallel, then composites the webcam as a PiP onto the recording

Usage: recorder record-pip [OPTIONS] --out <OUT>

Options:
      --display <DISPLAY>    Display id from `list-sources` [default: 1]
      --duration <DURATION>  Capture duration in seconds [default: 10]
      --width <WIDTH>        Capture width [default: 1920]
      --height <HEIGHT>      Capture height [default: 1080]
      --out <OUT>            Output file path (.mp4) with the webcam PiP baked in
      --scale <SCALE>        Overlay size as a fraction of the base width (0.0–1.0) [default: 0.3]
      --corner <CORNER>      Corner to pin the webcam: "tl", "tr", "bl", "br" [default: br]
      --margin <MARGIN>      Margin from the edge, in base-frame pixels [default: 24]
      --system-audio         Capture system audio into the recording. Combine with --mic to mix both into one track
      --mic                  Capture the default microphone into the recording. Combine with --system-audio to mix both into one track
      --camera <CAMERA>      Camera to use, by its uniqueID from `list-cameras`. Defaults to the system default camera. An id that no longer resolves (device unplugged, iPhone out of range) falls back to the default with a warning instead of failing the recording
      --shape <SHAPE>        Webcam bubble SHAPE: rect|rounded|circle|triangle|hexagon. `circle` gives the classic webcam-bubble look [default: rect]
  -h, --help                 Print help
```


### `wispframe record-camera`

```
Record a CAMERA on its own (webcam, external, or an iPhone over Continuity Camera) straight to an MP4 — no screen capture involved. The app-side equivalent of the Camera source tab

Usage: recorder record-camera [OPTIONS] --out <OUT>

Options:
      --camera <CAMERA>      Camera to use, by its uniqueID from `list-cameras`. Defaults to the system default camera
      --out <OUT>            Output file path (.mp4)
      --duration <DURATION>  Capture duration in seconds [default: 10]
      --fps <FPS>            Target frame rate [default: 30]
      --delay <DELAY>        Self-timer: count down N seconds before recording starts [default: 0]
  -h, --help                 Print help
```


### `wispframe compose-grid`

```
Compose 2–9 videos into a single scene (side-by-side, grid, rows, picture-in-picture, speaker). Implements `memory/3_4.md`. Outputs an MP4 with all sources arranged per the chosen layout

Usage: recorder compose-grid [OPTIONS] --out <OUT> --sources <SOURCES>

Options:
      --out <OUT>                Output video file (.mp4). Written atomically: frames go to '<out>.part' and are renamed onto --out only on success, so a failed run never destroys a pre-existing output file
      --width <WIDTH>            Output canvas width [default: 1920]
      --height <HEIGHT>          Output canvas height [default: 1080]
      --layout <LAYOUT>          Layout: sbs | grid | rows | pip-br | pip-tl | pip-tr | pip-bl | speaker. sbs = N equal columns; grid = auto square-ish grid; rows = N stacked rows; pip-* = source 0 fullscreen with the rest tiled from that corner; speaker = source 0 left 70%, rest stacked on the right [default: sbs]
      --sources <SOURCES>        Source videos, comma-separated (2–9 files)
      --audio-from <AUDIO_FROM>  Alias for `--audio <N>:1.0` (kept for backward compatibility): copy the audio track of the Nth source (0-based index into --sources) into the output without re-encoding. Cannot be combined with --audio
      --audio <IDX:GAIN>         Include source <IDX>'s audio at volume <GAIN> (0.0–2.0, 1.0 = unchanged), e.g. `--audio 0:1.0 --audio 2:0.6`. Repeatable — all selected tracks are mixed into ONE AAC output track. Every selected track is decoded, gained, mixed and re-encoded (48 kHz stereo AAC) — there is no passthrough path here, because the video runs on a synthetic constant-frame-rate clock the source audio timestamps no longer match. Sources without an audio track are skipped with a warning. Without any --audio/--audio-from the output has no audio track
      --offset <IDX:SECONDS>     Delay source <IDX> by <SECONDS> (0–3600): its cell stays empty (canvas black) until then, and its audio (if selected) is delayed identically. The output always runs to max(offset + duration, or the --end out-point) over all sources — NOT to source 0's length. Repeatable, e.g. `--offset 1:2.5`
      --end <IDX:SECONDS>        Stop showing source <IDX> at output time <SECONDS> (its out-point / timeline right-edge trim): after that instant its cell goes empty (canvas black) while other sources keep playing. Clamped into the source's real span, so it can shorten but never extend a clip. Repeatable, e.g. `--end 1:3` hides source 1 from t=3s on. 0-based index into --sources, mirroring --offset
      --place <IDX:X,Y,W,H>      Place source <IDX> at the explicit canvas-pixel rect X,Y,W,H (free video-mixer placement), e.g. `--place 1:1200,600,600,400`. Repeatable. Any --place switches the layout to manual: placed sources use their rect AS GIVEN (stretched to fill it — an explicit rect is intent, no letterboxing, same as compose --rect); unplaced sources keep their --layout cell (aspect-fit). Rects may overlap; sources draw in index order, so a higher index draws on top. Rects must lie inside the canvas
      --opacity <IDX:PCT>        Draw source <IDX> at <PCT> percent opacity (1–100, default 100): its cell is alpha-blended over whatever is beneath it (the canvas or lower-index sources), e.g. `--opacity 1:60`. Repeatable — combine with --place for overlapping mixer-style composites
      --shape <IDX:KIND>         Per-source cell SHAPE: `IDX:KIND` with KIND one of rect|rounded|circle|triangle|hexagon — e.g. `--shape 1:circle` turns source 1 into a webcam-style circle bubble. Repeatable
      --layout-key <LAYOUT_KEY>  Timed layout keyframe `T:LAYOUT[:TRANS[:DUR[:i=x,y,w,h;…]]]` — switch the arrangement mid-video (e.g. `12:grid:ease:0.6`, or a custom arrangement `40:custom:slide:0.5:0=0,0,960,1080;1=…`). TRANS is cut|slide|ease|fade (default cut; fade cross-dissolves the two arrangements), DUR seconds (default 0.5). Repeatable; with any keyframe present, --place is ignored
  -h, --help                     Print help
```


### `wispframe record-all`

```
Record ALL connected displays simultaneously, each to its own file. Implements `memory/4_1.md` (multi-monitor concurrent capture)

Usage: recorder record-all [OPTIONS] --out-template <OUT_TEMPLATE>

Options:
      --duration <DURATION>
          Capture duration in seconds [default: 10]
      --fps <FPS>
          Target FPS [default: 30]
      --hide-cursor
          Hide the mouse cursor in every recording
      --out-template <OUT_TEMPLATE>
          Output path template. `[n]` (or the equivalent curly-brace form) is replaced with the display id. e.g. /tmp/multi-[n].mp4 → /tmp/multi-1.mp4, /tmp/multi-3.mp4
      --system-audio
          Capture system audio into EVERY per-display recording (each file gets the same machine-audio track). Combine with --mic to mix
      --mic
          Capture the default microphone into every per-display recording. Combine with --system-audio to mix both into one track
      --system-audio-gain <SYSTEM_AUDIO_GAIN>
          Volume applied to the system audio (0.0–2.0, 1.0 = unchanged) [default: 1]
      --mic-gain <MIC_GAIN>
          Volume applied to the microphone (0.0–2.0, 1.0 = unchanged) [default: 1]
  -h, --help
          Print help
```


### `wispframe record-live-grid`

```
Record 2–9 live sources composited into ONE MP4 *during* capture (Task 40 "Merge live"). The live analog of `compose-grid`: no offline post-merge step — the output file is finished the moment you stop. Opt-in; costs more CPU/GPU than record-separately-then-merge because every source frame is read back GPU→CPU for compositing

Usage: recorder record-live-grid [OPTIONS] --sources <SOURCES> --out <OUT>

Options:
      --sources <SOURCES>  Sources, comma-separated: `display:<id>` / `d<id>` for a display, `window:<id>` / `w<id>` for a window (ids from `list-sources`). e.g. `display:1,window:42`
      --layout <LAYOUT>    Layout: sbs|grid|rows|pip-br|pip-bl|pip-tr|pip-tl|speaker [default: grid]
      --canvas <CANVAS>    Output canvas: 1080p|1440p|4k|8k or explicit WxH (e.g. 1920x1080) [default: 1080p]
      --out <OUT>          Output file (.mp4, or .mov for --codec prores)
      --seconds <SECONDS>  Capture duration in seconds [default: 10]
      --fps <FPS>          Target/output frame rate [default: 30]
      --codec <CODEC>      Output codec: h264|hevc|prores [default: h264]
      --system-audio       Capture system audio (once, globally) into the merged file
      --shape <IDX:KIND>   Per-source cell SHAPE `IDX:KIND` (KIND: rect|rounded|circle| triangle|hexagon), applied LIVE — e.g. `--shape 1:circle` records source 1 as a webcam-style circle bubble. Repeatable
  -h, --help               Print help
```


### `wispframe burn-timer`

```
Burn a running elapsed-time clock (MM:SS pill) into a video — the headless twin of the GUI's "Burn elapsed time" toggle

Usage: recorder burn-timer [OPTIONS] --in <IN> --out <OUT>

Options:
      --in <IN>              Input video file (.mp4/.mov)
      --out <OUT>            Output video file (.mp4). Written atomically via '<out>.part'
      --position <POSITION>  Clock position: top | bottom | bottom-left | bottom-right [default: bottom-right]
  -h, --help                 Print help
```


### `wispframe annotate-image`

```
Render annotations onto a still image (PNG/JPEG) — the headless twin of the GUI screenshot Annotate editor. Uses the same JSON spec as `annotate-video`; timing fields are ignored (everything is drawn)

Usage: recorder annotate-image --in <IN> --out <OUT> --spec <SPEC>

Options:
      --in <IN>      Input image (.png/.jpg)
      --out <OUT>    Output image (.png). Written atomically via '<out>.part'
      --spec <SPEC>  Path to the JSON annotations spec file (same format as annotate-video)
  -h, --help         Print help
```


### `wispframe to-gif`

```
Convert an MP4 to an animated GIF (short-clip sharing). Implements `memory/5_3.md`

Usage: recorder to-gif [OPTIONS] --in <IN> --out <OUT>

Options:
      --in <IN>        Input video file (.mp4)
      --out <OUT>      Output GIF file (.gif)
      --width <WIDTH>  Output width in pixels (height auto-scales to preserve aspect) [default: 640]
      --fps <FPS>      Target frame rate (downsamples the source; 15fps is plenty for GIF) [default: 15]
  -h, --help           Print help
```


### `wispframe trim`

```
Trim/cut a recording to only chosen time ranges, concatenated seamlessly into one shorter clip. Implements Task 32 (roadmap item 6). VFR-safe (CFR-resampled like compose-grid); audio is re-timed and re-encoded to match. Give either `--keep` (segments to keep) or `--cut` (segments to remove → the complement is kept), each repeatable

Usage: recorder trim [OPTIONS] --in <IN> --out <OUT>

Options:
      --in <IN>           Input video file (.mp4/.mov)
      --out <OUT>         Output video file (.mp4). Written atomically via '<out>.part'
      --keep <START-END>  A time range to KEEP, "START-END" in seconds (e.g. 2-5). Repeatable; ranges are sorted and merged. Mutually exclusive with --cut
      --cut <START-END>   A time range to REMOVE, "START-END" in seconds. Repeatable; the kept output is the complement. Mutually exclusive with --keep
  -h, --help              Print help
```


### `wispframe annotate-video`

```
Bake timed annotations (arrows / boxes / ellipses / lines / pen / highlighter / text / numbered steps / blur) into a video. Each annotation carries a time range and is drawn only on the frames within it. The set is given as a JSON spec file (see `--spec`). VFR-safe (CFR-resampled like trim), atomic `<out>.part` write, free-tier gated

Usage: recorder annotate-video --in <IN> --out <OUT> --spec <SPEC>

Options:
      --in <IN>      Input video file (.mp4/.mov)
      --out <OUT>    Output video file (.mp4). Written atomically via '<out>.part'
      --spec <SPEC>  Path to a JSON file describing the annotations. It is a JSON array of objects, each `{ "kind": <tool>, ...geometry, "start": S, "end": E }` where `start`/`end` are output seconds (`end` may be null = open-ended). Kinds: arrow, rect, ellipse, line, pen, highlight, text, step, blur. Geometry uses frame pixel coordinates: two-point shapes take `a: [x,y]` + `b: [x,y]`; pen takes `points: [[x,y],...]`; text takes `pos: [x,y]` + `text`; step takes `pos: [x,y]` + `n`. Optional `color: [r,g,b,a]`, `width`, `size`, `radius`
  -h, --help         Print help
```


### `wispframe speed`

```
Change playback speed — a single global factor for the whole clip, or per-segment ramps ("speed 10-20s to 4×"). Implements Task 33 (roadmap item 7). VFR-safe (CFR-resampled like trim); video is re-timed by frame resampling onto the warped clock. Give either `--factor` (global) or one or more `--segment A-B:F`

Usage: recorder speed [OPTIONS] --in <IN> --out <OUT>

Options:
      --in <IN>                     Input video file (.mp4/.mov)
      --out <OUT>                   Output video file (.mp4). Written atomically via '<out>.part'
      --factor <FACTOR>             Global speed factor for the whole clip (0.25–4.0; >1 faster, <1 slower). Mutually exclusive with --segment
      --segment <START-END:FACTOR>  A per-segment ramp "START-END:FACTOR" in seconds (e.g. 4-8:4 plays 4–8 s at 4×). Repeatable; segments must not overlap. Uncovered regions stay 1×. Mutually exclusive with --factor
      --audio <AUDIO>               Audio handling: "pitch" (resample to follow the video — pitch shifts) or "mute" (silence in non-1× regions). Default: pitch for a global --factor, mute for --segment ramps
  -h, --help                        Print help
```


### `wispframe studio`

```
"Studio polish" an existing recording into the Screen-Studio framed look: the video shrunk + centered on a padded canvas with a background (gradient / solid / image / a blurred copy of the video itself), rounded corners, and a soft drop shadow. Implements Task 25 (phase 1). VFR-safe (CFR-resampled like compose-grid); audio is re-timed and re-encoded

Usage: recorder studio [OPTIONS] --in <IN> --out <OUT>

Options:
      --in <IN>                    Input video file (.mp4/.mov)
      --out <OUT>                  Output video file (.mp4). Written atomically via '<out>.part'
      --bg <BG>                    Background: "blur" (blurred scaled-up copy of the video), "gradient:NAME" (Dusk|Sunset|Mint|Slate|Ocean|Mono), "color:#rrggbb" (or black|white|green), or "image:PATH" [default: blur]
      --padding <PADDING>          Uniform padding around the video as a percent of the shorter canvas side (0–30) [default: 8]
      --radius <RADIUS>            Corner radius in pixels (clamped to the video size) [default: 16]
      --shadow <SHADOW>            Drop-shadow strength, 0–100 (0 = no shadow) [default: 60]
      --canvas <CANVAS>            Output canvas size "WxH" (e.g. 1920x1080). Defaults to the source's own dimensions
      --crop <CROP>                Crop each frame to a sub-rectangle "x,y,w,h" (source pixels) BEFORE framing — only that region is studio-polished and saved
      --zoom                       Enable cursor-tracked auto-zoom + click highlights (Task 27). Needs a `<in>.cursor.json` sidecar (written by the recorder when "Record cursor" is on); without it this is a no-op
      --zoom-max <ZOOM_MAX>        Peak zoom factor when `--zoom` is set (e.g. 2.0 = 200%) [default: 2]
      --zoom-follow                Follow the cursor continuously instead of zooming only around clicks
      --click-rings <CLICK_RINGS>  Draw an expanding highlight ring at each click (with `--zoom`) [default: true] [possible values: true, false]
      --keys                       Overlay pressed-key pills (Task 31) for tutorials. Needs a `<in>.keys.json` sidecar (written by `record --record-keys` with Accessibility granted); without it this is a no-op
  -h, --help                       Print help
```


### `wispframe subtitle`

```
Burn styled captions into a video from an SRT/VTT/JSON file, or convert a caption file between SRT and VTT. Pick one of the 22 presets with `--preset` (Clean, "Bold Outline", Karaoke, "Word Pop", "Pop Word", "Pop Duo", "Glow Pop", "Highlight Box", "Boxed Pop", "Boxed Line", Aurora, Sunset, Ocean, "Lower Third", Neon, Typewriter, "Minimal Caps", Serif, Editorial, Script, Impact, Creator) and override individual bits with the style flags. See docs/plans/feliratozas-es-felskalazas-terv.md

Usage: recorder subtitle [OPTIONS]

Options:
      --in <IN>                  Input video (.mp4/.mov). Omit with `--export` to only convert a caption file
      --captions <CAPTIONS>      Caption file to burn (.srt, .vtt, or .json). A .json transcript keeps per-word timing, so karaoke / active-word-highlight styles animate. Optional only with --dump-style
      --out <OUT>                Output path: the captioned video, or (with `--export`) the .srt/.vtt. Not needed with --dump-style
      --export                   Only convert `--captions` to the format implied by `--out`'s extension (.srt/.vtt) — no video, no burn-in
      --preset <PRESET>          Style preset name (default "Clean") [default: Clean]
      --font-size <FONT_SIZE>    Font size as a percent of frame height (overrides the preset)
      --position <POSITION>      Vertical position: top | middle | bottom
      --font <FONT>              Font family key (e.g. Inter, Montserrat, Poppins)
      --style <STYLE>            Full style from a JSON file (every field individually configurable; overrides --preset). Get a starting file with --dump-style
      --dump-style <DUMP_STYLE>  Write the resolved style (from --preset + flags) to this JSON file and exit — an editable template for --style. No burn
  -h, --help                     Print help
```


### `wispframe transcribe`

```
Transcribe a video's speech to captions with an on-device Whisper model, writing an .srt or .vtt (by `--out`'s extension). The model is downloaded and cached on first use. See docs/plans/feliratozas-es-felskalazas-terv.md

Usage: recorder transcribe [OPTIONS] --in <IN> --out <OUT>

Options:
      --in <IN>        Input video (.mp4/.mov) with an audio track
      --out <OUT>      Output caption file: .srt / .vtt (cue-level) or .json (keeps per-word timing for karaoke / active-word highlight)
      --model <MODEL>  Model: Whisper (on-device) large-v3-turbo (default) | large-v3 | medium | small | base. Builds with the `python-backends` feature also accept Qwen3-ASR (qwen3-0.6b | qwen3-1.7b); the App Store build does not ship it [default: large-v3-turbo]
      --lang <LANG>    Language hint (e.g. hu, en). Omit to auto-detect
      --words          Emit per-word timestamps (for karaoke/word-pop styles)
  -h, --help           Print help
```


### `wispframe enhance`

```
Upscale / enhance a video with Real-ESRGAN (on-device, ONNX + CoreML). Each frame is super-resolved and sharpened; audio passes through. The model is downloaded and cached on first use. This is a heavy pass. See docs/plans/feliratozas-es-felskalazas-terv.md

Usage: recorder enhance [OPTIONS] --in <IN> --out <OUT>

Options:
      --in <IN>            Input video (.mp4/.mov)
      --out <OUT>          Output video (.mp4)
      --model <MODEL>      Model: general (default, compact + fast) | sharp (full RRDBNet x4plus, fp16, more detail — ANE) | apple (Apple's built-in VTSuperResolutionScaler, macOS 15.4+, native ANE/GPU, model auto-downloaded once). Aliases: photo/hq → sharp; native/vt → apple [default: general]
      --scale <SCALE>      Upscale factor, clamped to 2–4 (3 is accepted and produces a real 3x output; anything outside the range is clamped, not rejected) [default: 2]
      --max <MAX>          Cap the output size to fit "WxH" (preserving aspect); e.g. 3840x2160
      --denoise <DENOISE>  Denoise strength 0.0–1.0 (0 = sharpest, higher = smoother/cleaner) [default: 0.3]
  -h, --help               Print help
```


### `wispframe screenshot`

```
Capture a still-image screenshot to a PNG (task 34, phase 1). Full display, a single window, or a dragged region — the headless counterpart of the UI screenshot buttons. Triggers the Screen Recording permission prompt on first use

Usage: recorder screenshot [OPTIONS] --out <OUT>

Options:
      --display <DISPLAY>  Display id from `list-sources` (used for --full and --region) [default: 1]
      --full               Capture the WHOLE display (default when neither --region nor --window is given)
      --region <REGION>    Capture only a sub-rect of the display: "x,y,w,h" in the display's native backing pixels. Out-of-bounds rects clamp to the display
      --delay <DELAY>      Self-timer: count down N seconds before capturing (the GUI's ⏱ Countdown) [default: 0]
      --window <WINDOW>    Capture a single window by its id (from `list-sources`), at native size. Overrides --display / --region
      --out <OUT>          Output PNG path
      --clipboard          Also copy the captured image to the clipboard (as PNG)
  -h, --help               Print help
```


### `wispframe pick-color`

```
Sample the colour of a single pixel on a display and print its HEX + RGB (task 36) — the headless counterpart of the UI eyedropper. Grabs the display and reads the pixel at `--at x,y` (native backing pixels, top-left origin). Triggers the Screen Recording prompt on first use

Usage: recorder pick-color [OPTIONS] --at <AT>

Options:
      --display <DISPLAY>  Display id from `list-sources` [default: 1]
      --at <AT>            Pixel to sample: "x,y" in the display's native backing pixels
      --clipboard          Also copy the HEX value to the clipboard
  -h, --help               Print help
```


### `wispframe scroll-capture`

```
Scrolling screenshot capture (task 37): auto-scroll a region and stitch the frames into ONE tall PNG (a long web page, chat log, or document). Posting scroll events to another app needs the Accessibility permission (see `x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility`); without it the grabs still work but the page may not move. Screen Recording is required for the grabs

Usage: recorder scroll-capture [OPTIONS] --region <REGION> --out <OUT>

Options:
      --display <DISPLAY>        Display id from `list-sources` [default: 1]
      --region <REGION>          The scrollable rect to capture: "x,y,w,h" in the display's native backing pixels (top-left origin). Position it over the scrollable content so the synthetic scroll lands inside it
      --out <OUT>                Output PNG path for the stitched tall image
      --max-frames <MAX_FRAMES>  Safety cap on captured frames (default 60)
      --step-frac <STEP_FRAC>    Fraction of the region height to scroll per step (default 0.66)
      --clipboard                Also copy the stitched image to the clipboard (as PNG)
  -h, --help                     Print help
```


### `wispframe license`

```
Show or manage the license/subscription state. Freemium: recording is unlimited on Free; processed exports carry a watermark. `license activate annual|lifetime` simulates a Pro purchase (the real StoreKit purchase flow arrives with `memory/6_2.md`)

Usage: recorder license [OPTIONS]

Options:
      --action <ACTION>    Action: "status" (default), "activate annual|lifetime", or "deactivate" [default: status]
      --product <PRODUCT>  Product to activate: "monthly", "annual", or "lifetime" (one payment, forever). Only with --action activate [default: monthly]
  -h, --help               Print help
```

---

Docs: https://wispframe.app/cli · https://wispframe.app/mcp
