Adom Video Post-Production
Public Made by Adomby adom
AI-driven studio for finishing demo videos: review every captured clip, flag and re-record the weak ones, speed up dead air, narrate, mux and auto-level, validate the final cut, and publish straight to the wiki, driven end to end by your AI.
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. 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:
- Run a
setInterval(() => { engine.beginFrame(); scene.render(); engine.endFrame(); }, 33)for the duration of the recording so the canvas always paints. - Animate the camera (alpha += small_increment) on every tick so each frame has unique pixels — CDP only deduplicates pixel-identical frames.
- 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. - 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:
# 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:
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
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.
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:
# 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:
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:.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.
Force micro-motion via
browser_evalbetween 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:# 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 when the user's Hydrogen is busy):
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 — parent: orchestration, the ten failure modes, the "real demo" 10-step shape
- per-snippet-flow.md — manifest, storyboard review, mux + concat, hero, upload
- script-and-tts.md — script template, narration phonetics, captions
# 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