# Recording mechanics — capture path, surfaces, frame-rate sanity

This is the **capture-mechanism reference** for demo recordings.
Cross-cuts both Hydrogen and pup paths. Read this before touching
`recording start` / `browser_record_start` for the first time in a
session. Everything that's catastrophic-if-skipped is in the first
half of the file.

> Parent skill: [SKILL.md](SKILL.md). The "Ten failure modes" and
> "What a real demo means" sections in the parent are preconditions
> to this file. If you haven't read them, stop and go back.

## 🚨 NEVER stitch `browser_screenshot` calls into a video

**The capture mechanism for ALL pup-recorded video demos is
`adom-desktop browser_record_start` / `browser_record_stop` — full
stop.** Do not write a recorder that polls `browser_screenshot` in a
loop and ffmpeg-stitches PNGs into a webm. `browser_screenshot` is a
~770 ms-per-call one-shot devtools capture; even 4-way concurrent it
caps around 3 fps, and the resulting "video" plays as a slideshow.
Users cannot diagnose this — they just see "the demo looks horrific"
and the conversation gets uglier from there.

**Diagnostic:** if your `browser_record_start` recording came out
"frozen" (CDP screencast deduplicates identical pixel frames), the
problem is **the page didn't repaint**, NOT the capture mechanism.
Fixes go on the page side, not the recorder side:

1. Run a `setInterval(() => { engine.beginFrame(); scene.render(); engine.endFrame(); }, 33)` for the duration of the recording so the canvas always paints.
2. Animate the camera (alpha += small_increment) on every tick so each frame has unique pixels — CDP only deduplicates pixel-identical frames.
3. Strip ArcRotateCamera input handlers (`cam.inputs.removeByType('pointers'|'mousewheel'|'keyboard')`) before mutating alpha — they recompute camera state from inertia every frame and clobber direct writes.
4. UI events (hover, ctxmenu, isolate, inspector) stack ON TOP of the always-painting canvas — they never replace it.

`browser_record_start` reliably captures **20–30 fps real footage**
when the page is actually painting. That's 10–20× the rate of
screenshot-stitching, and the resulting webm scrubs / plays smooth.

**Post-process for size or framerate AFTER recording, not by
sacrificing capture rate:**

```bash
# Recorded at 30 fps; downsample to 24 fps + cut bitrate
ffmpeg -i raw.webm -c:v libvpx-vp9 -b:v 1.2M -r 24 -g 30 -keyint_min 30 \
       -c:a copy out-24fps.webm

# Or two-pass for tighter quality at a target bitrate
ffmpeg -y -i raw.webm -c:v libvpx-vp9 -b:v 1.5M -pass 1 -an -f null /dev/null
ffmpeg    -i raw.webm -c:v libvpx-vp9 -b:v 1.5M -pass 2 -c:a libopus out.webm
```

If you find yourself reaching for `browser_screenshot` polling
because `browser_record_start` "doesn't work," **stop and re-read this
section.** The capture is fine; the page isn't painting.

## 🚨 Wiki webm: keyframes every 1 s — NEVER upload raw recordings

**Every webm uploaded to the wiki must be re-encoded with a keyframe
interval of ~1 s (`-g 30 -keyint_min 30` for 30 fps).** Without that,
the wiki server's video element can't seek — the user can play the
clip start-to-end but can't scrub the timeline, jump to a section, or
share a deep-linked timestamp. `<video>` seeks land on the nearest
keyframe; without dense keyframes, the entire clip is one giant
seekable unit.

Raw `adom-cli hydrogen recording stop` output and raw CDP-frames mux
output **default to one keyframe at frame 0 only** — every subsequent
frame is a P-frame, no scrub possible. This bites every time someone
uploads a single-shot recording straight to `adom-wiki asset upload`
without going through the adom-video-post pipeline. (Bit me on
`molecules/brianna-led-nameplate`'s `brianna-demo.mp4`; bit Kyle the
same day.)

**The rule:** the LAST ffmpeg pass on any clip destined for the wiki
must include `-g 30 -keyint_min 30`. For 60 fps recordings use
`-g 60 -keyint_min 60`. The exact incantation depends on codec:

| Codec | Keyframe flags |
|---|---|
| `libvpx-vp9` (webm) | `-g 30 -keyint_min 30` |
| `libx264` (mp4) | `-g 30 -keyint_min 30 -force_key_frames "expr:gte(t,n_forced*1)"` |
| `libvpx` (legacy webm) | `-g 30 -keyint_min 30` |

The `-force_key_frames` expression on x264 is belt-and-suspenders —
some x264 builds honor `-g` as a soft hint and let scene-detection
override it; the expression forces a keyframe at integer-second
boundaries regardless.

**Verify after encode:**

```bash
ffprobe -v error -select_streams v:0 -show_entries packet=pts_time,flags \
  -of csv=p=0 final.webm | grep ',K' | head -10
# Should show keyframes (",K_") at ~0.0s, ~1.0s, ~2.0s, ...
# If you only see one ",K_" line at 0.0s, the encode dropped the rule.
```

If you find yourself running `adom-wiki asset upload --asset-type
video --file <raw>.webm` without a re-encode pass first, **stop**.
Pipe through `ffmpeg -i raw.webm -c:v libvpx-vp9 -b:v 2M -g 30
-keyint_min 30 -c:a copy out.webm` first, even on a single-shot
recording you weren't going to edit. The re-encode adds ~10 s for a
30 s clip and saves the user from a non-scrubbable wiki video.

The adom-video-post `concat` path SHOULD apply this on the final mux — if
your demo went snippet → manifest → concat, you're covered. The
single-shot path (just `recording stop` → upload) is where this gets
silently skipped, so the rule above applies *especially* there.

## Recording surface — Hydrogen by default, pup when Hydrogen is busy

The default recording surface is `adom-cli hydrogen recording start` — the
Hydrogen webview captures the full editor (panels + workspace) into a clean
webm, with `caption show` integrated.

**When Hydrogen is unavailable** — another Adom tool is occupying the webview
panels (e.g. `adom-tsci` showing a 3D preview, an existing demo storyboard
up for review) — switch to **pup** (a separate
Chrome window via Puppeteer). Pup recordings happen on the user's Windows
desktop, fully orthogonal to anything happening inside Hydrogen, so the two
surfaces never compete.

| Surface | When | Capture mechanism | Caption tooling | Notes |
|---|---|---|---|---|
| **Hydrogen** | Default. Demoing a Hydrogen-hosted feature. | `adom-cli hydrogen recording start --reason ...` → MediaRecorder on the workspace iframe. | `adom-cli hydrogen caption show "<text>"` overlays cleanly. | Best resolution + cleanest output. Don't use if another tab in the same workspace is occupying webviews you'd want to recapture later. |
| **pup** | "Aci is using webviews", "don't touch my Hydrogen layout", "the tool I'm demoing has its own UI in a Chrome tab anyway." | `adom-desktop browser_open_window` + `browser_focus_window` + `browser_record_start` (CDP screencast on the pup tab). | No `caption show` — render the caption inside the page itself with a temporary `<div>` injected via `browser_eval`, OR rely entirely on TTS narration. | Frame rate is paint-throttled by Chrome unless the window is OS-foregrounded — see "🔥 The pup foreground-rebump trick" below. Tar pulls of large recordings may exceed the 60-second WebSocket timeout — pull frames in batches of ~40 if `pull_file` returns null. |

### Third surface — the NATIVE browser (only for logged-in demos)

When a demo needs the user's **real session / cookies / logins** (a tool gated behind their account), neither
Hydrogen nor pup will do — those are throwaway/managed browsers with **no logins**. Only the user's real Chrome
has them. Record it through the **adom-browser-extension** (`nbrowser_record_start {method:"wgc"|"getdisplaymedia"}`).

**The canonical example is `chip-fetcher`.** It fetches each part's CAD by signing into the engineer's **stored
vendor accounts** — Mouser, DigiKey, SnapEDA/SnapMagic, ComponentSearchEngine, TI, etc. A pup or Hydrogen browser
isn't logged into any of those, so a chip-fetcher demo there would just hit login walls. To record chip-fetcher
(or anything that leans on the user's saved logins), you **must** drive + capture the user's real Chrome via the
extension — that's the whole reason the native-browser recording path exists.

The twist: you **must NOT foreground-rebump the user's real working browser** (the pup trick below would wreck
their flow). Instead, make their Chrome **paint while occluded** by launching it with the occlusion/throttle-disable
flags — a restart-into-background-recording mode you enter, record in, then **offer to revert** (battery). **Full
recipe: the [`native-browser-recording`](https://wiki.adom.inc/john/adom-browser-extension/files/skills/native-browser-recording/SKILL.md)
skill in the adom-browser-extension.** Use this surface ONLY when stored logins are required; otherwise prefer
Hydrogen or pup.

### 🔥 The pup foreground-rebump trick — required for high frame rate

Chrome **paint-throttles** any tab whose window is not the OS foreground. CDP `Page.startScreencast` only emits a frame when the page repaints, so a backgrounded pup tab silently drops to ~5 fps even when you asked for 15 fps. Symptoms: 275 captured frames in 46 s of wall-clock = ~6 fps real, video plays back ~3× faster than reality, narration desyncs from action.

**The trick:** `browser_focus_window` calls Win32 `SetForegroundWindow` to raise the pup window to the OS foreground. Calling it ONCE before `browser_record_start` is not enough — anything that grabs OS focus during the recording (Hydrogen tab switch, a notification, the user clicking elsewhere) re-throttles the pup tab. The fix is to **re-bump foreground before every action**, not just at the start.

```bash
SESS="demo-snippet"

# Open + initial focus
adom-desktop browser_open_window "$(jq -nc --arg s "$SESS" --arg u "$URL" \
  '{sessionId:$s, profile:$s, url:$u}')"
sleep 4
adom-desktop browser_focus_window "$(jq -nc --arg s "$SESS" '{sessionId:$s}')"
sleep 1

# Re-bump RIGHT BEFORE record_start — this is the critical one
adom-desktop browser_focus_window "$(jq -nc --arg s "$SESS" '{sessionId:$s}')"
RID=$(adom-desktop browser_record_start "$(jq -nc --arg s "$SESS" \
  '{sessionId:$s, fps:15, quality:75}')" | jq -r '.recordingId')

# Wrap every action so foreground gets re-bumped before each one
focused_eval() {
  adom-desktop browser_focus_window "$(jq -nc --arg s "$SESS" '{sessionId:$s}')" >/dev/null
  adom-desktop browser_eval "$(jq -nc --arg s "$SESS" --arg e "$1" \
    '{sessionId:$s, expr:$e}')" >/dev/null
}

focused_eval 'document.getElementById("btn-foo").click()'
sleep 3                        # short sleeps are fine — focus persists across them
focused_eval 'document.getElementById("btn-bar").click()'
sleep 3
adom-desktop browser_record_stop "$(jq -nc --arg s "$SESS" --arg r "$RID" \
  '{sessionId:$s, recordingId:$r}')"
```

**Verification — sniff-test the captured frame count vs wall-clock duration:**

```bash
# After every recording, check this BEFORE you commit to mux + TTS:
STOP=$(adom-desktop browser_record_stop ...)
FRAMES=$(echo "$STOP" | jq -r '.frameCount')
DUR_MS=$(echo "$STOP" | jq -r '.durationMs')
ACTUAL_FPS=$(python3 -c "print(${FRAMES}/(${DUR_MS}/1000))")
echo "captured ${ACTUAL_FPS} fps (asked for 15)"
# Below 12 fps → the window dropped foreground at some point. Re-record with the rebump pattern, don't try to mask with playback speedup.
```

**Don't substitute `browser_alert_window`** — it flashes the taskbar but explicitly does NOT steal foreground (so Chrome stays paint-throttled). Don't substitute `desktop_bring_to_front` either — it works but per the project memory ("use browser_focus_window for pup, not desktop_bring_to_front"), the browser-typed call is the canonical one.

**Side-effect to know about:** because `SetForegroundWindow` is OS-level, the user briefly sees the pup window raise to front during recording. Tell them in advance ("you'll see a Chrome window pop forward for ~30 s") so they don't think your code is fighting their workspace.

### Foreground alone isn't enough — also keep something painting

Foreground-rebump fixes Chrome's window-level paint throttle, but there's a **second** throttle: CDP `Page.startScreencast` is **event-driven**. It only emits a frame when the page actually repaints. A static, idle page (e.g. "user looking at the player view, video paused, no animation") produces zero frames per second even if the window is fully foregrounded.

Symptom: 7 seconds of "let the player view sit" captures 1 frame. The whole demo plays back too fast.

Two fixes, pick one:

1. **Keep a small animation always running on the page.** A 1-second CSS pulse on a non-distracting element does it — e.g. on the `.hud .dot`:

   ```css
   .hud .dot { animation: hud-pulse 1s infinite ease-in-out; }
   @keyframes hud-pulse { 0%,100%{opacity:.6} 50%{opacity:1} }
   ```

   This keeps the compositor running, which keeps frames flowing through CDP. Cost: a barely-visible animated dot. Benefit: full requested frame rate during static sections.

2. **Force micro-motion via `browser_eval`** between actions when you can't add CSS to the target page. Inject a 1-pixel scroll back-and-forth every 200 ms, or update a CSS variable that affects a transform:

   ```bash
   # Background loop nudging the page during the recording
   (
     while [ -f /tmp/avdemo/.recording ]; do
       focused_eval 'document.documentElement.style.setProperty("--nudge", Math.random())'
       sleep 0.2
     done
   ) &
   ```

**Sniff-test** with the same `actual_fps` formula above: if it's still <12 after the foreground rebump, the issue is paint-throttling on a static page, not OS focus.

### Before you re-record — try Hydrogen instead

If you've fought pup paint-throttling for two attempts and the demo still isn't smooth, **switch to Hydrogen recording** (`adom-cli hydrogen recording start`). Hydrogen captures the workspace via `getDisplayMedia` + `MediaRecorder`, which polls the framebuffer at the requested rate regardless of whether the page is repainting. Cost: you need the Hydrogen workspace free of competing tabs. Benefit: full 30 fps with no rebump tricks.

Pup is the right answer when Hydrogen is genuinely occupied. When Hydrogen is free, prefer it.

**Pup-recording per-section pattern** (substitute for the Hydrogen recording
block in [per-snippet-flow.md](per-snippet-flow.md) when the user's Hydrogen is busy):

```bash
URL="https://<container>.adom.cloud/proxy/$PORT/"   # the proxied URL of the tool being demoed

# Per section:
SESS="demo-$ID"
adom-desktop browser_open_window "$(jq -nc --arg s "$SESS" --arg u "$URL" \
  '{sessionId:$s, profile:$s, url:$u}')"
sleep 4
adom-desktop browser_focus_window "$(jq -nc --arg s "$SESS" '{sessionId:$s}')"
sleep 1

RID=$(adom-desktop browser_record_start "$(jq -nc --arg s "$SESS" \
  '{sessionId:$s, fps:15, quality:75}')" | jq -r '.recordingId')

# ... drive the section via browser_eval calls ...
adom-desktop browser_eval "$(jq -nc --arg s "$SESS" --arg e "<JS>" \
  '{sessionId:$s, expr:$e}')"

STOP=$(adom-desktop browser_record_stop "$(jq -nc --arg s "$SESS" --arg r "$RID" \
  '{sessionId:$s, recordingId:$r}')")
TAR=$(echo "$STOP" | jq -r '.tarPath')

adom-desktop pull_file "$(jq -nc --arg t "$TAR" '{filePaths:[$t], saveTo:"/tmp/demo/tar"}')"
adom-desktop browser_close_window "$(jq -nc --arg s "$SESS" '{sessionId:$s}')"

# Mux the CDP frames → webm
EXTRACT=/tmp/demo/tar/${ID}-extract && mkdir -p "$EXTRACT"
tar -xf "/tmp/demo/tar/$(basename "$TAR")" -C "$EXTRACT"
FRAME_DIR=$(find "$EXTRACT" -name ffmpeg-concat.txt -printf '%h\n' | head -1)
( cd "$FRAME_DIR" && ffmpeg -y -loglevel error -f concat -safe 0 \
    -i ffmpeg-concat.txt -c:v libvpx-vp9 -b:v 2M -deadline realtime \
    -cpu-used 8 -row-mt 1 -g 30 -keyint_min 30 \
    -an "/tmp/demo/raw/${ID}.webm" )

# Then standard TTS + mux + manifest add (same as Hydrogen path —
# see per-snippet-flow.md)
adom-tts say "<narration>" --out /tmp/demo/tts/${ID}.mp3 --voice en-US-AndrewMultilingualNeural
ffmpeg -y -loglevel error -i /tmp/demo/raw/${ID}.webm -i /tmp/demo/tts/${ID}.mp3 \
  -filter_complex "[0:v]tpad=stop_mode=clone:stop_duration=10[v]" \
  -map "[v]" -map 1:a -c:v libvpx-vp9 -b:v 2M -deadline realtime -cpu-used 8 \
  -g 30 -keyint_min 30 \
  -c:a libopus -b:a 96k -shortest /tmp/demo/narrated/${ID}.webm
adom-video-post manifest add /tmp/demo/manifest.json --id "$ID" --title "..." \
  --description "..." --raw /tmp/demo/narrated/${ID}.webm /tmp/demo/narrated/${ID}.webm
```

The `tpad=stop_mode=clone:stop_duration=10` filter holds the last frame so
short recordings don't cut the narration off — `-shortest` then trims the
output to whichever stream ends first (almost always the audio).

## See also

- [SKILL.md](SKILL.md) — parent: orchestration, the ten failure modes, the "real demo" 10-step shape
- [per-snippet-flow.md](per-snippet-flow.md) — manifest, storyboard review, mux + concat, hero, upload
- [script-and-tts.md](script-and-tts.md) — script template, narration phonetics, captions
