← Commit history
Cargo.tomladded+22
@@ -0,0 +1,22 @@+[package]+name = "adom-video-post"+version = "1.0.1"+edition = "2021"+description = "Post-process screen recordings: speed up boring sections with overlay captions, voiceover after the fact, publish to the Adom Wiki"+license = "MIT"++[[bin]]+name = "adom-video-post"+path = "src/main.rs"++[dependencies]+serde = { version = "1", features = ["derive"] }+serde_json = "1"+clap = { version = "4", features = ["derive", "env"] }+tiny_http = "0.12"+uuid = { version = "1", features = ["v4"] }++[profile.release]+strip = true+lto = "thin"+opt-level = "z"
README.md+27−27
@@ -1,6 +1,6 @@-# video-post+# adom-video-post -A demo recording post-production CLI for the Adom platform.+The **AI-driven studio for finishing demo videos.** Capture a demo as a series of clips, then review every snippet, flag and re-record the weak ones ("clip 3 is awful — redo it with…"), speed up the dead air, narrate, mux + auto-level the audio, validate the final cut, and publish to the wiki — driven end-to-end by your AI, no DaVinci Resolve.  ## Why this exists @@ -18,11 +18,11 @@ We also tried recording at a faster monitor refresh rate to make the playback fe  The actual fix is **explicit "this part is boring, speed it up later" markers dropped during the demo flow, then a single post-process pass that turns the marked sections into 10x-100x timelapses with a clear visual indicator** (a "20x SPEEDUP: Exporting gerbers" red banner). The viewer sees the slow part happen, understands what was happening, and isn't asked to sit through 30 seconds of an unchanged screen. -That's video-post phase 1. Phase 2 is the Video Post-Processing app: open the sped-up video in a Hydrogen webview, hit record, narrate over it, auto-level the audio (EBU R128 broadcast standard), keep every take and every auto-leveled version as separate tabs, save whichever you like as the final. Phase 3 adds wiki publishing so the final narrated video gets attached to the wiki page for the artifact you demoed.+That's adom-video-post phase 1. Phase 2 is the Adom Video Post-Processing app: open the sped-up video in a Hydrogen webview, hit record, narrate over it, auto-level the audio (EBU R128 broadcast standard), keep every take and every auto-leveled version as separate tabs, save whichever you like as the final. Phase 3 adds wiki publishing so the final narrated video gets attached to the wiki page for the artifact you demoed. -## What's in the Video Post-Processing app (phase 2)+## What's in the Adom Video Post-Processing app (phase 2) -The voiceover subcommand is a full webview app, not just a "record button". Running `video-post voiceover --input sped-up.webm --markers markers.jsonl` opens a Hydrogen tab with:+The voiceover subcommand is a full webview app, not just a "record button". Running `adom-video-post voiceover --input sped-up.webm --markers markers.jsonl` opens a Hydrogen tab with:  - **Speedup Timeline card** showing the source video as a horizontal bar, speedup regions highlighted, summary stats (source vs output duration, seconds saved, % shorter, number of speedup segments). Populated from the markers file the `process` step wrote. - **Video tab strip** above the player: "Original" (silent source), "Take 1" (your raw voiceover mux), "Take 1 auto-leveled" (same take with loudness normalized), "Take 2", "Take 2 auto-leveled", and so on. Every finalize produces two new tabs at once. Click any tab to play that version. Every tab is a separate file on disk; nothing is ever overwritten.@@ -52,7 +52,7 @@ So it lives in its own repo, follows the standard Rust + clap + SKILL.md + wiki-  > **Recording skills live here now.** The full demo-recording skill (script → record → mux → storyboard → publish, with the Hydrogen / pup / native-browser capture mechanics) is bundled under [`skills/demo-recording/`](skills/demo-recording/SKILL.md) — homed in this repo since gallia was retired. Recording the user's **real, logged-in** browser at full framerate in the background is the adom-browser-extension's `native-browser-recording` skill. -`adom-desktop`'s `scripts/demo-record.sh` is the consumer — it calls `video-post mark init` after Hydrogen recording starts and `video-post process` after it stops. But video-post itself doesn't know about adom-desktop. Any other recording orchestrator (even a hand-rolled bash script for a one-off demo) can use video-post the same way.+`adom-desktop`'s `scripts/demo-record.sh` is the consumer — it calls `adom-video-post mark init` after Hydrogen recording starts and `adom-video-post process` after it stops. But adom-video-post itself doesn't know about adom-desktop. Any other recording orchestrator (even a hand-rolled bash script for a one-off demo) can use adom-video-post the same way.  ## What it does (3 phases) @@ -62,21 +62,21 @@ Drop start/end markers around slow sections during the demo, then run a single c  ```bash # At the start of recording-video-post mark init+adom-video-post mark init  # Wrap any slow command (this is the most common pattern)-video-post wrap --speed 20 --label "Exporting gerbers" -- adom-desktop fusion_export_gerbers-video-post wrap --speed 15 --label "Pulling source files" -- pull_and_analyze "C:/tmp/foo.fbrd"+adom-video-post wrap --speed 20 --label "Exporting gerbers" -- adom-desktop fusion_export_gerbers+adom-video-post wrap --speed 15 --label "Pulling source files" -- pull_and_analyze "C:/tmp/foo.fbrd"  # Or drop manual start/end markers if you need finer control-video-post mark start --speed 50 --label "Cloud search"+adom-video-post mark start --speed 50 --label "Cloud search" # ... slow operation ...-video-post mark end+adom-video-post mark end  # After Hydrogen recording stops, post-process-video-post process \+adom-video-post process \   --input  /home/adom/project/recordings/demo.webm \-  --markers /tmp/video-post-markers.jsonl+  --markers /tmp/adom-video-post-markers.jsonl # → produces /home/adom/project/recordings/demo-fast.webm ``` @@ -85,7 +85,7 @@ The output video has the marked sections accelerated (default cap 100x) with a r ### Phase 2 — Voiceover overlay ✅ shipped  ```bash-video-post voiceover --input demo-fast.webm+adom-video-post voiceover --input demo-fast.webm ```  Starts a tiny HTTP server and opens a Hydrogen webview tab with a video player, a record button, and a real-time audio waveform indicator. You watch the sped-up video and narrate over it. When you stop recording, ffmpeg muxes the audio with the video to produce the final narrated output.@@ -93,7 +93,7 @@ Starts a tiny HTTP server and opens a Hydrogen webview tab with a video player, ### Phase 3 — Wiki publish ✅ shipped  ```bash-video-post publish \+adom-video-post publish \   --input demo-fast-narrated.webm \   --page apps/adom-desktop \   --caption "Fusion 360 deep dive: BQ25792 manufacturing pipeline"@@ -107,20 +107,20 @@ Wraps `adom-wiki asset upload` to attach the final narrated video to whichever w # 1. Record (silent, no voiceover) adom-cli hydrogen sharing request --share screen --reason "Demo recording" adom-cli hydrogen recording start --countdown 3-video-post mark init+adom-video-post mark init -# (run your demo, wrapping slow sections with `video-post wrap`)+# (run your demo, wrapping slow sections with `adom-video-post wrap`)  adom-cli hydrogen recording stop  # → demo.webm  # 2. Speedup pass-video-post process --input demo.webm+adom-video-post process --input demo.webm  # 3. Voiceover (Phase 2)-video-post voiceover --input demo-fast.webm+adom-video-post voiceover --input demo-fast.webm  # 4. Publish (Phase 3)-video-post publish \+adom-video-post publish \   --input demo-fast-narrated.webm \   --page apps/adom-desktop \   --caption "Fusion 360 deep dive"@@ -130,7 +130,7 @@ Four commands. Total wall-clock time under 10 minutes for a demo that captured 3  ## How the AI uses it -Adom users mostly don't run video-post directly — their Claude Code session does. The skill file (`SKILL.md`) is auto-installed to `~/.claude/skills/video-post/` and Claude Code finds it via trigger words like "speed up demo recording", "timelapse video", "voiceover after recording", "publish demo to wiki".+Adom users mostly don't run adom-video-post directly — their Claude Code session does. The skill file (`SKILL.md`) is auto-installed to `~/.claude/skills/adom-video-post/` and Claude Code finds it via trigger words like "speed up demo recording", "timelapse video", "voiceover after recording", "publish demo to wiki".  Once Claude finds the skill, it knows the marker workflow and the four-command pipeline above. The AI drops markers as it runs commands during the demo, then runs `process` automatically after the recording stops, and offers to run `voiceover` and `publish` next. @@ -140,17 +140,17 @@ This is the same pattern as `adom-molecule` (Claude drives the molecule import w  ```bash # From source-git clone https://github.com/adom-inc/video-post-cd video-post+git clone https://github.com/adom-inc/adom-video-post+cd adom-video-post ./build.sh  # After v0.1.0 release: from the wiki auto-discovery flow-gh release download v0.1.0 --repo adom-inc/video-post --pattern video-post -D /usr/local/bin-chmod +x /usr/local/bin/video-post-video-post install+gh release download v0.1.0 --repo adom-inc/adom-video-post --pattern adom-video-post -D /usr/local/bin+chmod +x /usr/local/bin/adom-video-post+adom-video-post install ``` -`video-post install` deploys the SKILL.md to `~/.claude/skills/video-post/` so Claude Code in this container picks it up immediately.+`adom-video-post install` deploys the SKILL.md to `~/.claude/skills/adom-video-post/` so Claude Code in this container picks it up immediately.  ## Prerequisites 
SKILL.md+59−59
@@ -1,5 +1,5 @@ ----name: video-post+name: adom-video-post description: >   Post-production for demo recordings: speed up boring parts with overlay   captions, add voiceover narration after the fact, or publish the final video@@ -11,7 +11,7 @@ description: >   speedup, demo post-process, post-process demo, publish demo video to wiki. --- -# video-post+# adom-video-post  Post-production CLI for demo recordings. For pre-production and recording (writing scripts, setting up panels, driving the recording), use the@@ -32,23 +32,23 @@ the feedback loop from precise ("clip 5 is wrong") to useless ("something's off somewhere"). Don't do that to them.  **The rule, in one line:** after `manifest add` for all clips, the next-command MUST be `video-post storyboard <manifest>`, and you MUST wait+command MUST be `adom-video-post storyboard <manifest>`, and you MUST wait for the user's per-clip feedback before concat / upload.  ```bash # Open storyboard → user reviews → you fix bad clips → repeat → only then concat-video-post storyboard /tmp/my-demo-manifest.json \-  --tab-name 'video-post: <name> review' --port 8797+adom-video-post storyboard /tmp/my-demo-manifest.json \+  --tab-name 'adom-video-post: <name> review' --port 8797 # Activate + screenshot to confirm it rendered-adom-cli hydrogen workspace active-tab --name 'video-post: <name> review'-adom-cli hydrogen screenshot panel --name 'video-post: <name> review' \+adom-cli hydrogen workspace active-tab --name 'adom-video-post: <name> review'+adom-cli hydrogen screenshot panel --name 'adom-video-post: <name> review' \   --reason 'show user the clip storyboard for review' # Then point the user at the tab and wait for "clip N is broken" / "looks good" ```  ### Every clip needs a `--description` -`video-post manifest add --description "<text>"` is how the storyboard+`adom-video-post manifest add --description "<text>"` is how the storyboard UI explains what each clip is supposed to show. Without descriptions, the user is still blind — they see a player but no context. Write descriptions that are **concrete about the intent**, not just the@@ -65,9 +65,9 @@ feature name. Compare: Rule of thumb: if a teammate reading just the description couldn't write the clip's interaction script, the description is too vague. -### Known bug: `video-post concat` drops audio+### Known bug: `adom-video-post concat` drops audio -Today `video-post concat --kind raw` only writes the video stream.+Today `adom-video-post concat --kind raw` only writes the video stream. Until fixed, use ffmpeg's concat demuxer as a drop-in replacement AFTER storyboard approval: @@ -105,7 +105,7 @@ A clip where the video is 2:17 and the narration is 0:30 is broken regardless of **Rule:** **video length ≈ narration length, ±15%.** If they diverge, fix whichever is wrong:  - Narration too long: tighten the script, drop filler ("So…", "What we're looking at here…").-- Video too long: either trim on recording, split the clip into N smaller clips (each with its own matched narration), or apply `video-post process` speedups in the boring bits with caption overlays.+- Video too long: either trim on recording, split the clip into N smaller clips (each with its own matched narration), or apply `adom-video-post process` speedups in the boring bits with caption overlays.  **Split before you speedup when:** - The sub-sections are genuinely different things you'd narrate separately ("open it / click around / close it" → three clips, three narrations).@@ -125,7 +125,7 @@ done  ## 🖥️ Fullscreen review in a Hydrogen webview is blocked — route users to pup -Hydrogen's Web View panel wraps the storyboard URL in an iframe without `allow="fullscreen"` (filed as `adom-inc/hydrogen#329`). The ⛶ Expand button on each clip card calls `element.requestFullscreen()` which throws silently. This is OUT OF scope for `video-post` to fix.+Hydrogen's Web View panel wraps the storyboard URL in an iframe without `allow="fullscreen"` (filed as `adom-inc/hydrogen#329`). The ⛶ Expand button on each clip card calls `element.requestFullscreen()` which throws silently. This is OUT OF scope for `adom-video-post` to fix.  **When the user asks why fullscreen doesn't work** (and they will), tell them: same URL, standalone Chrome window via `pup`: @@ -140,9 +140,9 @@ Do not wait for hydrogen#329 to ship before offering this — the workaround is  Every webm/mp4 you upload to the Adom Wiki must have a keyframe interval of ~1 s (`-g 30 -keyint_min 30` for 30 fps content). Without that, the `<video>` element on the wiki page can play start-to-end but **cannot scrub** — `<video>` seeks only land on keyframes, and a clip with one keyframe at frame 0 is one giant seekable unit. Users want to jump around the timeline; without dense keyframes, they can't. -This bites especially on the **single-shot path** (`adom-cli hydrogen recording stop` straight to `adom-wiki asset upload`) which skips all of video-post and inherits libvpx-vp9's default keyframe-only-at-frame-0 behavior.+This bites especially on the **single-shot path** (`adom-cli hydrogen recording stop` straight to `adom-wiki asset upload`) which skips all of adom-video-post and inherits libvpx-vp9's default keyframe-only-at-frame-0 behavior. -`video-post concat`'s final mux **must** include `-g 30 -keyint_min 30` on every code path that produces a webm bound for the wiki (concat output, snippet narration mux, hero image extract is fine without it). The same rule lives in the `demo-recording` skill — these two skills enforce identical encode contracts on every wiki-bound clip. If you find a code path that skips it, fix it before letting the upload proceed.+`adom-video-post concat`'s final mux **must** include `-g 30 -keyint_min 30` on every code path that produces a webm bound for the wiki (concat output, snippet narration mux, hero image extract is fine without it). The same rule lives in the `demo-recording` skill — these two skills enforce identical encode contracts on every wiki-bound clip. If you find a code path that skips it, fix it before letting the upload proceed.  **Quick verify after encode:** @@ -156,13 +156,13 @@ ffprobe -v error -select_streams v:0 -show_entries packet=pts_time,flags \  ## 🎬 Two flows, pick the right one -`video-post` supports **two** demo-assembly flows. Pick based on what+`adom-video-post` supports **two** demo-assembly flows. Pick based on what you're making:  ### Flow A — Per-section concat (preferred for feature tours)  Each feature / section is recorded as its own short clip with its own-TTS narration. `video-post concat` stitches them. This is the+TTS narration. `adom-video-post concat` stitches them. This is the canonical path for feature-tour demos ("here are 14 things adom-tsci can do") because: @@ -177,7 +177,7 @@ adom-tsci can do") because: The orchestration loop (full details in the demo-recording skill):  ```bash-video-post manifest init --output demo.json+adom-video-post manifest init --output demo.json for each section:   adom-cli hydrogen caption show "<title>" -d 0   adom-cli hydrogen recording start --reason "<title>"@@ -187,12 +187,12 @@ for each section:     --write-media "<id>.mp3"   ffmpeg -y -i <clip>.webm -i <id>.mp3 -map 0:v -map 1:a \     -c:v copy -c:a libopus -shortest <id>-narrated.webm-  video-post manifest add --manifest demo.json \+  adom-video-post manifest add --manifest demo.json \     --id <id> --title "<title>" \     --raw <id>-narrated.webm <id>-narrated.webm   adom-cli hydrogen caption hide done-video-post concat --manifest demo.json --output final.webm --kind raw+adom-video-post concat --manifest demo.json --output final.webm --kind raw ```  **TTS voice:** default `en-US-AndrewNeural` (Microsoft Edge neural,@@ -207,8 +207,8 @@ infrastructure. Until then, `edge-tts` on PATH is the correct default.  ### Flow B — Single long take + speedup markers -A single recording, with `video-post mark start` / `mark end` around-slow operations, post-processed by `video-post process` to compress+A single recording, with `adom-video-post mark start` / `mark end` around+slow operations, post-processed by `adom-video-post process` to compress boring sections. This is the right flow for **live-narration** demos where the speech timing is inseparable from the action, or where the demo has unpredictable pauses (cloud search results, AI tool latency,@@ -229,7 +229,7 @@ demo videos. Phases:  ## Why this exists -Demo recordings have long boring stretches: file exports (10-30s), cloud searches (5-30s), AI tool call latency (2-5s per call). A 12-step demo turns into a 5-minute video where 80% of the runtime is the viewer watching unchanged screens. Nobody watches that. video-post compresses the boring parts to ~5% of their wall-clock time with a clear visual indicator that the speedup is happening.+Demo recordings have long boring stretches: file exports (10-30s), cloud searches (5-30s), AI tool call latency (2-5s per call). A 12-step demo turns into a 5-minute video where 80% of the runtime is the viewer watching unchanged screens. Nobody watches that. adom-video-post compresses the boring parts to ~5% of their wall-clock time with a clear visual indicator that the speedup is happening.  For **feature-tour demos** (Flow A), the problem is different: you want each section to be a tidy, narrated clip, and you want the final video to be a clean concatenation rather than one continuous take with timing gymnastics. `manifest` + `concat` does that job. @@ -237,27 +237,27 @@ For **feature-tour demos** (Flow A), the problem is different: you want each sec  ```bash # Health check (verify ffmpeg + ffprobe are installed)-video-post health+adom-video-post health  # Initialize the markers file at the START of a recording session-video-post mark init+adom-video-post mark init  # Wrap any slow command with a speedup marker-video-post wrap --speed 20 --label "Exporting gerbers" -- adom-desktop fusion_export_gerbers-video-post wrap --speed 15 --label "Pulling source files" -- pull_and_analyze "C:/tmp/foo.fbrd"+adom-video-post wrap --speed 20 --label "Exporting gerbers" -- adom-desktop fusion_export_gerbers+adom-video-post wrap --speed 15 --label "Pulling source files" -- pull_and_analyze "C:/tmp/foo.fbrd"  # OR drop manual start/end markers-video-post mark start --speed 50 --label "Cloud search"+adom-video-post mark start --speed 50 --label "Cloud search" # ... run slow operation ...-video-post mark end+adom-video-post mark end  # Inspect the markers you've collected-video-post inspect+adom-video-post inspect  # After the recording stops, post-process it-video-post process \+adom-video-post process \   --input  /home/adom/project/recordings/demo.webm \-  --markers /tmp/video-post-markers.jsonl+  --markers /tmp/adom-video-post-markers.jsonl # → produces /home/adom/project/recordings/demo-fast.webm ``` @@ -266,7 +266,7 @@ video-post process \ ### `health`  ```bash-video-post health+adom-video-post health ```  Verifies `ffmpeg` and `ffprobe` are installed and returns their versions. Exit 0 if ready, 1 with an install hint if missing.@@ -278,7 +278,7 @@ OK: ffmpeg 6.1.1 + ffprobe 6.1.1 ready. ### `mark init`  ```bash-video-post mark init [--file /tmp/video-post-markers.jsonl]+adom-video-post mark init [--file /tmp/adom-video-post-markers.jsonl] ```  Clears the markers file and writes a `recording_start` event with the current epoch. Call this immediately after `adom-cli hydrogen recording start --countdown N` returns. The post-processor uses this timestamp as t=0 for the video.@@ -286,9 +286,9 @@ Clears the markers file and writes a `recording_start` event with the current ep ### `mark start` / `mark end`  ```bash-video-post mark start --speed 20 --label "Exporting gerbers"+adom-video-post mark start --speed 20 --label "Exporting gerbers" # ... run slow operation ...-video-post mark end+adom-video-post mark end ```  Drops a `speedup_start` event (with speed multiplier and caption label) and a matching `speedup_end` event. The post-processor will accelerate everything between these two timestamps and overlay the caption.@@ -300,16 +300,16 @@ Drops a `speedup_start` event (with speed multiplier and caption label) and a ma ### `wrap`  ```bash-video-post wrap --speed 20 --label "Exporting gerbers" -- adom-desktop fusion_export_gerbers+adom-video-post wrap --speed 20 --label "Exporting gerbers" -- adom-desktop fusion_export_gerbers ```  Convenience wrapper that drops a start marker, runs the command, drops the end marker, and returns the command's exit code. Equivalent to:  ```bash-video-post mark start --speed 20 --label "Exporting gerbers"+adom-video-post mark start --speed 20 --label "Exporting gerbers" adom-desktop fusion_export_gerbers RC=$?-video-post mark end+adom-video-post mark end exit $RC ``` @@ -318,7 +318,7 @@ Use this in your demo script for any command that takes more than ~3 seconds and ### `inspect`  ```bash-video-post inspect [--file /tmp/video-post-markers.jsonl]+adom-video-post inspect [--file /tmp/adom-video-post-markers.jsonl] ```  Prints a human-readable summary of the markers file: how long the recording has been running, how many speedup segments are defined, total time saved at the configured speeds.@@ -334,9 +334,9 @@ OK: Recording started 142.3s ago, 4 speedup segments, ~98.7s of source video → ### `process`  ```bash-video-post process \+adom-video-post process \   --input /home/adom/project/recordings/demo.webm \-  --markers /tmp/video-post-markers.jsonl \+  --markers /tmp/adom-video-post-markers.jsonl \   [--output /home/adom/project/recordings/demo-fast.webm] \   [--max-speed 100] ```@@ -352,18 +352,18 @@ Audio is dropped (`-an`). Phase 1 expects silent recordings. Phase 2 (the `voice  Default output is `<input-stem>-fast.webm`. Override with `--output`. -### `voiceover` (Video Post-Processing webview app)+### `voiceover` (Adom Video Post-Processing webview app)  ```bash-video-post voiceover \+adom-video-post voiceover \   --input /home/adom/project/recordings/demo-fast.webm \   [--output /home/adom/project/recordings/demo-fast-narrated.webm] \-  [--markers /tmp/video-post-markers.jsonl] \+  [--markers /tmp/adom-video-post-markers.jsonl] \   [--panel-id <leaf-id>] \-  [--tab-name "Video Post-Processing"]+  [--tab-name "Adom Video Post-Processing"] ``` -This is the full Video Post-Processing app. It ships as a Rust-backed+This is the full Adom Video Post-Processing app. It ships as a Rust-backed HTTP server with a Hydrogen webview frontend, Adom brand-compliant Familjen Grotesk + Satoshi typography, frosted-glass card layout, and 2-way HTTP comms so both the human user and an AI driver can operate@@ -377,7 +377,7 @@ Features:   stats: source duration, after-speedup duration, seconds saved, %   shorter, number of speedup segments. Shown only when `--markers` is   passed so the card reflects the real speedup plan from an earlier-  `video-post process` run. Each segment has a deep tooltip+  `adom-video-post process` run. Each segment has a deep tooltip   explaining EBU R128 loudness parameters in plain English. - **Video tab strip** above the player: "Original" (the silent source),   "Take N" (raw mux for each voiceover take), "Take N auto-leveled"@@ -428,7 +428,7 @@ Features: ### `publish` (Phase 3, coming soon)  ```bash-video-post publish \+adom-video-post publish \   --input /home/adom/project/recordings/demo-fast-narrated.webm \   --page apps/adom-desktop \   --caption "Fusion 360 deep dive: BQ25792 manufacturing pipeline"@@ -439,10 +439,10 @@ Uploads the final video to the wiki page for the artifact you're demoing. Wraps ### `install`  ```bash-video-post install+adom-video-post install ``` -Deploys the embedded `SKILL.md` to `~/.claude/skills/video-post/SKILL.md` so Claude Code finds it automatically via trigger words. Run once after building.+Deploys the embedded `SKILL.md` to `~/.claude/skills/adom-video-post/SKILL.md` so Claude Code finds it automatically via trigger words. Run once after building.  ## The full pipeline (when all 3 phases ship) @@ -450,22 +450,22 @@ Deploys the embedded `SKILL.md` to `~/.claude/skills/video-post/SKILL.md` so Cla # 1. Record (silent — no voiceover) adom-cli hydrogen sharing request --share screen --reason "Demo recording" adom-cli hydrogen recording start --countdown 3-video-post mark init+adom-video-post mark init -# (run your demo, wrapping slow sections with `video-post wrap`)+# (run your demo, wrapping slow sections with `adom-video-post wrap`)  adom-cli hydrogen recording stop  # → /home/adom/project/recordings/demo.webm  # 2. Speedup pass-video-post process --input demo.webm --markers /tmp/video-post-markers.jsonl+adom-video-post process --input demo.webm --markers /tmp/adom-video-post-markers.jsonl # → demo-fast.webm  # 3. Voiceover pass (Phase 2)-video-post voiceover --input demo-fast.webm+adom-video-post voiceover --input demo-fast.webm # → demo-fast-narrated.webm  # 4. Publish to wiki (Phase 3)-video-post publish --input demo-fast-narrated.webm --page apps/adom-desktop \+adom-video-post publish --input demo-fast-narrated.webm --page apps/adom-desktop \   --caption "Fusion 360 deep dive" ``` @@ -483,20 +483,20 @@ JSONL, one event per line. Self-contained — the file embeds the recording star {"action":"speedup_end","epoch":1776038562.456} ``` -Default file path: `$VIDEO_SPEEDUP_MARKERS` env var, or `/tmp/video-post-markers.jsonl` if unset.+Default file path: `$VIDEO_SPEEDUP_MARKERS` env var, or `/tmp/adom-video-post-markers.jsonl` if unset.  ## Integration with adom-desktop demo flow  `adom-desktop`'s `scripts/demo-record.sh` integrates with this tool: -- `demo-record.sh start` calls `video-post mark init` after the Hydrogen recording starts-- `demo-record.sh stop` calls `video-post process` automatically if any speedup markers exist, producing both `demo.webm` and `demo-fast.webm`+- `demo-record.sh start` calls `adom-video-post mark init` after the Hydrogen recording starts+- `demo-record.sh stop` calls `adom-video-post process` automatically if any speedup markers exist, producing both `demo.webm` and `demo-fast.webm`  Per-step demo commands wrap their slow operations:  ```bash step_caption "Step 7/12: Exporting gerbers"-video-post wrap --speed 20 --label "Exporting gerbers" -- bash -c '+adom-video-post wrap --speed 20 --label "Exporting gerbers" -- bash -c '   GERBER_RESP=$(adom-desktop fusion_export_gerbers 2>&1)   pull_and_analyze "$ZIP_PATH" '
build.shadded+9
@@ -0,0 +1,9 @@+#!/usr/bin/env bash+set -euo pipefail+cd "$(dirname "$0")"+cargo build --release+sudo cp target/release/adom-video-post /usr/local/bin/+chmod +x /usr/local/bin/adom-video-post+adom-video-post install+echo ""+echo "OK: adom-video-post installed. Run 'adom-video-post health' to verify."
docs/timeline-card-real-demo.pngadded
⋯ 1 unchanged line ⋯
page.json+8−2
@@ -1,14 +1,17 @@ {   "slug": "adom-video-post",   "title": "Adom Video Post-Production",-  "summary": "Demo post-production for Adom: speed up the boring parts, record a voiceover over the sped-up cut, and publish straight to the wiki \u2014 seekable, captioned, finished. Bundles the demo-recording skill set (script \u2192 record \u2192 mux \u2192 storyboard \u2192 publish) homed here since gallia retired.",+  "summary": "AI-driven studio for finishing demo videos: review every captured clip, flag and re-record the weak ones (\"clip 3 is awful \u2014 redo it\u2026\"), speed up dead air, narrate, mux + auto-level, validate the final cut, and publish to the wiki \u2014 driven end-to-end by your AI.",   "category": "Developer Tools",   "tags": [     "video",     "demo",     "recording",     "post-production",+    "review",+    "ai-feedback",     "voiceover",+    "mux",     "speedup",     "wiki-publish"   ],@@ -18,5 +21,8 @@     "path": "screenshots/hero.png"   },   "visibility": "public",-  "brief": "Speed up the boring parts, record a voiceover, and publish straight to the wiki \u2014 the post-production studio for Adom demos."+  "brief": "AI-driven studio for finishing demo videos: review every captured clip, flag and re-record the weak ones (\"clip 3 is awful \u2014 redo it\u2026\"), speed up dead air, narrate, mux + auto-level, validate the final cut, and publish to the wiki \u2014 driven end-to-end by your AI.",+  "install": {+    "binary_name": "adom-video-post"+  } }\ No newline at end of file
skills/demo-recording/SKILL.md+12−12
@@ -5,7 +5,7 @@ description: >   record their screen, or capture a demo of a feature. Handles the full   pre-production and recording workflow: writing the demo script, generating   captions, verifying the setup, choreographing panels, and driving the-  recording. Hands off to video-post for post-production (speedup, voiceover,+  recording. Hands off to adom-video-post for post-production (speedup, voiceover,   publish). Trigger words: record a video, record a demo, make a movie, create   a walkthrough, record my screen, demo video, screen recording, narrate a   walkthrough, capture a demo, make a demo.@@ -24,7 +24,7 @@ sub-docs you should follow when their phase becomes relevant: | [per-snippet-flow.md](per-snippet-flow.md) | While you record + after. Setup verification, the record loop, manifest add, **storyboard review (mandatory)**, concat, hero, wiki upload, workspace restore. |  For post-production after recording (speedup, voiceover, publish-to-wiki),-hand off to the `video-post` skill.+hand off to the `adom-video-post` skill.  ## What "a real demo" means at Adom — non-negotiable shape @@ -45,16 +45,16 @@ every one of these steps has to ship — none of them is optional:    when adom-tts isn't installed. 4. **Per-clip mux** — TTS audio combined with the silent recording into one    `<id>-narrated.webm` (`ffmpeg -i clip.webm -i tts.mp3 ... libopus`).-5. **`video-post manifest add`** for every clip with a `--description` that-   states **intent** (not just feature name) — see video-post skill rules.-6. **`video-post storyboard <manifest>` review with the user** — MANDATORY+5. **`adom-video-post manifest add`** for every clip with a `--description` that+   states **intent** (not just feature name) — see adom-video-post skill rules.+6. **`adom-video-post storyboard <manifest>` review with the user** — MANDATORY    gate before concat. Per-clip review catches "clip 5 measured the wrong    thing" in seconds; reviewing a 3-min concat blind takes 20× as long.    Skip this step ONLY if the user has explicitly told you they're    unavailable (e.g. "I'm at the gym, just ship it") — and call it out    prominently in the final report so they can re-review later.-7. **`ffmpeg -f concat -safe 0 -c copy` to the final** — `video-post concat`-   currently drops audio (known bug, documented in video-post skill).+7. **`ffmpeg -f concat -safe 0 -c copy` to the final** — `adom-video-post concat`+   currently drops audio (known bug, documented in adom-video-post skill). 8. **Hero image** — `ffmpeg -ss <t> -i final.webm -frames:v 1 hero.png`    from a representative frame. 9. **Show the final concat in `adom-video` BEFORE wiki upload.** Drop the@@ -106,7 +106,7 @@ takes 20× longer AND the user has to re-watch every revision.  Fix: the storyboard gate is non-negotiable. If the user says "watch later" once, that does NOT extend to follow-up shoots. Open-`video-post storyboard <manifest>` after every recording pass. Detail+`adom-video-post storyboard <manifest>` after every recording pass. Detail in [per-snippet-flow.md](per-snippet-flow.md) ("Step 5" section).  ### 2. One-shot batch instead of iterative@@ -197,7 +197,7 @@ should run in parallel.  Fix: the moment the last snippet is recorded, kick off `(ffmpeg concat ... &)` (background bash) AND-`video-post storyboard <manifest>` (foreground for the user). By the+`adom-video-post storyboard <manifest>` (foreground for the user). By the time the user clicks through snippets, the mux is either done OR you've already killed it because they flagged a re-shoot. Detail in [per-snippet-flow.md](per-snippet-flow.md) ("Background-mux while the user reviews" section).@@ -283,11 +283,11 @@ Present the user with a choice using AskUserQuestion: > 1. **Quick recording** — I'll start recording your screen with your mic on. >    You narrate live, stop when done. Ready to upload immediately. > 2. **Polished recording** — I'll write a sectioned script first, then record->    each section as its own clip, generate TTS per section, and `video-post`+>    each section as its own clip, generate TTS per section, and `adom-video-post` >    concats them into one final video. This is the canonical path for >    feature-tour demos. > 3. **Just post-process** — I already have a recording. (→ hand off to->    `video-post` skill)+>    `adom-video-post` skill)  ### Quick recording @@ -317,7 +317,7 @@ that matches the phase you're in, not all three at once. - [script-and-tts.md](script-and-tts.md) — script template, narration phonetics, captions - [per-snippet-flow.md](per-snippet-flow.md) — verify, record, manifest, storyboard review, concat, hero, publish, workspace restore - [tts-pronunciation](../tts-pronunciation/SKILL.md) — Adom-specific TTS phonetics-- `video-post` skill (system-installed) — speedup, voiceover, publish-to-wiki post-recording+- `adom-video-post` skill (system-installed) — speedup, voiceover, publish-to-wiki post-recording  ## Recorded-tab verification BEFORE recording starts 
skills/demo-recording/per-snippet-flow.md+18−18
@@ -36,7 +36,7 @@ this file.  **The rule:** a polished demo is a sequence of **separately-recorded clips**, one per section / feature, each with its own TTS narration.-`video-post concat` stitches them together at the end. Do NOT record+`adom-video-post concat` stitches them together at the end. Do NOT record one long take covering every feature. Reasons:  - A single long recording turns every mistake into a re-shoot of@@ -49,7 +49,7 @@ one long take covering every feature. Reasons: - Hero image selection is trivial when each section is its own file —   just pick the best frame from clip #1 or #2. - Re-shoots are surgical: if the Measure tool scene is bad, re-record-  `measure.webm`, update the manifest entry, `video-post concat` again.+  `measure.webm`, update the manifest entry, `adom-video-post concat` again.   The other 12 clips are untouched.  ### Why the storyboard gate exists (read this before skipping it)@@ -181,7 +181,7 @@ Where `looks_ready()` does one or more of: MANIFEST=/tmp/<name>-manifest.json CLIPS=/tmp/<name>-clips/ mkdir -p $CLIPS-video-post manifest init --output $MANIFEST+adom-video-post manifest init --output $MANIFEST  for each section in script:   # 1. Show caption (viewer-side context)@@ -212,7 +212,7 @@ for each section in script:     -map 0:v -map 1:a -c:v copy -c:a libopus -shortest \     "$CLIPS/<id>-narrated.webm"   # 7. Register in manifest — --description is MANDATORY, see below-  video-post manifest add \+  adom-video-post manifest add \     --id "<id>" --title "<short label>" \     --description "<full intent: what this clip shows, which project, \                    which features, what the viewer should notice>" \@@ -258,7 +258,7 @@ the demo needs variety: `en-US-AriaNeural` (polished female), `en-US-GuyNeural` (warm neutral male). Pick ONE voice for a whole demo — switching voices mid-demo is jarring. -## Step 5: 🛑 MANDATORY — open video-post storyboard and let the user review every clip+## Step 5: 🛑 MANDATORY — open adom-video-post storyboard and let the user review every clip  **This step is not optional.** The whole per-section recording flow exists so the user can audit each clip individually, because AI-driven@@ -271,8 +271,8 @@ wrong thing" or "clip 8 never clicked anything." Don't do it. # Opens a Hydrogen webview listing every clip in the manifest with its # title, description, and a player. The user can play each one and # tell you which clips are bad without watching the whole video.-video-post storyboard $MANIFEST \-  --tab-name 'video-post: <name> review' \+adom-video-post storyboard $MANIFEST \+  --tab-name 'adom-video-post: <name> review' \   --port 8797 ``` @@ -280,12 +280,12 @@ Then:  1. **Actively point the user at the storyboard tab.** Activate it,    screenshot to confirm it rendered, tell the user the tab name to look-   at. "The storyboard is open in tab `video-post: …`, 13 clips, take a+   at. "The storyboard is open in tab `adom-video-post: …`, 13 clips, take a    look and tell me which ones are wrong." 2. **Wait for user feedback.** The user will tell you "clip 5 needs    more parts clicked," "clip 11 walkthrough isn't advancing," etc. —    or just "looks good."-3. **Re-shoot surgically.** `video-post manifest remove --id <id>`,+3. **Re-shoot surgically.** `adom-video-post manifest remove --id <id>`,    re-record that one clip, `manifest add` with updated description,    point the user back at storyboard. Repeat until they approve. 4. **Only then** proceed to Step 6 (concat + publish).@@ -306,7 +306,7 @@ run in parallel: CONCAT_PID=$!  # In parallel, open the storyboard for review.-video-post storyboard $MANIFEST --tab-name 'video-post: <name> review' --port 8797+adom-video-post storyboard $MANIFEST --tab-name 'adom-video-post: <name> review' --port 8797  # When user approves: concat is already done. Upload. # When user flags a re-shoot: kill the in-flight concat, re-shoot the clip,@@ -320,7 +320,7 @@ Treating concat as a finish-line task instead of a parallel one wastes ## Step 6: Concat + publish — ONLY after storyboard approval  ```bash-# Option A: `video-post concat` (drops audio today — KNOWN BUG, use B)+# Option A: `adom-video-post concat` (drops audio today — KNOWN BUG, use B) # Option B: ffmpeg concat demuxer (preserves all streams) LIST=$(mktemp) for c in $CLIPS/*-narrated.webm; do echo "file '$c'" >> $LIST; done@@ -337,7 +337,7 @@ ffprobe -v error -show_streams <name>-final.webm | grep codec_type # Must show both `video` and `audio`. ``` -Then publish (Step 7). If any section is bad, `video-post manifest+Then publish (Step 7). If any section is bad, `adom-video-post manifest remove --id <id>`, re-record that ONE clip, re-register with updated description, reopen storyboard for another review pass, then re-concat. The surgical re-shoot is the whole point.@@ -456,12 +456,12 @@ After `adom-wiki asset upload` finishes, tear down in reverse:  ```bash # 1. Kill background servers you spawned.-pkill -f "video-post storyboard"+pkill -f "adom-video-post storyboard" # (add any app-specific dev servers you started, e.g. tsci dev on 8850)  # 2. Remove every tab you added during the demo. Compare the current #    tab list against the pre-demo snapshot; the delta is yours.-for name in "<tab you added>" "<video-post: ... review>"; do+for name in "<tab you added>" "<adom-video-post: ... review>"; do   adom-cli hydrogen workspace remove-tab --name "$name" done @@ -502,7 +502,7 @@ line to the tool-specific demo flow). ## Legacy one-take + speedup path (not preferred)  The older flow recorded a single long take then sped up slow parts-with `video-post process --markers`. It still works and is the right+with `adom-video-post process --markers`. It still works and is the right call for *live* narration demos where timing is inseparable from speech — but for **scripted feature tours**, the per-section path above beats it on every axis: easier re-shoots, cleaner TTS, better@@ -511,9 +511,9 @@ hero selection, no "narrate fast here, slow there" timing puzzle. If you do need the legacy path:  ```bash-video-post inspect                      # show speedup summary-video-post process --input <file>.webm --markers /tmp/video-post-markers.jsonl-video-post voiceover --input <file>-fast.webm   # live-narration UI+adom-video-post inspect                      # show speedup summary+adom-video-post process --input <file>.webm --markers /tmp/adom-video-post-markers.jsonl+adom-video-post voiceover --input <file>-fast.webm   # live-narration UI adom-wiki asset upload apps/<page> --asset-type video --file <final>.webm \   --caption "..." ```
skills/demo-recording/recording-mechanics.md+3−3
@@ -66,7 +66,7 @@ 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 video-post pipeline. (Bit me on+without going through the adom-video-post pipeline. (Bit me on `molecules/brianna-led-nameplate`'s `brianna-demo.mp4`; bit Kyle the same day.) @@ -101,7 +101,7 @@ Pipe through `ffmpeg -i raw.webm -c:v libvpx-vp9 -b:v 2M -g 30 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 video-post `concat` path SHOULD apply this on the final mux — if+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.@@ -277,7 +277,7 @@ ffmpeg -y -loglevel error -i /tmp/demo/raw/${ID}.webm -i /tmp/demo/tts/${ID}.mp3   -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-video-post manifest add /tmp/demo/manifest.json --id "$ID" --title "..." \+adom-video-post manifest add /tmp/demo/manifest.json --id "$ID" --title "..." \   --description "..." --raw /tmp/demo/narrated/${ID}.webm /tmp/demo/narrated/${ID}.webm ``` 
skills/demo-recording/script-and-tts.md+5−5
@@ -20,7 +20,7 @@ The demo script is a **first-class artifact** of the repo, not scratch work. It The script is a runnable spec. A fresh Claude thread (or a teammate) reading it should be able to re-render the entire demo without asking any questions. That means every setting that affects the output goes in the file, not in the assistant's head:  - **Metadata header** — version being demoed, target duration, recording date, hero frame timestamp, final output path-- **Tooling deps** — `edge-tts` / `adom-cli` / `video-post` / `ffmpeg` versions / required env vars+- **Tooling deps** — `edge-tts` / `adom-cli` / `adom-video-post` / `ffmpeg` versions / required env vars - **Voice + rate** — `en-US-AndrewMultilingualNeural` / `+5%` / any per-scene overrides - **Workspace setup** — canonical split ratio (default 0.30 AI / 0.70 feature), which panels, which apps pre-opened - **Scene list** — numbered, with captions, interactions, commands, panel choreography, speedup markers, **and narration text** (see table below)@@ -45,7 +45,7 @@ Omitting any of the above means the next person who tries to regenerate the demo  - `edge-tts` — `pip install edge-tts` (tested with 6.1.x) - `adom-cli` — container-provided-- `video-post` — built from `/home/adom/project/video-post/` (`cargo build --release`)+- `adom-video-post` — built from `/home/adom/project/adom-video-post/` (`cargo build --release`) - `ffmpeg 6+` — container-provided - Env vars: none required beyond the container defaults @@ -76,9 +76,9 @@ cd <repo> python3 demo/gen_tts.py           # renders TTS from this script's narration column python3 demo/record.py            # drives scene-by-scene recording python3 demo/mux.py               # muxes TTS onto clips, concatenates final-video-post storyboard /tmp/<name>-manifest.json   # review+adom-video-post storyboard /tmp/<name>-manifest.json   # review # after review:-video-post concat /tmp/<name>-manifest.json --kind raw --output ~/project/recordings/<name>-<version>-tour.webm+adom-video-post concat /tmp/<name>-manifest.json --kind raw --output ~/project/recordings/<name>-<version>-tour.webm adom-wiki asset upload apps/<page> --asset-type video --file ~/project/recordings/<name>-<version>-tour.webm \`\`\` @@ -188,7 +188,7 @@ adom-cli hydrogen caption show "Isometric view — full board layout" -d 5  Hide when transitioning: `adom-cli hydrogen caption hide` -**Do NOT use video-post `--label` for captions.** Those are ugly ffmpeg text+**Do NOT use adom-video-post `--label` for captions.** Those are ugly ffmpeg text overlays. Use `adom-cli hydrogen caption show` for clean, rendered captions that appear natively in the workspace. 
src/app/icon.svgadded+5
@@ -0,0 +1,5 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#e6edf3">+  <!-- Movie clapperboard — a single recognizable "video post-production"+       glyph. Monochrome #e6edf3 per the Adom brand rule. -->+  <path d="M4 6h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2zm0 4v8h16v-8H4zm1.5-5.5L4 3h2.5l1.5 1.5L6.5 6 5 4.5zm4 0L8 3h2.5l1.5 1.5L10.5 6 9 4.5zm4 0L12 3h2.5L16 4.5 14.5 6 13 4.5zm4 0L16 3h2.5L20 4.5 18.5 6 17 4.5z"/>+</svg>
src/app/mod.rsadded+161
@@ -0,0 +1,161 @@+//! Unified `adom-video-post app` subcommand — opens ONE Hydrogen tab+//! named "Adom Video Post" that hosts all three post-production phases+//! (Review, Narrate, Publish) behind a phase selector at the top of+//! the UI, so the user sees a single tool instead of three.+//!+//! The backend composes `StoryboardRoutes` (review/dailies) and+//! `VoiceoverRoutes` (narration/ADR) under one HTTP server. The+//! storyboard routes live at their normal top-level paths+//! (`/manifest`, `/video/:id/raw`, etc.) because they're the primary+//! data layer; voiceover routes get prefixed with `/voiceover/` so+//! we can disambiguate (`/voiceover/start-recording`,+//! `/voiceover/takes`, `/voiceover/state`, etc.). The UI fetches+//! both surfaces as needed to render the active phase.++pub mod routes;++use crate::storyboard::manifest::Manifest;+use crate::voiceover::{server::VoiceoverRoutes, VoiceoverConfig};+use crate::webapp::{run_webapp, WebappConfig};+use std::path::PathBuf;+use std::sync::atomic::AtomicU64;+use std::sync::{Arc, Mutex, RwLock};+use std::time::{Duration, SystemTime};++/// Icon placeholder shown on the Hydrogen tab for ~100ms while the+/// page loads, then overridden by the served `/favicon.svg` per+/// the hydrogen-tab-icons skill.+const TAB_ICON_PLACEHOLDER: &str = "mdi:movie-edit-outline";++pub struct AppConfig {+    pub manifest_path: PathBuf,+    pub port: u16,+    pub tab_name: String,+    pub panel_id_override: Option<String>,+    pub watch: bool,+    /// Skip the Hydrogen webview tab open; HTTP server only.+    pub no_open: bool,+}++pub fn run(cfg: AppConfig) -> Result<(), String> {+    if !cfg.manifest_path.exists() {+        return Err(format!(+            "manifest not found: {}. Hint: create one with `adom-video-post manifest init`, then add clips with `adom-video-post manifest add`.",+            cfg.manifest_path.display()+        ));+    }++    let initial = Manifest::load(&cfg.manifest_path)?;+    let title = if initial.title.is_empty() {+        "Adom Video Post".to_string()+    } else {+        initial.title.clone()+    };++    // Shared storyboard state — manifest under RwLock so the watcher+    // thread can swap it on mtime change, plus a version counter the+    // UI polls to decide when to re-fetch.+    let manifest = Arc::new(RwLock::new(initial));+    let version = Arc::new(AtomicU64::new(1));+    let manifest_mtime = Arc::new(Mutex::new(+        std::fs::metadata(&cfg.manifest_path)+            .and_then(|m| m.modified())+            .unwrap_or(SystemTime::UNIX_EPOCH),+    ));++    // File watcher — same pattern as the standalone storyboard+    // subcommand.+    if cfg.watch {+        let manifest_for_thread = Arc::clone(&manifest);+        let version_for_thread = Arc::clone(&version);+        let mtime_for_thread = Arc::clone(&manifest_mtime);+        let path_for_thread = cfg.manifest_path.clone();+        std::thread::spawn(move || loop {+            std::thread::sleep(Duration::from_millis(500));+            let new_mtime = match std::fs::metadata(&path_for_thread)+                .and_then(|m| m.modified())+            {+                Ok(t) => t,+                Err(_) => continue,+            };+            let mut last = mtime_for_thread.lock().unwrap();+            if new_mtime == *last {+                continue;+            }+            match Manifest::load(&path_for_thread) {+                Ok(fresh) => {+                    *manifest_for_thread.write().unwrap() = fresh;+                    version_for_thread+                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);+                    *last = new_mtime;+                }+                Err(e) => {+                    eprintln!("app: manifest reload failed: {}", e);+                }+            }+        });+    }++    // Derive the voiceover input: prefer manifest.final_fast_path+    // (the concat output), fall back to final_raw_path. If neither+    // is set yet, voiceover features are disabled until the user+    // runs `adom-video-post concat` to populate one.+    let voiceover_input = {+        let m = manifest.read().unwrap();+        m.final_fast_path.clone().or_else(|| m.final_raw_path.clone())+    };+    let voiceover_output_dir = voiceover_input+        .as_ref()+        .and_then(|p| p.parent().map(|p| p.to_path_buf()))+        .unwrap_or_else(|| std::env::temp_dir());+    let voiceover_output = voiceover_output_dir.join("adom-video-post-narrated.webm");++    // Build a voiceover routes struct pointing at whichever final+    // concat is available. The result_slot here is per-app-session,+    // separate from storyboard's (storyboard doesn't produce a+    // result). If the user runs a narration and finalizes, the+    // narrated path lands in this slot and is displayed in the UI.+    let voiceover_result_slot: Arc<Mutex<Option<Result<PathBuf, String>>>> =+        Arc::new(Mutex::new(None));+    let voiceover_cfg = VoiceoverConfig {+        input: voiceover_input.unwrap_or_else(|| PathBuf::from("/nonexistent")),+        output: voiceover_output,+        // These three fields are only consumed by the voiceover+        // subcommand's standalone `run()`; AppRoutes never looks at+        // them but VoiceoverConfig requires them.+        port: cfg.port,+        tab_name: cfg.tab_name.clone(),+        panel_id_override: cfg.panel_id_override.clone(),+        markers_file: None,+        no_open: cfg.no_open,+    };+    let voiceover_routes = Arc::new(VoiceoverRoutes::new(+        voiceover_cfg,+        voiceover_result_slot,+    ));++    // Storyboard routes for the review phase — uses the same+    // manifest + version the watcher updates.+    let storyboard_routes = Arc::new(crate::storyboard::routes::StoryboardRoutes {+        manifest: Arc::clone(&manifest),+        version: Arc::clone(&version),+        manifest_path: cfg.manifest_path.clone(),+    });++    let app_routes = Arc::new(routes::AppRoutes {+        storyboard: storyboard_routes,+        voiceover: voiceover_routes,+    });++    let webapp_cfg = WebappConfig {+        port: cfg.port,+        tab_name: cfg.tab_name,+        panel_id_override: cfg.panel_id_override,+        display_icon: TAB_ICON_PLACEHOLDER.to_string(),+        subcommand: "app".to_string(),+        title,+        no_open: cfg.no_open,+    };++    run_webapp(webapp_cfg, app_routes)+}
src/app/routes.rsadded+83
@@ -0,0 +1,83 @@+//! `impl Routes` for the unified `adom-video-post app` subcommand.+//!+//! Dispatches incoming requests across the storyboard and voiceover+//! sub-route tables behind prefix-based URL rewriting:+//!+//!   /manifest, /state, /video/*, /poster/*, /image/*,+//!   /console-png/*, /final-video/*   → `StoryboardRoutes`+//!   /voiceover/<rest>                 → `VoiceoverRoutes` with+//!                                        `<rest>` passed as the+//!                                        stripped URL+//!+//! Voiceover's /video endpoint serves the configured input (the+//! final_fast_path from the manifest) — it's accessible via+//! `/voiceover/video` in the unified app. The UI's Narrate panel+//! points a `<video>` element at that URL.++use crate::storyboard::routes::StoryboardRoutes;+use crate::voiceover::server::VoiceoverRoutes;+use crate::webapp::{video as webapp_video, RouteOutcome, Routes};+use std::sync::Arc;+use tiny_http::{Method, Request};++const UI_HTML: &str = include_str!("ui.html");+const FAVICON_SVG: &str = include_str!("icon.svg");++pub struct AppRoutes {+    pub storyboard: Arc<StoryboardRoutes>,+    pub voiceover: Arc<VoiceoverRoutes>,+}++impl Routes for AppRoutes {+    fn ui_html(&self) -> &str {+        UI_HTML+    }++    fn favicon_svg(&self) -> &str {+        FAVICON_SVG+    }++    fn handle(&self, request: Request, method: &Method, url: &str) -> RouteOutcome {+        // Special-case /voiceover/video: VoiceoverRoutes's own+        // /video handler reads a hardcoded `cfg.input` path that was+        // captured at app startup. In the unified app, the user may+        // run `POST /concat` after startup to produce the final fast+        // concat, and we want the Narrate phase's video player to+        // pick up the newly-set manifest.final_fast_path without+        // needing a process restart. So intercept the request here,+        // read the current final_fast_path out of the shared+        // manifest, and serve it directly. Fall back to the raw+        // concat if final_fast_path is unset.+        if matches!(method, Method::Get) && url == "/voiceover/video" {+            let path = {+                let m = self.storyboard.manifest.read().unwrap();+                m.final_fast_path+                    .clone()+                    .or_else(|| m.final_raw_path.clone())+            };+            return match path {+                Some(p) if p.exists() => RouteOutcome::Handled(+                    webapp_video::serve_file_range(request, &p, "video/webm"),+                ),+                _ => RouteOutcome::Handled(+                    request+                        .respond(tiny_http::Response::from_string("no final video available — run POST /concat first")+                            .with_status_code(404))+                        .map_err(|e| e.to_string()),+                ),+            };+        }++        // /voiceover/* → strip the prefix and hand off to the+        // voiceover route table, which thinks it's running alone.+        if let Some(rest) = url.strip_prefix("/voiceover") {+            let sub_url = if rest.is_empty() { "/" } else { rest };+            return self.voiceover.handle(request, method, sub_url);+        }++        // Everything else falls through to the storyboard routes.+        // This includes /manifest, /state, /video/:id/*, /poster/:id,+        // /image/:id, /console-png/:id, /final-video/raw|fast.+        self.storyboard.handle(request, method, url)+    }+}
src/app/ui.htmladded+1247
@@ -0,0 +1,1247 @@+<!DOCTYPE html>+<html lang="en">+<head>+<meta charset="UTF-8">+<title>Adom Video Post</title>+<link rel="icon" type="image/svg+xml" href="favicon.svg">+<style>+@font-face {+  font-family: 'Familjen Grotesk';+  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2');+  font-weight: 700; font-display: swap;+}+@font-face {+  font-family: 'Familjen Grotesk';+  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2');+  font-weight: 600; font-display: swap;+}+@font-face {+  font-family: 'Familjen Grotesk';+  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');+  font-weight: 500; font-display: swap;+}+@font-face {+  font-family: 'Satoshi';+  src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');+  font-weight: 400; font-display: swap;+}+@font-face {+  font-family: 'Satoshi';+  src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');+  font-weight: 500; font-display: swap;+}+@font-face {+  font-family: 'JetBrains Mono';+  src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-400-normal.woff2') format('woff2');+  font-weight: 400; font-display: swap;+}+@font-face {+  font-family: 'JetBrains Mono';+  src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-500-normal.woff2') format('woff2');+  font-weight: 500; font-display: swap;+}+@font-face {+  font-family: 'JetBrains Mono';+  src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-600-normal.woff2') format('woff2');+  font-weight: 600; font-display: swap;+}+  :root {+    --bg: #0d1117;+    --surface: #161b22;+    --elevated: #1c2128;+    --border: #30363d;+    --border-muted: #21262d;+    --text: #e6edf3;+    --text2: #7d8590;+    --text3: #484f58;+    --accent: #00b8b0;+    --accent-bright: #00e6dc;+    --accent-glow: rgba(0,184,176,0.25);+    --purple: #8C6BF7;+    --purple-light: #a890ff;+    --green: #4aa857;+    --red: #f85149;+    --yellow: #d4aa3e;+  }+  * { box-sizing: border-box; }+  body {+    margin: 0;+    background: var(--bg);+    color: var(--text);+    font-family: 'Familjen Grotesk', 'Satoshi', -apple-system, "Segoe UI", sans-serif;+    font-size: 14px;+    line-height: 1.5;+  }++  /* Canonical Adom app-header — see gallia/skills/adom-app-header/SKILL.md.+     Single row, 44 px tall + phase tabs as a separate row beneath. */+  header {+    padding: 0;+    border-bottom: 1px solid var(--border-muted);+    background: var(--bg);+    position: relative;+  }+  header .header-row {+    height: 44px;+    padding: 0 16px;+    display: flex;+    align-items: center;+    gap: 12px;+    font-family: 'Familjen Grotesk', sans-serif;+  }+  header .brand {+    display: flex;+    align-items: center;+    gap: 8px;+    color: var(--accent);+    font-size: 13px;+    font-weight: 600;+    letter-spacing: 0.01em;+    cursor: default;+    flex-shrink: 0;+    outline: none;+  }+  header .brand:hover .app-name { text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: 3px; }+  header .brand svg.logo { width: 16px; height: 16px; flex-shrink: 0; }+  header .brand .app-name { color: var(--accent); }+  header .brand .app-subtitle { color: var(--text2); font-weight: 500; }+  header .brand .sep { color: var(--text3); font-weight: 400; }+  header .vsep {+    width: 1px;+    height: 18px;+    background: var(--border);+    flex-shrink: 0;+  }+  header .subject {+    display: flex;+    align-items: center;+    gap: 8px;+    min-width: 0;+    flex: 1 1 auto;+  }+  header .subject-name {+    color: var(--text);+    font-family: 'Familjen Grotesk', sans-serif;+    font-size: 14px;+    font-weight: 600;+    white-space: nowrap;+    overflow: hidden;+    text-overflow: ellipsis;+    min-width: 0;+  }+  header .subject-meta {+    display: flex;+    align-items: center;+    gap: 6px;+    color: var(--text2);+    font-family: 'JetBrains Mono', monospace;+    font-size: 11px;+    white-space: nowrap;+    flex-shrink: 0;+  }+  header .subject-meta .sep { color: var(--text3); }+  @media (max-width: 720px) {+    header .subject-meta { display: none; }+  }+  header .hover-popover {+    position: absolute;+    top: calc(100% + 6px);+    left: 16px;+    background: var(--elevated);+    border: 1px solid var(--border);+    border-radius: 8px;+    box-shadow: 0 8px 24px rgba(0,0,0,0.5);+    padding: 12px 14px;+    min-width: 300px;+    max-width: 420px;+    font-family: 'Satoshi', sans-serif;+    font-size: 12px;+    color: var(--text);+    z-index: 99999;+    opacity: 0;+    transform: translateY(-2px);+    pointer-events: none;+    transition: opacity 120ms ease, transform 120ms ease;+  }+  header .hover-popover.show { opacity: 1; transform: none; pointer-events: auto; }+  header .hover-popover .popover-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 8px; }+  header .hover-popover .popover-row { display: flex; gap: 12px; padding: 3px 0; align-items: baseline; }+  header .hover-popover .popover-row .k { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 10px; text-transform: lowercase; width: 64px; flex-shrink: 0; }+  header .hover-popover .popover-row .v { color: var(--text); font-family: 'Satoshi', sans-serif; font-size: 12px; flex: 1; word-break: break-word; }+  header .hover-popover .popover-actions { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-muted); display: flex; gap: 12px; }+  header .hover-popover .popover-link { color: var(--accent); text-decoration: none; font-family: 'Familjen Grotesk', sans-serif; font-size: 12px; font-weight: 600; }+  header .hover-popover .popover-link:hover { text-decoration: underline; }++  /* Phase selector: three tabs below the title */+  .phase-tabs {+    display: flex;+    gap: 0;+    margin-top: 14px;+    border-bottom: 1px solid var(--border-muted);+  }+  .phase-tab {+    padding: 12px 24px;+    background: transparent;+    border: none;+    color: var(--text2);+    font-family: inherit;+    font-size: 13px;+    font-weight: 500;+    text-transform: uppercase;+    letter-spacing: 0.8px;+    cursor: pointer;+    border-bottom: 2px solid transparent;+    transition: color 0.15s, border-color 0.15s;+  }+  .phase-tab:hover { color: var(--text); }+  .phase-tab.active {+    color: var(--accent-bright);+    border-bottom-color: var(--accent);+  }+  .phase-tab .step {+    display: inline-block;+    width: 18px;+    height: 18px;+    line-height: 18px;+    text-align: center;+    background: var(--elevated);+    color: var(--text3);+    border-radius: 50%;+    font-size: 10px;+    font-weight: 700;+    margin-right: 8px;+  }+  .phase-tab.active .step {+    background: var(--accent);+    color: var(--bg);+  }++  .phase-panel { display: none; }+  .phase-panel.active { display: block; }++  /* ─── Review phase (storyboard) ──────────────────────────── */+  .finals {+    margin: 18px 32px 0;+    display: flex;+    gap: 16px;+    flex-wrap: wrap;+  }+  .finals .card {+    flex: 1;+    min-width: 320px;+    background: var(--surface);+    border: 1px solid var(--border);+    border-radius: 10px;+    padding: 16px;+  }+  .finals h3 {+    margin: 0 0 8px;+    font-size: 12px;+    text-transform: uppercase;+    letter-spacing: 0.8px;+    color: var(--text3);+  }++  .timeline {+    display: flex;+    align-items: center;+    gap: 6px;+    background: var(--elevated);+    border: 1px solid var(--border);+    padding: 14px 18px;+    margin: 18px 32px;+    border-radius: 8px;+    overflow-x: auto;+    white-space: nowrap;+  }+  .tl-step {+    flex-shrink: 0;+    padding: 7px 13px;+    border-radius: 6px;+    background: var(--surface);+    border: 1px solid var(--border);+    font-size: 12px;+    color: var(--text);+    cursor: pointer;+    text-decoration: none;+    display: inline-flex;+    flex-direction: column;+    gap: 2px;+  }+  .tl-step.speedup { border-color: var(--purple); background: #1f1530; }+  .tl-step:hover { border-color: var(--accent-bright); }+  .tl-step .num { color: var(--text3); font-size: 10px; font-weight: 600; }+  .tl-step .mark { color: var(--purple); font-size: 10px; }++  .steps { padding: 0 32px 80px; }+  .step-card {+    background: var(--surface);+    border: 1px solid var(--border);+    border-radius: 10px;+    padding: 24px;+    margin-top: 20px;+  }+  .step-card h2 {+    margin: 0 0 6px;+    font-size: 22px;+    color: var(--accent-bright);+    display: flex;+    align-items: center;+    gap: 12px;+  }+  .step-card h2 .badge {+    background: var(--accent);+    color: var(--bg);+    padding: 4px 10px;+    border-radius: 16px;+    font-size: 13px;+    font-weight: 700;+  }+  .step-card .desc { color: var(--text2); font-size: 13px; margin-bottom: 14px; }++  .speedup-pill {+    display: inline-block;+    padding: 4px 10px;+    border-radius: 12px;+    background: var(--purple);+    color: #fff;+    font-size: 12px;+    font-weight: 600;+    margin-left: 8px;+  }++  .actions-list {+    background: var(--bg);+    border: 1px solid var(--border);+    border-radius: 6px;+    padding: 12px 16px;+    font-family: 'JetBrains Mono', monospace;+    font-size: 12px;+    line-height: 1.7;+    color: var(--text2);+    margin-top: 12px;+    max-height: 160px;+    overflow-y: auto;+  }+  .warnings {+    background: #2d2412;+    border: 1px solid #a8791e;+    color: #f0c260;+    border-radius: 6px;+    padding: 10px 14px;+    font-size: 12px;+    margin-top: 12px;+  }++  .clip-row { display: grid; grid-template-columns: 1fr; gap: 16px; margin-top: 18px; }+  .clip-block { display: flex; flex-direction: column; gap: 6px; }+  .clip-label {+    font-size: 11px;+    text-transform: uppercase;+    letter-spacing: 1px;+    font-weight: 600;+  }+  .clip-label.raw { color: var(--accent-bright); }+  .clip-label.fast { color: var(--purple); }++  .video-wrap {+    position: relative;+    aspect-ratio: 16 / 10;+    background: #000;+    border-radius: 6px;+    border: 1px solid var(--border);+    overflow: hidden;+  }+  .video-wrap video {+    width: 100%;+    height: 100%;+    display: block;+    background: #000;+    object-fit: contain;+  }+  .video-wrap .expand {+    position: absolute;+    top: 8px;+    right: 8px;+    background: rgba(0,0,0,0.75);+    color: #fff;+    border: 1px solid rgba(255,255,255,0.3);+    padding: 6px 12px;+    border-radius: 4px;+    cursor: pointer;+    font-size: 12px;+    font-family: inherit;+    z-index: 5;+    transition: background 0.15s;+  }+  .video-wrap .expand:hover {+    background: rgba(0,0,0,0.95);+    border-color: var(--accent-bright);+    color: var(--accent-bright);+  }++  /* ─── Narrate phase (voiceover) ─────────────────────────── */+  .narrate {+    padding: 24px 32px 80px;+    max-width: 1100px;+    margin: 0 auto;+  }+  .narrate .big-video {+    background: #000;+    border: 1px solid var(--border);+    border-radius: 10px;+    aspect-ratio: 16 / 10;+    overflow: hidden;+    margin-bottom: 18px;+  }+  .narrate .big-video video {+    width: 100%;+    height: 100%;+    object-fit: contain;+  }+  .narrate-controls {+    display: flex;+    gap: 12px;+    align-items: center;+    flex-wrap: wrap;+    margin-bottom: 16px;+  }+  .narrate button {+    background: var(--elevated);+    color: var(--text);+    border: 1px solid var(--border);+    padding: 10px 18px;+    border-radius: 8px;+    font-family: inherit;+    font-size: 14px;+    font-weight: 500;+    cursor: pointer;+    transition: background 0.15s, border-color 0.15s;+  }+  .narrate button:hover:not(:disabled) {+    border-color: var(--accent);+    background: rgba(0,184,176,0.08);+  }+  .narrate button:disabled { opacity: 0.4; cursor: not-allowed; }+  .narrate .btn-record {+    background: var(--red);+    color: #fff;+    border-color: var(--red);+    padding: 12px 22px;+    font-weight: 600;+    box-shadow: 0 4px 14px rgba(248,81,73,0.25);+  }+  .narrate .btn-record:hover:not(:disabled) {+    background: #ff5a52;+    color: #fff;+  }+  .narrate .btn-record.recording {+    background: var(--elevated);+    color: var(--red);+    border-color: var(--red);+    box-shadow: 0 0 0 3px rgba(248,81,73,0.15);+    animation: pulse 1.6s ease-in-out infinite;+  }+  .narrate .btn-record.recording::before { content: '⏹ '; }+  .narrate .btn-finalize {+    background: var(--accent);+    color: var(--bg);+    border-color: var(--accent);+    font-weight: 500;+  }+  .narrate .btn-finalize:hover:not(:disabled) {+    background: var(--accent-bright);+    border-color: var(--accent-bright);+    color: var(--bg);+  }+  @keyframes pulse {+    0%,100% { box-shadow: 0 0 0 3px rgba(248,81,73,0.15); }+    50%     { box-shadow: 0 0 0 6px rgba(248,81,73,0.35); }+  }++  .narrate .status {+    background: var(--elevated);+    border: 1px solid var(--border);+    border-radius: 8px;+    padding: 14px 18px;+    color: var(--text2);+    font-size: 13px;+    line-height: 1.6;+    white-space: pre-wrap;+  }+  .narrate .not-ready {+    background: #2d2412;+    border: 1px solid #a8791e;+    color: #f0c260;+    border-radius: 8px;+    padding: 20px 24px;+    font-size: 13px;+    margin-bottom: 18px;+    line-height: 1.6;+  }+  .narrate .not-ready .prepare-btn {+    display: inline-block;+    margin-top: 14px;+    background: var(--accent);+    color: var(--bg);+    border: 1px solid var(--accent);+    padding: 11px 22px;+    border-radius: 8px;+    font-family: inherit;+    font-size: 14px;+    font-weight: 600;+    cursor: pointer;+    transition: background 0.15s, color 0.15s;+  }+  .narrate .not-ready .prepare-btn:hover:not(:disabled) {+    background: var(--accent-bright);+    color: var(--bg);+  }+  .narrate .not-ready .prepare-btn:disabled {+    opacity: 0.55;+    cursor: wait;+  }+  .narrate .not-ready .prepare-hint {+    display: block;+    margin-top: 10px;+    color: #caa860;+    font-size: 11px;+    font-family: 'JetBrains Mono', monospace;+  }++  /* ─── Publish phase ─────────────────────────────────────── */+  .publish {+    padding: 32px;+    max-width: 780px;+    margin: 0 auto;+    color: var(--text2);+    font-size: 14px;+  }+  .publish h3 { color: var(--text); margin: 0 0 14px; font-size: 20px; font-weight: 700; }+  .publish p { margin: 0 0 14px; }+  .publish .inline-code {+    background: var(--elevated);+    padding: 2px 8px;+    border-radius: 4px;+    color: var(--accent-bright);+    font-family: 'JetBrains Mono', monospace;+    font-size: 12px;+  }+  .publish form {+    background: var(--surface);+    border: 1px solid var(--border);+    border-radius: 10px;+    padding: 20px 24px;+    margin-top: 18px;+  }+  .publish label {+    display: block;+    margin-bottom: 14px;+  }+  .publish label .lbl {+    display: block;+    font-size: 11px;+    text-transform: uppercase;+    letter-spacing: 1px;+    font-weight: 600;+    color: var(--text3);+    margin-bottom: 6px;+  }+  .publish input[type="text"] {+    width: 100%;+    background: var(--bg);+    color: var(--text);+    border: 1px solid var(--border);+    border-radius: 6px;+    padding: 10px 14px;+    font-family: inherit;+    font-size: 14px;+  }+  .publish input[type="text"]:focus {+    border-color: var(--accent);+    outline: none;+  }+  .publish .prompt-box {+    background: var(--bg);+    border: 1px solid var(--border);+    border-radius: 8px;+    padding: 16px 18px;+    margin-top: 14px;+    font-family: 'JetBrains Mono', monospace;+    font-size: 12px;+    line-height: 1.7;+    color: var(--text);+    white-space: pre-wrap;+    word-break: break-word;+  }+  .publish .prompt-actions {+    margin-top: 12px;+    display: flex;+    gap: 10px;+    align-items: center;+  }+  .publish .copy-btn {+    background: var(--accent);+    color: var(--bg);+    border: 1px solid var(--accent);+    padding: 10px 18px;+    border-radius: 8px;+    font-family: inherit;+    font-size: 13px;+    font-weight: 600;+    cursor: pointer;+    transition: background 0.15s;+  }+  .publish .copy-btn:hover { background: var(--accent-bright); color: var(--bg); }+  .publish .copy-btn.copied {+    background: var(--green);+    border-color: var(--green);+    color: var(--bg);+  }+  .publish .publish-warning {+    background: #2d2412;+    border: 1px solid #a8791e;+    color: #f0c260;+    border-radius: 8px;+    padding: 14px 18px;+    font-size: 13px;+    margin-top: 16px;+  }++  /* Lightbox + video fullscreen — shared across phases */+  .lightbox {+    display: none;+    position: fixed;+    inset: 0;+    z-index: 1000;+    background: rgba(0,0,0,0.92);+    align-items: center;+    justify-content: center;+    padding: 40px;+    cursor: zoom-out;+  }+  .lightbox.open { display: flex; }+  .lightbox img {+    max-width: 100%;+    max-height: 100%;+    object-fit: contain;+    box-shadow: 0 8px 32px rgba(0,0,0,0.6);+    border: 1px solid var(--border);+  }+  .lightbox .close {+    position: absolute;+    top: 20px;+    right: 32px;+    color: var(--text);+    font-size: 32px;+    cursor: pointer;+    background: none;+    border: none;+  }+  .video-fullscreen {+    display: none;+    position: fixed;+    inset: 0;+    z-index: 2000;+    background: #000;+    align-items: center;+    justify-content: center;+  }+  .video-fullscreen.open { display: flex; }+  .video-fullscreen video {+    max-width: 100%;+    max-height: 100%;+    width: auto;+    height: auto;+  }+  .video-fullscreen .close {+    position: absolute;+    top: 16px;+    right: 20px;+    z-index: 2001;+    background: rgba(0,0,0,0.85);+    border: 1px solid rgba(255,255,255,0.3);+    color: #fff;+    font-size: 20px;+    cursor: pointer;+    padding: 8px 14px;+    border-radius: 4px;+  }+</style>+</head>+<body>+<!-- Canonical Adom app-header — see gallia/skills/adom-app-header/SKILL.md.+     Single row + phase tabs row underneath. -->+<header>+  <div class="header-row">+    <div class="brand" data-tooltip-target="app-info" tabindex="0">+      <svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">+        <path d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4zm2 2v2h2V6H5zm4 0v12h6V6H9zm8 0v2h2V6h-2zM5 10v2h2v-2H5zm12 0v2h2v-2h-2zM5 14v2h2v-2H5zm12 0v2h2v-2h-2zM5 18v0h2v0H5zm12 0v0h2v0h-2zM11 8v8h2V8h-2z"/>+      </svg>+      <span class="app-name">adom-video-post</span>+      <span class="sep">·</span>+      <span class="app-subtitle">review · narrate · publish</span>+    </div>+    <div class="vsep" aria-hidden="true"></div>+    <div class="subject">+      <span class="subject-name" id="title">Loading…</span>+      <span class="subject-meta" id="stats"></span>+    </div>+  </div>+  <div class="phase-tabs">+    <button class="phase-tab active" data-phase="review">+      <span class="step">1</span>Review+    </button>+    <button class="phase-tab" data-phase="narrate">+      <span class="step">2</span>Narrate+    </button>+    <button class="phase-tab" data-phase="publish">+      <span class="step">3</span>Publish+    </button>+  </div>++  <div class="hover-popover" id="app-info" role="dialog" aria-label="App information">+    <div class="popover-title">adom-video-post</div>+    <div class="popover-row"><span class="k">about</span>+      <span class="v">Per-clip review, voiceover narration, and final concat for Adom feature-tour demos.</span></div>+    <div class="popover-row"><span class="k">version</span><span class="v">v0.3.0</span></div>+    <div class="popover-row"><span class="k">author</span><span class="v">Adom Inc · adom.inc</span></div>+    <div class="popover-row"><span class="k">tts</span>+      <span class="v"><a class="popover-link" href="https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-tts" target="_blank" rel="noopener">adom-tts ↗</a> — house TTS engine + pronunciation overrides shared by every Adom tool that speaks. Mispronounced terms? Add them once there.</span></div>+    <div class="popover-actions">+      <a class="popover-link" href="https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-video-post" target="_blank" rel="noopener">Open wiki ↗</a>+      <a class="popover-link" href="https://github.com/adom-inc/adom-video-post" target="_blank" rel="noopener">GitHub ↗</a>+    </div>+  </div>+</header>++<script>+// SHOW_DELAY = 500 ms per human-ui-patterns §1b; HIDE_GRACE = 220 ms.+(function attachHeaderPopovers(){+  const SHOW_DELAY = 500;+  const HIDE_GRACE = 220;+  const wire = () => {+    document.querySelectorAll('header [data-tooltip-target]').forEach(trigger => {+      if (trigger.__popoverWired) return;+      trigger.__popoverWired = true;+      const target = document.getElementById(trigger.dataset.tooltipTarget);+      if (!target) return;+      let showTimer = null, hideTimer = null;+      const show = () => {+        clearTimeout(hideTimer);+        if (target.classList.contains('show')) return;+        clearTimeout(showTimer);+        showTimer = setTimeout(() => target.classList.add('show'), SHOW_DELAY);+      };+      const queueHide = () => {+        clearTimeout(showTimer);+        clearTimeout(hideTimer);+        hideTimer = setTimeout(() => target.classList.remove('show'), HIDE_GRACE);+      };+      trigger.addEventListener('mouseenter', show);+      trigger.addEventListener('mouseleave', queueHide);+      trigger.addEventListener('focus', show);+      trigger.addEventListener('blur', queueHide);+      target.addEventListener('mouseenter', show);+      target.addEventListener('mouseleave', queueHide);+      trigger.addEventListener('click', e => {+        clearTimeout(showTimer);+        if (target.classList.contains('show')) target.classList.remove('show');+        else target.classList.add('show');+        e.stopPropagation();+      });+    });+  };+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);+  else wire();+})();+</script>++<!-- ─── Review phase ─────────────────────────────────────── -->+<section id="phase-review" class="phase-panel active">+  <div id="finals" class="finals" style="display:none;"></div>+  <div id="timeline" class="timeline" style="display:none;"></div>+  <div id="steps" class="steps"></div>+</section>++<!-- ─── Narrate phase ────────────────────────────────────── -->+<section id="phase-narrate" class="phase-panel">+  <div class="narrate">+    <div id="narrateNotReady" class="not-ready" style="display:none;">+      <strong>Narration isn't ready yet.</strong> The manifest has no+      <code>final_fast_path</code> — your sped-up clips need to be+      concatenated into a single video first.+      <div>+        <button id="prepareBtn" class="prepare-btn">Prepare for narration</button>+        <span class="prepare-hint">+          Runs <code>ffmpeg concat</code> over every clip's+          <code>fast_path</code> and writes the result back into the+          manifest. Re-enables this phase as soon as it's done.+        </span>+      </div>+    </div>+    <div class="big-video">+      <video id="narrateVideo" controls preload="metadata" src="voiceover/video"></video>+    </div>+    <div class="narrate-controls">+      <button id="narrateRecord" class="btn-record">Record voiceover</button>+      <button id="narrateDiscard" disabled>Discard &amp; redo</button>+      <button id="narrateFinalize" class="btn-finalize" disabled>Finalize &amp; mux</button>+    </div>+    <div id="narrateStatus" class="status">Click Record voiceover for a 3-2-1 countdown, then the video plays silently while Hydrogen captures your mic. Click Finalize &amp; mux when done.</div>+  </div>+</section>++<!-- ─── Publish phase ────────────────────────────────────── -->+<section id="phase-publish" class="phase-panel">+  <div class="publish">+    <h3>Publish to the Adom wiki</h3>+    <p>+      Fill in the target wiki page and a caption, then copy the+      generated prompt and paste it into Claude Code. Claude will+      use the <span class="inline-code">adom-video-post</span> ++      <span class="inline-code">adom-wiki</span> skills to upload+      the narrated final video.+    </p>+    <form onsubmit="event.preventDefault(); return false;">+      <label>+        <span class="lbl">Wiki page ref</span>+        <input type="text" id="pubPage" placeholder="apps/adom-desktop" value="apps/adom-desktop">+      </label>+      <label>+        <span class="lbl">Caption</span>+        <input type="text" id="pubCaption" placeholder="Fusion 360 deep dive: BQ25792 manufacturing pipeline">+      </label>+      <div class="prompt-box" id="pubPrompt">(fill in the fields above to see the prompt)</div>+      <div class="prompt-actions">+        <button class="copy-btn" id="pubCopyBtn">Copy prompt</button>+        <span id="pubPromptHint" style="color:var(--text3);font-size:11px;"></span>+      </div>+      <div id="pubNoVideo" class="publish-warning" style="display:none;">+        ⚠ No narrated video available yet. Finalize a voiceover in+        the <strong>Narrate</strong> phase first, or paste the prompt+        to Claude Code and it will pick up whatever final video+        exists when it runs.+      </div>+    </form>+  </div>+</section>++<!-- Shared overlays -->+<div id="lightbox" class="lightbox" onclick="closeLightbox(event)">+  <button class="close" onclick="closeLightbox()">&times;</button>+  <img id="lightbox-img" alt="">+</div>+<div id="video-fs" class="video-fullscreen" onclick="closeVideoFs(event)">+  <button class="close" onclick="closeVideoFs()">&times; Close</button>+  <video id="video-fs-player" controls autoplay></video>+</div>++<!-- EVAL_CLIENT -->++<script>+window.__appCtx = window.__appCtx || {};+window.__appCtx.subcommand = 'app';+window.__appCtx.phase = 'review';+window.__appCtx.manifest = null;+window.__appCtx.version = 0;++// ─── console forwarding ──────────────────────────────────+(function() {+  const orig = { log: console.log, warn: console.warn, error: console.error };+  function forward(level, args) {+    orig[level].apply(console, args);+    try {+      fetch('console', {+        method: 'POST',+        headers: { 'Content-Type': 'application/json' },+        body: JSON.stringify({+          level: level,+          message: args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '),+          ts: Date.now(),+        }),+      }).catch(() => {});+    } catch (_) {}+  }+  console.log = function() { forward('log', arguments); };+  console.warn = function() { forward('warn', arguments); };+  console.error = function() { forward('error', arguments); };+})();++function esc(s) {+  return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');+}++// ─── phase selector ──────────────────────────────────────+document.querySelectorAll('.phase-tab').forEach(btn => {+  btn.addEventListener('click', () => {+    const phase = btn.dataset.phase;+    switchPhase(phase);+  });+});+function switchPhase(phase) {+  window.__appCtx.phase = phase;+  document.querySelectorAll('.phase-tab').forEach(b => {+    b.classList.toggle('active', b.dataset.phase === phase);+  });+  document.querySelectorAll('.phase-panel').forEach(p => {+    p.classList.toggle('active', p.id === 'phase-' + phase);+  });+  console.log('phase switched:', phase);+}++// ─── lightbox + fullscreen helpers ───────────────────────+window.closeLightbox = function(e) {+  if (e && e.target && e.target.tagName === 'IMG') return;+  document.getElementById('lightbox').classList.remove('open');+};+window.expandVideo = function(btn) {+  const wrap = btn.parentElement;+  const src = wrap.querySelector('video');+  if (!src) return;+  const fs = document.getElementById('video-fs');+  const player = document.getElementById('video-fs-player');+  player.src = src.getAttribute('src') || src.src;+  player.currentTime = src.currentTime || 0;+  fs.classList.add('open');+};+window.closeVideoFs = function(e) {+  if (e && e.target && e.target.tagName === 'VIDEO') return;+  const fs = document.getElementById('video-fs');+  const player = document.getElementById('video-fs-player');+  player.pause();+  player.src = '';+  fs.classList.remove('open');+};+document.addEventListener('keydown', (e) => {+  if (e.key === 'Escape') { closeLightbox(); closeVideoFs(); }+});++// ─── Review phase rendering ──────────────────────────────+function renderReview(manifest) {+  const clipCount = manifest.clips.length;+  const totalSpeedups = manifest.clips.reduce((n, c) => n + (c.speedup_events?.length || 0), 0);+  const withFast = manifest.clips.filter(c => c.fast_path).length;+  const parts = [`${clipCount} clip${clipCount === 1 ? '' : 's'}`];+  if (totalSpeedups > 0) parts.push(`${totalSpeedups} speedup${totalSpeedups === 1 ? '' : 's'}`);+  if (withFast > 0) parts.push(`${withFast} sped-up`);+  document.getElementById('stats').innerHTML = parts.map(esc).join('<span class="sep">·</span>');+  document.getElementById('title').textContent = manifest.title || 'Adom Video Post';++  // Finals+  const finalsEl = document.getElementById('finals');+  let finalsHtml = '';+  if (manifest.final_raw_path) {+    finalsHtml += `<div class="card"><h3>Raw concat</h3>+      <div class="video-wrap">+        <button class="expand" onclick="expandVideo(this)">⛶ Expand</button>+        <video controls preload="metadata" src="final-video/raw"></video>+      </div></div>`;+  }+  if (manifest.final_fast_path) {+    finalsHtml += `<div class="card"><h3>Fast concat</h3>+      <div class="video-wrap">+        <button class="expand" onclick="expandVideo(this)">⛶ Expand</button>+        <video controls preload="metadata" src="final-video/fast"></video>+      </div></div>`;+  }+  finalsEl.innerHTML = finalsHtml;+  finalsEl.style.display = finalsHtml ? 'flex' : 'none';++  // Timeline strip+  const timelineEl = document.getElementById('timeline');+  if (clipCount > 0) {+    timelineEl.style.display = 'flex';+    timelineEl.innerHTML = manifest.clips.map((c, i) => {+      const n = i + 1;+      const hasSpeedup = (c.speedup_events || []).length > 0;+      const cls = hasSpeedup ? 'tl-step speedup' : 'tl-step';+      const markHtml = hasSpeedup+        ? `<span class="mark">⚡ ${esc(c.speedup_events[0].speed)}x</span>`+        : '';+      return `<a class="${cls}" href="#clip-${esc(c.id)}">+        <span class="num">${n}</span>+        <span>${esc(c.title || c.id).slice(0, 30)}</span>+        ${markHtml}+      </a>`;+    }).join('');+  } else {+    timelineEl.style.display = 'none';+  }++  // Step cards+  const stepsEl = document.getElementById('steps');+  if (clipCount === 0) {+    stepsEl.innerHTML = `<div style="padding:40px 32px;color:var(--text3);text-align:center;font-family:'JetBrains Mono',monospace;">+      <strong style="color:var(--text);">No clips yet.</strong><br><br>+      Your demo script should call <code>adom-video-post manifest add</code> once per step.<br>+      The UI live-updates as clips are added.+    </div>`;+    return;+  }+  stepsEl.innerHTML = manifest.clips.map((c, i) => renderClip(c, i + 1)).join('');+}++function renderClip(c, n) {+  const speedupPill = (c.speedup_events || []).length > 0+    ? `<span class="speedup-pill">⚡ ${esc(c.speedup_events[0].speed)}x ${esc(c.speedup_events[0].label)}</span>`+    : '';+  const description = c.description ? `<div class="desc">${esc(c.description)}</div>` : '';+  const warnings = (c.warnings || []).length > 0+    ? `<div class="warnings">⚠ ${c.warnings.map(esc).join('<br>⚠ ')}</div>`+    : '';+  const actions = (c.actions || []).length > 0+    ? `<div class="actions-list">${c.actions.map(a => esc(a)).map(a => '• ' + a).join('<br>')}</div>`+    : '';+  let rawBlock = '';+  if (c.raw_path) {+    rawBlock = `<div class="clip-block">+      <div class="clip-label raw">Raw (as recorded)</div>+      <div class="video-wrap">+        <button class="expand" onclick="expandVideo(this)">⛶ Expand</button>+        <video controls preload="metadata" poster="poster/${esc(c.id)}" src="video/${esc(c.id)}/raw"></video>+      </div>+    </div>`;+  }+  let fastBlock = '';+  if (c.fast_path) {+    fastBlock = `<div class="clip-block">+      <div class="clip-label fast">Fast (after speedup)</div>+      <div class="video-wrap">+        <button class="expand" onclick="expandVideo(this)">⛶ Expand</button>+        <video controls preload="metadata" poster="poster/${esc(c.id)}" src="video/${esc(c.id)}/fast"></video>+      </div>+    </div>`;+  }+  return `<div class="step-card" id="clip-${esc(c.id)}">+    <h2><span class="badge">${n}</span>${esc(c.title || c.id)}${speedupPill}</h2>+    ${description}+    ${warnings}+    <div class="clip-row">${rawBlock}${fastBlock}</div>+  </div>`;+}++// ─── Narrate phase ───────────────────────────────────────+const narrateRecord   = document.getElementById('narrateRecord');+const narrateDiscard  = document.getElementById('narrateDiscard');+const narrateFinalize = document.getElementById('narrateFinalize');+const narrateStatus   = document.getElementById('narrateStatus');+const narrateNotReady = document.getElementById('narrateNotReady');+const narrateVideo    = document.getElementById('narrateVideo');+const prepareBtn      = document.getElementById('prepareBtn');++// ─── Prepare for narration: POST /concat ─────────────────+prepareBtn.addEventListener('click', async () => {+  prepareBtn.disabled = true;+  const originalText = prepareBtn.textContent;+  prepareBtn.textContent = 'Concatenating clips…';+  try {+    const r = await fetch('concat', { method: 'POST' });+    const result = await r.json();+    if (result.ok) {+      prepareBtn.textContent = `Concatenated ${result.clipCount} clips ✓`;+      console.log('concat complete:', result.path);+      // The backend bumps the version counter, so the next pollLoop+      // tick will re-fetch /manifest, see the new final_fast_path,+      // and auto-unhide the record button + hide this card.+    } else {+      prepareBtn.textContent = originalText;+      prepareBtn.disabled = false;+      narrateStatus.textContent = 'Concat failed: ' + (result.error || 'unknown');+      console.warn('concat failed:', result);+    }+  } catch (e) {+    prepareBtn.textContent = originalText;+    prepareBtn.disabled = false;+    narrateStatus.textContent = 'Concat failed: ' + e.message;+    console.warn('concat error:', e.message);+  }+});++narrateRecord.addEventListener('click', async () => {+  if (narrateRecord.classList.contains('recording')) {+    // Stop+    narrateVideo.pause();+    narrateRecord.disabled = true;+    narrateStatus.textContent = 'Stopping recording…';+    try {+      const r = await fetch('voiceover/stop-recording', { method: 'POST' });+      const result = await r.json();+      if (result.ok) {+        narrateStatus.textContent = 'Recorded. Click Finalize & mux to produce the narrated video.';+        narrateRecord.classList.remove('recording');+        narrateRecord.textContent = 'Record voiceover';+        narrateRecord.disabled = false;+        narrateFinalize.disabled = false;+        narrateDiscard.disabled = false;+      } else {+        narrateStatus.textContent = 'Stop failed: ' + (result.error || 'unknown');+        narrateRecord.disabled = false;+      }+    } catch (e) {+      narrateStatus.textContent = 'Stop failed: ' + e.message;+      narrateRecord.disabled = false;+    }+  } else {+    // Start+    narrateRecord.disabled = true;+    narrateStatus.textContent = 'Starting recording…';+    try {+      const r = await fetch('voiceover/start-recording', { method: 'POST' });+      const result = await r.json();+      if (result.ok) {+        narrateRecord.classList.add('recording');+        narrateRecord.textContent = 'Stop recording';+        narrateRecord.disabled = false;+        narrateVideo.currentTime = 0;+        narrateVideo.play().catch(e => console.warn('play:', e.message));+        narrateStatus.textContent = 'Recording — narrate while the video plays.';+      } else {+        narrateStatus.textContent = 'Start failed: ' + (result.error || 'unknown');+        narrateRecord.disabled = false;+      }+    } catch (e) {+      narrateStatus.textContent = 'Start failed: ' + e.message;+      narrateRecord.disabled = false;+    }+  }+});+narrateFinalize.addEventListener('click', async () => {+  narrateFinalize.disabled = true;+  narrateStatus.textContent = 'Running ffmpeg mux…';+  try {+    const r = await fetch('voiceover/finalize', { method: 'POST' });+    const result = await r.json();+    if (result.ok) {+      narrateStatus.textContent = 'Done. Final path: ' + (result.rawPath || result.path || 'unknown');+    } else {+      narrateStatus.textContent = 'Finalize failed: ' + (result.error || 'unknown');+    }+  } catch (e) {+    narrateStatus.textContent = 'Finalize failed: ' + e.message;+  }+  narrateFinalize.disabled = false;+});+narrateDiscard.addEventListener('click', async () => {+  await fetch('voiceover/discard', { method: 'POST' }).catch(() => {});+  narrateRecord.classList.remove('recording');+  narrateRecord.textContent = 'Record voiceover';+  narrateFinalize.disabled = true;+  narrateDiscard.disabled = true;+  narrateStatus.textContent = 'Discarded. Ready for a fresh take.';+});++// ─── Publish phase: build a Claude Code prompt ────────────+const pubPage = document.getElementById('pubPage');+const pubCaption = document.getElementById('pubCaption');+const pubPrompt = document.getElementById('pubPrompt');+const pubCopyBtn = document.getElementById('pubCopyBtn');+const pubPromptHint = document.getElementById('pubPromptHint');+const pubNoVideo = document.getElementById('pubNoVideo');++function renderPublishPrompt() {+  const m = window.__appCtx.manifest;+  const title = (m && m.title) ? m.title : 'this demo';+  const finalFast = (m && m.final_fast_path) || '';+  // The voiceover subcommand writes the narrated result to+  // <input>.webm → <input>-narrated.webm per its default naming.+  // Derive that here so Claude's prompt references the actual+  // narrated output rather than the silent concat.+  let narratedPath = '';+  if (finalFast) {+    narratedPath = finalFast.replace(/\.webm$/, '-take-1-norm.webm');+  }+  const page = (pubPage.value || 'apps/adom-desktop').trim();+  const caption = (pubCaption.value || title).trim();++  pubNoVideo.style.display = finalFast ? 'none' : 'block';++  const videoLine = narratedPath+    ? `The narrated final video is at \`${narratedPath}\` (if that's missing, use whatever the latest narrated take is under the same directory).`+    : 'The narrated final video is whatever came out of the Narrate phase of the Adom Video Post app — look in the same directory as the manifest for the most recent `*-narrated*.webm` or `*-take-*-norm.webm`.';++  const prompt = `Publish my narrated "${title}" demo video to the Adom wiki.++${videoLine}++Upload it to the \`${page}\` wiki page with the caption:+"${caption}"++Use the \`adom-video-post\` and \`adom-wiki\` skills. Confirm the upload succeeded by returning the wiki asset URL.`;+  pubPrompt.textContent = prompt;+}++pubPage.addEventListener('input', renderPublishPrompt);+pubCaption.addEventListener('input', renderPublishPrompt);++pubCopyBtn.addEventListener('click', async () => {+  try {+    await navigator.clipboard.writeText(pubPrompt.textContent);+    pubCopyBtn.textContent = 'Copied ✓';+    pubCopyBtn.classList.add('copied');+    pubPromptHint.textContent = 'Paste it into your Claude Code chat.';+    setTimeout(() => {+      pubCopyBtn.textContent = 'Copy prompt';+      pubCopyBtn.classList.remove('copied');+    }, 2000);+  } catch (e) {+    pubPromptHint.textContent = 'Copy failed: ' + e.message + ' — select the text above manually.';+    console.warn('clipboard.writeText failed:', e.message);+  }+});++// ─── fetch + live watch ──────────────────────────────────+async function fetchManifest() {+  const r = await fetch('manifest');+  if (!r.ok) throw new Error('GET /manifest ' + r.status);+  return r.json();+}+async function fetchState() {+  const r = await fetch('state');+  if (!r.ok) throw new Error('GET /state ' + r.status);+  return r.json();+}++async function pollLoop() {+  let lastFinalFast = null;+  while (true) {+    try {+      const s = await fetchState();+      if (s.version !== window.__appCtx.version) {+        const m = await fetchManifest();+        window.__appCtx.manifest = m;+        window.__appCtx.version = s.version;+        renderReview(m);+        // Narrate phase availability depends on final_fast_path+        const narrateReady = !!m.final_fast_path;+        narrateNotReady.style.display = narrateReady ? 'none' : 'block';+        narrateRecord.disabled = !narrateReady;+        // If final_fast_path just appeared (or changed), tell the+        // narrate video element to reload. Without this, the+        // element keeps its NETWORK_NO_SOURCE state from the+        // initial 404 when the concat hadn't run yet, and clicking+        // Play shows an empty black player.+        if (m.final_fast_path !== lastFinalFast) {+          lastFinalFast = m.final_fast_path;+          if (narrateReady) {+            narrateVideo.load();+            console.log('narrate video reloaded for new final_fast_path:', m.final_fast_path);+          }+        }+        // Re-render the Publish-phase prompt with fresh context+        // (title, derived narrated path, etc.) each time the+        // manifest changes.+        renderPublishPrompt();+        console.log('manifest loaded: version', s.version, 'clips', m.clips.length, 'narrateReady', narrateReady);+      }+    } catch (e) {+      console.warn('pollLoop error:', e.message);+    }+    await new Promise(r => setTimeout(r, 800));+  }+}++window.addEventListener('load', pollLoop);+</script>+</body>+</html>
src/ctrl.rsadded+420
@@ -0,0 +1,420 @@+//! `adom-video-post list` and `adom-video-post ctrl` — AI control surface for+//! running webapp instances.+//!+//! Every `run_webapp` invocation writes a `~/.adom/adom-video-post/instances/+//! {pid}.json` file on startup and deletes it on shutdown. `list` reads+//! that directory, filters out stale PIDs (processes that died without+//! cleaning up), and prints a table. `ctrl` resolves a symbolic target+//! (`voiceover` / `storyboard` / `latest` / port / pid) to a concrete+//! port and dispatches one of a handful of actions against that+//! instance's HTTP surface.+//!+//! Supported `ctrl` actions:+//!   state           GET /state → print JSON+//!   console         GET /console[?since=N&wait=MS] → print messages+//!     --tail N      print only the last N+//!     --since SEQ   only messages whose seq > SEQ+//!     --follow      long-poll (blocking, exits on Ctrl-C)+//!   eval <code>     POST /eval + poll result (same as `adom-video-post eval`)+//!   shutdown        POST /shutdown+//!   call M PATH     raw HTTP call, optional --body JSON+//!+//! All HTTP calls are done via curl so there's no extra dep.++use crate::webapp::registry;+use std::fs;+use std::process::Command;++/// One live instance's registry record.+#[derive(Debug, Clone)]+pub struct Instance {+    pub pid: u32,+    pub port: u16,+    pub subcommand: String,+    pub title: String,+    pub started_at: u64,+}++/// Read every `*.json` in the instances directory, parse it, filter+/// out stale entries whose PID is dead, remove those stale files+/// from disk, and return the survivors sorted by started_at+/// ascending (oldest first).+pub fn list_instances() -> Result<Vec<Instance>, String> {+    let dir = registry::instances_dir()?;+    if !dir.exists() {+        return Ok(Vec::new());+    }+    let mut out: Vec<Instance> = Vec::new();+    let entries = fs::read_dir(&dir)+        .map_err(|e| format!("read_dir {}: {}", dir.display(), e))?;+    for entry in entries.flatten() {+        let path = entry.path();+        if path.extension().and_then(|s| s.to_str()) != Some("json") {+            continue;+        }+        let body = match fs::read_to_string(&path) {+            Ok(b) => b,+            Err(_) => continue,+        };+        let parsed: serde_json::Value = match serde_json::from_str(&body) {+            Ok(v) => v,+            Err(_) => continue,+        };+        let pid = parsed.get("pid").and_then(|v| v.as_u64()).unwrap_or(0) as u32;+        if pid == 0 {+            continue;+        }+        if !pid_alive(pid) {+            // Stale entry — clean it up so the next list() is faster.+            let _ = fs::remove_file(&path);+            continue;+        }+        out.push(Instance {+            pid,+            port: parsed.get("port").and_then(|v| v.as_u64()).unwrap_or(0) as u16,+            subcommand: parsed+                .get("subcommand")+                .and_then(|v| v.as_str())+                .unwrap_or("")+                .to_string(),+            title: parsed+                .get("title")+                .and_then(|v| v.as_str())+                .unwrap_or("")+                .to_string(),+            started_at: parsed+                .get("startedAt")+                .and_then(|v| v.as_u64())+                .unwrap_or(0),+        });+    }+    out.sort_by_key(|i| i.started_at);+    Ok(out)+}++/// Check whether a process with the given PID is still alive.+/// Uses `kill -0 <pid>` which sends the null signal — it doesn't+/// actually signal the process, it just validates whether the+/// signal COULD be delivered (i.e., the PID exists and we have+/// permission).+fn pid_alive(pid: u32) -> bool {+    Command::new("kill")+        .args(["-0", &pid.to_string()])+        .output()+        .map(|o| o.status.success())+        .unwrap_or(false)+}++/// Resolve a user-supplied target string to a concrete instance.+///+/// Resolution order:+///   1. "latest"           → most recently started instance+///   2. "<subcommand>"     → first instance whose subcommand matches+///   3. "<port>"           → instance listening on that port+///   4. "pid:<pid>"        → instance with that exact PID+///   5. numeric 0..=65535  → treated as a port (case 3)+///   6. numeric > 65535    → treated as a pid (case 4)+pub fn resolve_target(target: &str) -> Result<Instance, String> {+    let instances = list_instances()?;+    if instances.is_empty() {+        return Err(format!(+            "no running adom-video-post instances found. Hint: start one with `adom-video-post voiceover --input ...` or `adom-video-post storyboard <manifest>`."+        ));+    }++    if target == "latest" {+        return instances.last().cloned().ok_or_else(|| "no instances".to_string());+    }++    if let Some(pid_str) = target.strip_prefix("pid:") {+        let pid: u32 = pid_str+            .parse()+            .map_err(|_| format!("invalid pid in target {:?}", target))?;+        return instances+            .into_iter()+            .find(|i| i.pid == pid)+            .ok_or_else(|| format!("no running instance with pid {}", pid));+    }++    // Numeric: port (<=65535) or pid (>65535).+    if let Ok(n) = target.parse::<u64>() {+        if n <= 65535 {+            let port = n as u16;+            return instances+                .into_iter()+                .find(|i| i.port == port)+                .ok_or_else(|| format!("no running instance on port {}", port));+        }+        let pid = n as u32;+        return instances+            .into_iter()+            .find(|i| i.pid == pid)+            .ok_or_else(|| format!("no running instance with pid {}", pid));+    }++    // String: subcommand match (case-insensitive).+    let t = target.to_lowercase();+    instances+        .into_iter()+        .find(|i| i.subcommand.to_lowercase() == t)+        .ok_or_else(|| {+            format!(+                "no running instance matching {:?}. Hint: run `adom-video-post list` to see what is running.",+                target+            )+        })+}++/// `adom-video-post list` — print the registry as a human-readable table.+pub fn cmd_list() -> Result<(), String> {+    let instances = list_instances()?;+    if instances.is_empty() {+        println!("no adom-video-post instances running");+        return Ok(());+    }+    println!(+        "{:<7} {:<6} {:<12} {:<40} STARTED",+        "PID", "PORT", "SUBCOMMAND", "TITLE"+    );+    let now = std::time::SystemTime::now()+        .duration_since(std::time::UNIX_EPOCH)+        .map(|d| d.as_secs())+        .unwrap_or(0);+    for i in &instances {+        let age = now.saturating_sub(i.started_at);+        let age_str = human_duration(age);+        let title = if i.title.len() > 38 {+            format!("{}…", &i.title[..37])+        } else {+            i.title.clone()+        };+        println!(+            "{:<7} {:<6} {:<12} {:<40} {} ago",+            i.pid, i.port, i.subcommand, title, age_str+        );+    }+    Ok(())+}++fn human_duration(secs: u64) -> String {+    if secs < 60 {+        format!("{}s", secs)+    } else if secs < 3600 {+        format!("{}m", secs / 60)+    } else if secs < 86400 {+        format!("{}h", secs / 3600)+    } else {+        format!("{}d", secs / 86400)+    }+}++/// `adom-video-post ctrl <target> <action> [args...]` dispatcher.+pub fn cmd_ctrl(target: &str, action: &CtrlAction) -> Result<(), String> {+    let instance = resolve_target(target)?;+    let base = format!("http://127.0.0.1:{}", instance.port);+    eprintln!(+        "→ {} (pid {}, port {})",+        instance.subcommand, instance.pid, instance.port+    );+    match action {+        CtrlAction::State => ctrl_state(&base),+        CtrlAction::Console { tail, since, follow } => ctrl_console(&base, *tail, *since, *follow),+        CtrlAction::Eval { code, timeout } => ctrl_eval(&base, code, *timeout),+        CtrlAction::Shutdown => ctrl_shutdown(&base),+        CtrlAction::Call { method, path, body } => ctrl_call(&base, method, path, body.as_deref()),+    }+}++pub enum CtrlAction {+    State,+    Console {+        tail: Option<usize>,+        since: Option<u64>,+        follow: bool,+    },+    Eval {+        code: String,+        timeout: u64,+    },+    Shutdown,+    Call {+        method: String,+        path: String,+        body: Option<String>,+    },+}++fn ctrl_state(base: &str) -> Result<(), String> {+    let out = curl_text("GET", &format!("{}/state", base), None, 5)?;+    let parsed: serde_json::Value =+        serde_json::from_str(&out).map_err(|e| format!("parse /state: {} ({})", e, out))?;+    println!("{}", serde_json::to_string_pretty(&parsed).unwrap_or(out));+    Ok(())+}++fn ctrl_console(+    base: &str,+    tail: Option<usize>,+    since: Option<u64>,+    follow: bool,+) -> Result<(), String> {+    let mut since = since.unwrap_or(0);+    loop {+        let qs = if follow {+            format!("?since={}&wait=25000", since)+        } else {+            format!("?since={}", since)+        };+        let url = format!("{}/console{}", base, qs);+        // When following we allow the long-poll to block for ~25s.+        let timeout = if follow { 30 } else { 10 };+        let body = curl_text("GET", &url, None, timeout)?;+        let parsed: serde_json::Value =+            serde_json::from_str(&body).map_err(|e| format!("parse /console: {} ({})", e, body))?;+        let seq = parsed.get("seq").and_then(|v| v.as_u64()).unwrap_or(since);+        let msgs = parsed+            .get("messages")+            .and_then(|v| v.as_array())+            .cloned()+            .unwrap_or_default();+        let start = match tail {+            Some(n) if msgs.len() > n && !follow => msgs.len() - n,+            _ => 0,+        };+        for m in &msgs[start..] {+            println!("{}", m);+        }+        if !follow {+            return Ok(());+        }+        // In follow mode, advance `since` past what we just printed so+        // the next long-poll only wakes on truly-new messages. If the+        // long-poll returned 204 (empty), curl_text returns "" and we+        // just loop.+        if seq > since {+            since = seq;+        }+    }+}++fn ctrl_eval(base: &str, code: &str, timeout: u64) -> Result<(), String> {+    // POST the snippet.+    let post_body = serde_json::json!({ "code": code }).to_string();+    let post = curl_text("POST", &format!("{}/eval", base), Some(&post_body), 5)?;+    let parsed: serde_json::Value =+        serde_json::from_str(&post).map_err(|e| format!("parse /eval response: {} ({})", e, post))?;+    let id = parsed+        .get("id")+        .and_then(|v| v.as_str())+        .ok_or_else(|| format!("/eval missing 'id': {}", post))?+        .to_string();++    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout);+    loop {+        let (body, status) = curl_text_with_status(+            "GET",+            &format!("{}/eval/{}/result", base, id),+            None,+            30,+        )?;+        if status == 200 {+            let result: serde_json::Value = serde_json::from_str(&body)+                .map_err(|e| format!("parse result: {} ({})", e, body))?;+            println!("{}", serde_json::to_string_pretty(&result).unwrap_or(body));+            let ok = result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);+            if ok {+                return Ok(());+            }+            return Err(result+                .get("error")+                .and_then(|v| v.as_str())+                .unwrap_or("eval returned ok:false")+                .to_string());+        }+        if status != 204 {+            return Err(format!("unexpected status {}: {}", status, body));+        }+        if std::time::Instant::now() >= deadline {+            return Err(format!("timed out after {}s waiting for eval result", timeout));+        }+    }+}++fn ctrl_shutdown(base: &str) -> Result<(), String> {+    let body = curl_text("POST", &format!("{}/shutdown", base), None, 5)?;+    println!("{}", body.trim());+    Ok(())+}++fn ctrl_call(+    base: &str,+    method: &str,+    path: &str,+    body: Option<&str>,+) -> Result<(), String> {+    let full = if path.starts_with('/') {+        format!("{}{}", base, path)+    } else {+        format!("{}/{}", base, path)+    };+    let out = curl_text(method, &full, body, 30)?;+    println!("{}", out);+    Ok(())+}++/// Shell out to curl for a simple HTTP call. Returns the response+/// body on a 2xx, errors otherwise.+fn curl_text(+    method: &str,+    url: &str,+    body: Option<&str>,+    timeout_secs: u64,+) -> Result<String, String> {+    let (resp, status) = curl_text_with_status(method, url, body, timeout_secs)?;+    if !(200..300).contains(&status) && status != 204 {+        return Err(format!("HTTP {} from {}: {}", status, url, resp));+    }+    Ok(resp)+}++/// Like `curl_text` but returns (body, status_code) so the caller+/// can handle 204 vs 200 branching explicitly (used by /eval polling).+fn curl_text_with_status(+    method: &str,+    url: &str,+    body: Option<&str>,+    timeout_secs: u64,+) -> Result<(String, u16), String> {+    let mut cmd = Command::new("curl");+    cmd.args([+        "-s",+        "-X",+        method,+        "-w",+        "\n%{http_code}",+        "--max-time",+        &timeout_secs.to_string(),+    ]);+    if let Some(b) = body {+        cmd.args(["-H", "Content-Type: application/json", "-d", b]);+    }+    cmd.arg(url);+    let out = cmd+        .output()+        .map_err(|e| format!("curl spawn failed: {}", e))?;+    let raw = String::from_utf8_lossy(&out.stdout).to_string();+    let (body, status_line) = raw+        .rsplit_once('\n')+        .map(|(b, s)| (b.to_string(), s.to_string()))+        .unwrap_or_else(|| (raw.clone(), "000".to_string()));+    let status: u16 = status_line.trim().parse().unwrap_or(0);+    if status == 0 {+        return Err(format!(+            "curl {} {} failed: {}",+            method,+            url,+            String::from_utf8_lossy(&out.stderr)+        ));+    }+    Ok((body, status))+}
src/ffmpeg.rsadded+624
@@ -0,0 +1,624 @@+//! ffmpeg invocation: build the filter_complex graph and run it.+//!+//! The strategy is a single ffmpeg call that:+//!   1. Trims the input into segments at every marker boundary+//!   2. Normal segments: setpts=PTS-STARTPTS (passthrough timing)+//!   3. Speedup segments: setpts=(1/speed)*(PTS-STARTPTS) + drawtext overlay+//!   4. Concats all segments back into one output+//!+//! Audio is dropped (-an). Phase 1 expects silent recordings.++use crate::markers::Segment;+use std::path::Path;+use std::process::Command;++const FONT_PATH: &str = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf";++/// Get the duration of a video file (in seconds) via ffprobe.+///+/// Handles three common cases:+///   1. Format-level `duration` is a number: parse it and return.+///   2. Format-level `duration` is `N/A` (common on MediaRecorder webms+///      that ship without a proper duration header): fall back to+///      querying the first video stream's duration.+///   3. Stream-level duration is also missing: remux via ffmpeg to a+///      fresh file, let ffmpeg compute the duration on the fly, and+///      probe the fresh file.+pub fn probe_duration(input: &Path) -> Result<f64, String> {+    // Try format-level duration first.+    let format_dur = run_ffprobe_duration(input, &["-show_entries", "format=duration"])?;+    if let Ok(v) = format_dur.parse::<f64>() {+        if v > 0.0 {+            return Ok(v);+        }+    }+    // Fall back to stream-level duration of the first video stream.+    let stream_dur = run_ffprobe_duration(+        input,+        &[+            "-select_streams", "v:0",+            "-show_entries", "stream=duration",+        ],+    )?;+    if let Ok(v) = stream_dur.parse::<f64>() {+        if v > 0.0 {+            return Ok(v);+        }+    }+    // Last resort: remux into a temp file with `-c copy` and probe that.+    // This is what MediaRecorder webms need because they never write a+    // proper container duration header; remuxing forces ffmpeg to+    // compute and write one.+    let tmp = std::env::temp_dir().join(format!(+        "adom-video-post-probe-{}.webm",+        std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0)+    ));+    let remux = Command::new("ffmpeg")+        .args(["-y", "-i"])+        .arg(input)+        .args(["-c", "copy"])+        .arg(&tmp)+        .output()+        .map_err(|e| format!("ffmpeg remux spawn failed: {}", e))?;+    if !remux.status.success() {+        return Err(format!(+            "ffprobe could not determine duration (format='{}', stream='{}') and remux fallback also failed: {}",+            format_dur, stream_dur, String::from_utf8_lossy(&remux.stderr).lines().last().unwrap_or("")+        ));+    }+    let after_remux = run_ffprobe_duration(&tmp, &["-show_entries", "format=duration"])?;+    let _ = std::fs::remove_file(&tmp);+    after_remux.parse::<f64>()+        .map_err(|e| format!("duration still unavailable after remux: {} ({})", after_remux, e))+}++fn run_ffprobe_duration(input: &Path, extra_args: &[&str]) -> Result<String, String> {+    let mut cmd = Command::new("ffprobe");+    cmd.args(["-v", "error"]);+    cmd.args(extra_args);+    cmd.args(["-of", "default=noprint_wrappers=1:nokey=1"]);+    cmd.arg(input);+    let output = cmd.output().map_err(|e| format!("ffprobe spawn failed: {}", e))?;+    if !output.status.success() {+        return Err(format!(+            "ffprobe failed: {}",+            String::from_utf8_lossy(&output.stderr)+        ));+    }+    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())+}++/// Get the (width, height) of the first video stream via ffprobe.+pub fn probe_dimensions(input: &Path) -> Result<(u32, u32), String> {+    let output = Command::new("ffprobe")+        .args([+            "-v",+            "error",+            "-select_streams",+            "v:0",+            "-show_entries",+            "stream=width,height",+            "-of",+            "csv=p=0:s=x",+        ])+        .arg(input)+        .output()+        .map_err(|e| format!("ffprobe spawn failed: {}", e))?;+    if !output.status.success() {+        return Err(format!(+            "ffprobe failed: {}",+            String::from_utf8_lossy(&output.stderr)+        ));+    }+    let s = String::from_utf8_lossy(&output.stdout).trim().to_string();+    let mut parts = s.split('x');+    let w = parts.next().and_then(|p| p.parse::<u32>().ok()).ok_or_else(|| format!("could not parse width from '{}'", s))?;+    let h = parts.next().and_then(|p| p.parse::<u32>().ok()).ok_or_else(|| format!("could not parse height from '{}'", s))?;+    Ok((w, h))+}++/// Compute a reasonable font size for the caption overlay based on video width.+/// Targets readability across resolutions: ~32px at 640w, ~48px at 1280w, ~64px at 1920w.+pub fn compute_font_size(width: u32) -> u32 {+    let raw = (width as f64 / 30.0) as u32;+    raw.clamp(20, 80)+}++/// Check ffmpeg + ffprobe versions. Returns (ffmpeg_version, ffprobe_version).+pub fn check_versions() -> Result<(String, String), String> {+    let ffmpeg = Command::new("ffmpeg")+        .arg("-version")+        .output()+        .map_err(|e| format!("ffmpeg not found in PATH: {}. Hint: sudo apt-get install ffmpeg", e))?;+    let ffprobe = Command::new("ffprobe")+        .arg("-version")+        .output()+        .map_err(|e| format!("ffprobe not found in PATH: {}. Hint: sudo apt-get install ffmpeg", e))?;+    let parse_v = |out: &[u8]| -> String {+        let s = String::from_utf8_lossy(out);+        s.lines()+            .next()+            .and_then(|line| line.split_whitespace().nth(2))+            .unwrap_or("?")+            .to_string()+    };+    Ok((parse_v(&ffmpeg.stdout), parse_v(&ffprobe.stdout)))+}++/// Escape a string for ffmpeg drawtext text= argument.+/// drawtext is finicky: colons, single quotes, backslashes, percents all need escaping.+fn escape_drawtext(s: &str) -> String {+    s.replace('\\', "\\\\")+        .replace(':', "\\:")+        .replace('\'', "\\'")+        .replace('%', "\\%")+}++/// Build the filter_complex graph string for the given segments and total duration.+/// font_size is the drawtext fontsize in pixels (use `compute_font_size` from the+/// probed video width).+/// Returns the filter_complex argument value (one big string with semicolons).+pub fn build_filter_complex(segments: &[Segment], total_duration: f64, font_size: u32) -> String {+    build_filter_complex_with_trim(segments, total_duration, font_size, 0.0)+}++/// Same as `build_filter_complex` but also trims the first `trim_offset`+/// seconds off the start of the input. The first normal section starts+/// at `trim_offset` instead of 0, and segments whose start_offset is+/// less than `trim_offset` should already have been clamped by the caller.+pub fn build_filter_complex_with_trim(segments: &[Segment], total_duration: f64, font_size: u32, trim_offset: f64) -> String {+    build_filter_complex_full(segments, total_duration, font_size, trim_offset, 1.0, false)+}++/// Full version: segments + trim_offset + base_speed + no_overlay.+/// `base_speed` > 1.0 applies a global setpts to normal (unmarked)+/// sections so the ENTIRE unmarked video runs faster. Marked speedup+/// sections still run at their own marker rate, the marker always+/// dominates the base. A base of 1.0 is a no-op and falls through to+/// the old behavior. `no_overlay` skips the red drawtext caption on+/// speedup sections — useful when the caller is already burning in+/// its own captions (e.g. desktop_caption step_marker) and the red+/// overlay would cover them up.+pub fn build_filter_complex_full(+    segments: &[Segment],+    total_duration: f64,+    font_size: u32,+    trim_offset: f64,+    base_speed: f64,+    no_overlay: bool,+) -> String {+    // Compute the alternating list of (start, end, type) sections.+    // Type is either "normal" (no speedup) or a speedup segment ref.+    enum Section<'a> {+        Normal { start: f64, end: f64 },+        Speedup { seg: &'a Segment },+    }++    let mut sections: Vec<Section> = Vec::new();+    let mut cursor = trim_offset.max(0.0);+    for seg in segments {+        if seg.start_offset > cursor + 0.001 {+            sections.push(Section::Normal {+                start: cursor,+                end: seg.start_offset,+            });+        }+        sections.push(Section::Speedup { seg });+        cursor = seg.end_offset;+    }+    if cursor < total_duration - 0.001 {+        sections.push(Section::Normal {+            start: cursor,+            end: total_duration,+        });+    }++    let mut parts: Vec<String> = Vec::new();+    let mut labels: Vec<String> = Vec::new();++    for (i, sec) in sections.iter().enumerate() {+        let label = format!("v{}", i);+        match sec {+            Section::Normal { start, end } => {+                // Apply base_speed to every normal (unmarked) section so+                // the global pace of the video is base_speed times the+                // original recording. base_speed == 1.0 falls through to+                // the plain passthrough setpts (unchanged).+                if (base_speed - 1.0).abs() < 0.001 {+                    parts.push(format!(+                        "[0:v]trim=start={:.3}:end={:.3},setpts=PTS-STARTPTS[{}]",+                        start, end, label+                    ));+                } else {+                    let base_factor = 1.0 / base_speed;+                    parts.push(format!(+                        "[0:v]trim=start={:.3}:end={:.3},setpts={:.6}*(PTS-STARTPTS)[{}]",+                        start, end, base_factor, label+                    ));+                }+            }+            Section::Speedup { seg } => {+                let setpts_factor = 1.0 / (seg.speed as f64);+                if no_overlay {+                    parts.push(format!(+                        "[0:v]trim=start={:.3}:end={:.3},setpts={:.6}*(PTS-STARTPTS)[{}]",+                        seg.start_offset, seg.end_offset, setpts_factor, label+                    ));+                } else {+                    let caption = format!("{}x SPEEDUP: {}", seg.speed, seg.label);+                    let escaped = escape_drawtext(&caption);+                    parts.push(format!(+                        "[0:v]trim=start={:.3}:end={:.3},setpts={:.6}*(PTS-STARTPTS),\+                         drawtext=text='{}':fontfile={}:fontsize={}:fontcolor=white:\+                         box=1:[email protected]:boxborderw=20:x=(w-text_w)/2:y=80[{}]",+                        seg.start_offset, seg.end_offset, setpts_factor, escaped, FONT_PATH, font_size, label+                    ));+                }+            }+        }+        labels.push(format!("[{}]", label));+    }++    if labels.is_empty() {+        // No segments at all. Pass through unchanged.+        return "[0:v]copy[out]".to_string();+    }++    let concat = format!(+        "{}concat=n={}:v=1:a=0[out]",+        labels.join(""),+        labels.len()+    );+    parts.push(concat);+    parts.join(";")+}++/// Run ffmpeg with the given filter_complex graph. Returns Ok on success or+/// an error message containing ffmpeg's stderr.+///+/// Encoder: libvpx-vp9 at 2 Mbps with `-deadline realtime -cpu-used 8`.+/// This is ~5-10x faster than libvpx-vp9's default "good" deadline, at a+/// tiny quality cost that does not matter for speedup content (drawn+/// captions + fast forward sections, not mastered video). For a 60 s+/// source on this hardware that is the difference between ~45 s encode+/// and ~6 minutes encode. When we start wanting higher quality we can+/// expose the speed knob as a CLI flag.+pub fn run_process(input: &Path, output: &Path, filter_complex: &str) -> Result<(), String> {+    run_process_with_duration(input, output, filter_complex, None)+}++/// Same as `run_process` but accepts an optional source duration for ETA+/// calculation. When provided, progress lines include an ETA.+/// `max_width` downscales the output (0 = no scaling).+pub fn run_process_with_duration(+    input: &Path,+    output: &Path,+    filter_complex: &str,+    source_duration: Option<f64>,+) -> Result<(), String> {+    run_process_full(input, output, filter_complex, source_duration, 0)+}++/// Full version with optional downscale. max_width > 0 appends a+/// scale filter to the output, e.g. 1280 → scale=1280:-2.+pub fn run_process_full(+    input: &Path,+    output: &Path,+    filter_complex: &str,+    source_duration: Option<f64>,+    max_width: u32,+) -> Result<(), String> {+    use std::io::{BufRead, BufReader};+    use std::process::Stdio;++    // -r 30 + -fps_mode cfr forces constant 30 fps output. This is+    // essential: the source recordings come from MediaRecorder which+    // emits variable-framerate frames (idle screens get long gaps+    // between frames). After setpts compression the gaps pack together+    // into stuttering bursts — the player sits on one frame for+    // seconds then jumps. Resampling to CFR evens it all out.+    //+    // -g 60 -keyint_min 60 force a keyframe every 2 seconds at 30 fps,+    // so the browser can seek and recover decoder state quickly.+    //+    // -progress pipe:1 writes machine-readable progress to stdout.+    //+    // If max_width is set and the source is wider, append a scale filter+    // to the filter_complex graph so ffmpeg encodes fewer pixels.+    let final_filter = if max_width > 0 {+        // Append scale to the [out] label: rename [out] → [pre], add scale+        let patched = filter_complex.replace("[out]", "[pre]");+        format!("{};[pre]scale='min({},iw)':-2[out]", patched, max_width)+    } else {+        filter_complex.to_string()+    };++    let mut child = Command::new("ffmpeg")+        .args(["-y", "-i"])+        .arg(input)+        .args(["-filter_complex", &final_filter])+        .args(["-map", "[out]"])+        .args([+            "-c:v", "libvpx-vp9",+            "-b:v", "2M",+            "-deadline", "realtime",+            "-cpu-used", "8",+            "-row-mt", "1",+            "-r", "30",+            "-fps_mode", "cfr",+            "-g", "60",+            "-keyint_min", "60",+            "-an",+            "-progress", "pipe:1",+        ])+        .arg(output)+        .stdout(Stdio::piped())+        .stderr(Stdio::piped())+        .spawn()+        .map_err(|e| format!("ffmpeg spawn failed: {}", e))?;++    // Stream stdout for progress (ffmpeg -progress pipe:1 writes+    // key=value lines with out_time_ms= showing encode position).+    let stdout = child.stdout.take().expect("stdout piped");+    let start = std::time::Instant::now();+    let reader = BufReader::new(stdout);+    let mut last_print = std::time::Instant::now();++    for line in reader.lines() {+        let line = match line {+            Ok(l) => l,+            Err(_) => break,+        };+        // Parse out_time_ms=<microseconds>+        if let Some(val) = line.strip_prefix("out_time_ms=") {+            if let Ok(us) = val.trim().parse::<i64>() {+                let encoded_s = us as f64 / 1_000_000.0;+                let elapsed = start.elapsed().as_secs_f64();+                // Throttle prints to once per second+                if last_print.elapsed().as_secs_f64() >= 1.0 {+                    last_print = std::time::Instant::now();+                    if let Some(dur) = source_duration {+                        if encoded_s > 0.1 && dur > 0.1 {+                            let pct = (encoded_s / dur * 100.0).min(100.0);+                            let speed = encoded_s / elapsed;+                            let remaining_s = if speed > 0.01 {+                                (dur - encoded_s) / speed+                            } else {+                                0.0+                            };+                            eprint!(+                                "\r\x1b[K  \x1b[36m{:.0}%\x1b[0m  {:.0}s/{:.0}s encoded  \x1b[2m{:.1}x speed  ~{:.0}s remaining\x1b[0m",+                                pct, encoded_s, dur, speed, remaining_s+                            );+                        }+                    } else {+                        eprint!(+                            "\r\x1b[K  \x1b[36m{:.0}s\x1b[0m encoded  \x1b[2m{:.0}s elapsed\x1b[0m",+                            encoded_s, elapsed+                        );+                    }+                }+            }+        }+    }+    eprintln!(); // newline after progress++    let result = child.wait().map_err(|e| format!("ffmpeg wait failed: {}", e))?;+    if !result.success() {+        // Read stderr for error details+        let mut stderr_str = String::new();+        if let Some(mut stderr) = child.stderr.take() {+            use std::io::Read;+            let _ = stderr.read_to_string(&mut stderr_str);+        }+        let normalised = stderr_str.replace('\r', "\n");+        let meaningful: Vec<&str> = normalised+            .lines()+            .map(str::trim_end)+            .filter(|l| {+                !l.is_empty()+                    && !l.starts_with("frame=")+                    && !l.starts_with("size=")+                    && !l.starts_with("Press [q]")+            })+            .collect();+        let tail_start = meaningful.len().saturating_sub(60);+        let tail = &meaningful[tail_start..];+        return Err(format!("ffmpeg failed:\n{}", tail.join("\n")));+    }++    let total_elapsed = start.elapsed().as_secs_f64();+    eprintln!("  \x1b[32mdone\x1b[0m in {:.0}s", total_elapsed);+    Ok(())+}++/// Generate a first-frame JPG poster from a video.+///+/// Used by the storyboard subcommand to populate `clip.poster_path`+/// lazily when the manifest doesn't already have one. The resulting+/// JPG is used as the `<video poster=...>` attribute so cards render+/// a visible thumbnail before the user clicks play, without+/// downloading any video bytes.+pub fn generate_poster(video: &Path, output: &Path) -> Result<(), String> {+    let result = Command::new("ffmpeg")+        .args(["-y", "-loglevel", "error", "-i"])+        .arg(video)+        .args(["-frames:v", "1", "-q:v", "3"])+        .arg(output)+        .output()+        .map_err(|e| format!("ffmpeg spawn failed: {}", e))?;+    if !result.status.success() {+        return Err(format!(+            "ffmpeg poster failed: {}",+            String::from_utf8_lossy(&result.stderr).lines().rev().take(5).collect::<Vec<_>>().join("; ")+        ));+    }+    Ok(())+}++/// Append a still image (held for `duration` seconds) to the end+/// of a video. Used by demo workflows to give viewers a readable+/// pause on the actual exported data (console output, gerber file+/// list, BOM rows, etc.) right after a sped-up action.+///+/// The still is padded to match the source video's dimensions and+/// encoded with the same CFR 30 fps + 2s keyframe interval as the+/// main speedup pipeline, so `-c copy` concat works without+/// re-encoding. If the concat-copy fails for any reason, falls back+/// to a full re-encode.+pub fn append_still(+    video: &Path,+    image: &Path,+    duration: f64,+    output: &Path,+) -> Result<(), String> {+    let (w, h) = probe_dimensions(video)?;++    // Step 1: encode the still as a short webm matching the source+    // dimensions and codec params.+    let still_tmp = std::env::temp_dir().join(format!(+        "adom-video-post-still-{}.webm",+        std::process::id()+    ));+    let pad_filter = format!(+        "scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2:black,setsar=1",+        w, h, w, h+    );+    let still_result = Command::new("ffmpeg")+        .args(["-y", "-loglevel", "error", "-loop", "1", "-framerate", "30", "-t"])+        .arg(format!("{}", duration))+        .args(["-i"])+        .arg(image)+        .args([+            "-vf", &pad_filter,+            "-c:v", "libvpx-vp9",+            "-b:v", "2M",+            "-deadline", "realtime",+            "-cpu-used", "8",+            "-row-mt", "1",+            "-r", "30",+            "-fps_mode", "cfr",+            "-g", "60",+            "-keyint_min", "60",+            "-pix_fmt", "yuv420p",+            "-an",+        ])+        .arg(&still_tmp)+        .output()+        .map_err(|e| format!("ffmpeg spawn failed: {}", e))?;+    if !still_result.status.success() {+        let _ = std::fs::remove_file(&still_tmp);+        return Err(format!(+            "ffmpeg still encode failed: {}",+            String::from_utf8_lossy(&still_result.stderr)+                .lines()+                .rev()+                .take(5)+                .collect::<Vec<_>>()+                .join("; ")+        ));+    }++    // Step 2: concat video + still into output. Try copy first,+    // fall back to re-encode.+    let list_tmp = std::env::temp_dir().join(format!(+        "adom-video-post-concat-{}.txt",+        std::process::id()+    ));+    std::fs::write(+        &list_tmp,+        format!("file '{}'\nfile '{}'\n", video.display(), still_tmp.display()),+    )+    .map_err(|e| format!("write concat list: {}", e))?;++    let copy_result = Command::new("ffmpeg")+        .args(["-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i"])+        .arg(&list_tmp)+        .args(["-c", "copy", "-an"])+        .arg(output)+        .output();+    let ok = matches!(&copy_result, Ok(r) if r.status.success());+    if !ok {+        // Fall back to re-encode.+        let re_result = Command::new("ffmpeg")+            .args(["-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i"])+            .arg(&list_tmp)+            .args([+                "-c:v", "libvpx-vp9",+                "-b:v", "2M",+                "-deadline", "realtime",+                "-cpu-used", "8",+                "-row-mt", "1",+                "-r", "30",+                "-fps_mode", "cfr",+                "-g", "60",+                "-keyint_min", "60",+                "-pix_fmt", "yuv420p",+                "-an",+            ])+            .arg(output)+            .output()+            .map_err(|e| format!("ffmpeg spawn failed: {}", e))?;+        if !re_result.status.success() {+            let _ = std::fs::remove_file(&still_tmp);+            let _ = std::fs::remove_file(&list_tmp);+            return Err(format!(+                "ffmpeg concat re-encode failed: {}",+                String::from_utf8_lossy(&re_result.stderr)+                    .lines()+                    .rev()+                    .take(10)+                    .collect::<Vec<_>>()+                    .join("; ")+            ));+        }+    }++    let _ = std::fs::remove_file(&still_tmp);+    let _ = std::fs::remove_file(&list_tmp);+    Ok(())+}++/// Concatenate multiple webm videos into one. Uses ffmpeg's concat+/// demuxer with `-c copy` (lossless, fast) — assumes the inputs+/// share codec and timebase, which is true for everything produced+/// by `adom-video-post process` + `append_still`.+pub fn concat_videos(inputs: &[std::path::PathBuf], output: &Path) -> Result<(), String> {+    if inputs.is_empty() {+        return Err("concat: no input videos".to_string());+    }+    let list_tmp = std::env::temp_dir().join(format!(+        "adom-video-post-concat-list-{}.txt",+        std::process::id()+    ));+    let mut body = String::new();+    for p in inputs {+        if !p.exists() {+            let _ = std::fs::remove_file(&list_tmp);+            return Err(format!("concat: input missing: {}", p.display()));+        }+        body.push_str(&format!("file '{}'\n", p.display()));+    }+    std::fs::write(&list_tmp, body).map_err(|e| format!("write concat list: {}", e))?;++    let result = Command::new("ffmpeg")+        .args(["-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i"])+        .arg(&list_tmp)+        .args(["-c", "copy", "-an"])+        .arg(output)+        .output()+        .map_err(|e| format!("ffmpeg spawn failed: {}", e))?;+    let _ = std::fs::remove_file(&list_tmp);+    if !result.status.success() {+        return Err(format!(+            "ffmpeg concat failed: {}",+            String::from_utf8_lossy(&result.stderr)+                .lines()+                .rev()+                .take(10)+                .collect::<Vec<_>>()+                .join("; ")+        ));+    }+    Ok(())+}
src/install.rsadded+16
@@ -0,0 +1,16 @@+//! Deploy the embedded SKILL.md to ~/.claude/skills/adom-video-post/SKILL.md+//! so Claude Code finds it via trigger words.++use std::fs;+use std::path::PathBuf;++const SKILL_MD: &str = include_str!("../SKILL.md");++pub fn install() -> Result<PathBuf, String> {+    let home = std::env::var("HOME").map_err(|_| "HOME env var not set".to_string())?;+    let dir = PathBuf::from(home).join(".claude").join("skills").join("adom-video-post");+    fs::create_dir_all(&dir).map_err(|e| format!("create {}: {}", dir.display(), e))?;+    let path = dir.join("SKILL.md");+    fs::write(&path, SKILL_MD).map_err(|e| format!("write {}: {}", path.display(), e))?;+    Ok(path)+}
src/main.rsadded+1190
@@ -0,0 +1,1190 @@+//! adom-video-post: post-process screen recordings with speedup overlays.+//!+//! See SKILL.md for the full feature description.++mod app;+mod ctrl;+mod ffmpeg;+mod install;+mod markers;+mod publish;+mod storyboard;+mod voiceover;+mod webapp;++use clap::{Args, Parser, Subcommand};+use std::io::IsTerminal;+use std::path::PathBuf;+use std::process::ExitCode;++// ANSI color constants. The internal status prints (filter graph+// info, etc.) use these directly in format strings because those are+// always terminal output. The four USER-FACING helpers below (ok, err,+// hint, warn) are gated on isatty so when the CLI is called via a pipe+// or from a shell tool, the "OK:" / "ERROR:" / "Hint:" / "WARN:" lines+// emit plain text without ANSI escape soup.+use std::sync::OnceLock;+static STDOUT_IS_TTY: OnceLock<bool> = OnceLock::new();+static STDERR_IS_TTY: OnceLock<bool> = OnceLock::new();+fn stdout_is_tty() -> bool { *STDOUT_IS_TTY.get_or_init(|| std::io::stdout().is_terminal()) }+fn stderr_is_tty() -> bool { *STDERR_IS_TTY.get_or_init(|| std::io::stderr().is_terminal()) }++const GREEN: &str = "\x1b[32m";+const RED: &str = "\x1b[31m";+const YELLOW: &str = "\x1b[33m";+const CYAN: &str = "\x1b[36m";+const BOLD: &str = "\x1b[1m";+const DIM: &str = "\x1b[2m";+const RESET: &str = "\x1b[0m";++/// Strip ANSI escape sequences from a string. Used when emitting to a+/// non-TTY stdout/stderr so callers that embed {CYAN}...{RESET} inside+/// their message format strings don't leak escape codes.+fn strip_ansi(s: &str) -> String {+    let mut out = String::with_capacity(s.len());+    let mut chars = s.chars().peekable();+    while let Some(c) = chars.next() {+        if c == '\x1b' && chars.peek() == Some(&'[') {+            chars.next(); // consume '['+            for c2 in chars.by_ref() {+                if c2.is_ascii_alphabetic() { break; }+            }+            continue;+        }+        out.push(c);+    }+    out+}++fn ok(msg: impl std::fmt::Display) {+    if stdout_is_tty() {+        println!("{GREEN}{BOLD}OK:{RESET} {msg}");+    } else {+        println!("OK: {}", strip_ansi(&msg.to_string()));+    }+}+fn err(msg: impl std::fmt::Display) {+    if stderr_is_tty() {+        eprintln!("{RED}{BOLD}ERROR:{RESET} {msg}");+    } else {+        eprintln!("ERROR: {}", strip_ansi(&msg.to_string()));+    }+}+fn hint(msg: impl std::fmt::Display) {+    if stderr_is_tty() {+        eprintln!("{DIM}Hint:{RESET} {msg}");+    } else {+        eprintln!("Hint: {}", strip_ansi(&msg.to_string()));+    }+}+fn warn(msg: impl std::fmt::Display) {+    if stderr_is_tty() {+        eprintln!("{YELLOW}{BOLD}WARN:{RESET} {msg}");+    } else {+        eprintln!("WARN: {}", strip_ansi(&msg.to_string()));+    }+}++/// Progress `println!` that strips ANSI escape codes when stdout is+/// not a TTY. Every internal progress line (filter graph info,+/// "starting X server on port Y", "running ffmpeg...", per-segment+/// summary) goes through this instead of raw `println!` so piped+/// output doesn't leak `[36m` garbage into log files. This is the+/// rule from the adom-cli-design skill §"Colored output" — colors+/// are for humans only, never for pipes.+macro_rules! note {+    ($($arg:tt)*) => {{+        let __s = format!($($arg)*);+        if $crate::stdout_is_tty() {+            println!("{}", __s);+        } else {+            println!("{}", $crate::strip_ansi(&__s));+        }+    }};+}+macro_rules! enote {+    ($($arg:tt)*) => {{+        let __s = format!($($arg)*);+        if $crate::stderr_is_tty() {+            eprintln!("{}", __s);+        } else {+            eprintln!("{}", $crate::strip_ansi(&__s));+        }+    }};+}++#[derive(Parser)]+#[command(name = "adom-video-post", version, about = "AI-driven demo-finishing studio: review every captured clip, flag & re-record the weak ones, speed up dead air, narrate, mux + auto-level, validate the final cut, and publish to the wiki")]+struct Cli {+    #[command(subcommand)]+    cmd: Cmd,+}++#[derive(Subcommand)]+enum Cmd {+    /// Verify ffmpeg + ffprobe are installed+    Health,++    /// Drop a speedup_start, speedup_end, or recording_start event in the markers file+    #[command(subcommand)]+    Mark(MarkCmd),++    /// Wrap a command with start/end markers and execute it+    Wrap(WrapArgs),++    /// Print a human-readable summary of the markers file+    Inspect(InspectArgs),++    /// Speedup post-process: read markers + input video, produce sped-up output+    Process(ProcessArgs),++    /// Unified Adom Video Post app: opens one Hydrogen tab with all three+    /// post-production phases (Review / Narrate / Publish) behind a+    /// phase selector. Takes a manifest like `storyboard` does. This+    /// is the recommended entrypoint — the standalone `storyboard`+    /// and `voiceover` subcommands still exist for direct launches+    /// but the unified app is what most users should use.+    App(AppArgs),++    /// Voiceover overlay: open a Hydrogen webview, narrate over the video, mux audio+    Voiceover(VoiceoverArgs),++    /// Storyboard review: open a Hydrogen webview pointed at a clip+    /// manifest. Live-watches the manifest file by default so the UI+    /// updates as the demo script appends new clips.+    Storyboard(StoryboardArgs),++    /// Manifest CRUD: init / add / inspect / remove. Demo scripts+    /// call `manifest add` once per completed step.+    #[command(subcommand)]+    Manifest(ManifestCmd),++    /// Append a still image (held for `--duration` seconds) to the+    /// end of a video. Used by demo workflows for data-heavy steps+    /// where a sped-up clip blurs through the actual exported values.+    AppendStill(AppendStillArgs),++    /// Concat all `--kind raw` or `--kind fast` clips from a manifest+    /// into a single output webm and write the path back into the+    /// manifest's `final_<kind>_path` field.+    Concat(ConcatArgs),++    /// Publish a final video to a wiki page (wraps adom-wiki asset upload)+    Publish(PublishArgs),++    /// Run a JavaScript snippet inside a running adom-video-post web UI and+    /// print the result. Uses the /eval hot-patch channel from the+    /// app-creator skill — works against voiceover, storyboard, and+    /// any future webapp subcommand without a rebuild.+    Eval(EvalArgs),++    /// List every running adom-video-post web UI (reads the instance+    /// registry in `~/.adom/adom-video-post/instances/`, filters out stale+    /// PIDs). Shows PID, port, subcommand, title, age.+    List,++    /// One-stop control surface for a running adom-video-post web UI.+    /// Target can be `latest`, a subcommand name (`voiceover`,+    /// `storyboard`), a port, `pid:<pid>`, or a bare pid. Actions+    /// include `state`, `console [--tail --since --follow]`,+    /// `eval <code>`, `shutdown`, and `call <METHOD> <PATH>`.+    Ctrl(CtrlArgs),++    /// Deploy the embedded SKILL.md to ~/.claude/skills/adom-video-post/+    Install,+}++#[derive(Subcommand)]+enum MarkCmd {+    /// Clear the markers file and write a recording_start event+    Init {+        #[arg(long, default_value_t = markers::default_path())]+        file: String,+    },+    /// Drop a speedup_start event+    Start {+        #[arg(long)]+        speed: u32,+        #[arg(long)]+        label: String,+        #[arg(long, default_value_t = markers::default_path())]+        file: String,+    },+    /// Drop a speedup_end event+    End {+        #[arg(long, default_value_t = markers::default_path())]+        file: String,+    },+}++#[derive(Args)]+struct WrapArgs {+    #[arg(long)]+    speed: u32,+    #[arg(long)]+    label: String,+    #[arg(long, default_value_t = markers::default_path())]+    file: String,+    /// Command to run after the markers (everything after `--`)+    #[arg(trailing_var_arg = true, allow_hyphen_values = true, num_args = 1..)]+    command: Vec<String>,+}++#[derive(Args)]+struct InspectArgs {+    #[arg(long, default_value_t = markers::default_path())]+    file: String,+}++#[derive(Args)]+struct ProcessArgs {+    #[arg(long)]+    input: PathBuf,+    #[arg(long, default_value_t = markers::default_path())]+    markers: String,+    /// Output path. Default: <input-stem>-fast.<ext>+    #[arg(long)]+    output: Option<PathBuf>,+    /// Cap the speed multiplier (defensive, anything above 100 looks like a glitch)+    #[arg(long, default_value_t = 100)]+    max_speed: u32,+    /// Trim dead air from the start of the video. Values:+    ///   "auto"      = trim to first speedup marker minus 2s buffer+    ///   "<float>"   = trim the first N seconds literally+    ///   (unset)     = do not trim anything+    /// The "auto" mode is the most useful: it cuts the pre-first-caption+    /// setup time off the front so the final video starts right where+    /// the first meaningful content is.+    #[arg(long)]+    trim_start: Option<String>,+    /// Trim dead air from the end of the video. Values:+    ///   "auto"      = trim to last speedup_end marker plus 4s buffer+    ///   "<float>"   = keep only the first N seconds from the (post trim-start) video+    ///   (unset)     = do not trim anything+    /// The "auto" mode handles the common case of the AI doing cleanup+    /// work after the final caption, which is boring dead air the viewer+    /// does not care about.+    #[arg(long)]+    trim_end: Option<String>,+    /// Apply a global base speed multiplier to the ENTIRE video, on top+    /// of any per-segment speedup markers. Normal (unmarked) sections+    /// run at this rate; marked sections still run at their own marker+    /// speed (the marker dominates). Use this to crunch out the AI's+    /// natural slow pacing across the whole recording. 2.0 is a good+    /// default that stays readable. Set to 1.0 (or omit) to disable.+    #[arg(long, default_value_t = 1.0)]+    base_speed: f64,+    /// Disable the red "Nx SPEEDUP: label" drawtext overlay on speedup+    /// sections. Useful when the caller is already burning in its own+    /// captions (e.g. desktop_caption step markers) during recording,+    /// so the adom-video-post overlay would just cover them up. When set,+    /// the speedup still happens — only the drawtext caption is skipped.+    #[arg(long, default_value_t = false)]+    no_overlay: bool,+    /// Downscale output to this max width in pixels. Default 1280.+    /// Set to 0 to keep original resolution. Wiki demos don't need+    /// 2546px wide video; 1280 encodes ~4x faster and looks fine.+    #[arg(long, default_value_t = 1280)]+    max_width: u32,+}++#[derive(Args)]+struct AppArgs {+    /// Path to the manifest JSON file (typically /tmp/storyboard-manifest.json).+    manifest: PathBuf,+    /// HTTP server port. Defaults to 8798 for the unified app (8796 = voiceover, 8797 = storyboard).+    #[arg(long, default_value_t = 8798)]+    port: u16,+    /// Hydrogen webview tab name.+    #[arg(long, default_value_t = String::from("Adom Video Post"))]+    tab_name: String,+    /// Force a specific Hydrogen pane id. Omit to auto-pick a pane+    /// that does NOT contain VS Code.+    #[arg(long)]+    panel_id: Option<String>,+    /// Disable the manifest file-watcher.+    #[arg(long)]+    no_watch: bool,+    /// Run HTTP server only — skip opening a Hydrogen webview tab.+    /// Use when reviewing in pup or driving via browser_eval.+    #[arg(long)]+    no_open: bool,+}++#[derive(Args)]+struct StoryboardArgs {+    /// Path to the manifest JSON file (typically /tmp/storyboard-manifest.json).+    manifest: PathBuf,+    /// HTTP server port. Defaults to 8797 (storyboard); voiceover lives on 8796.+    #[arg(long, default_value_t = 8797)]+    port: u16,+    /// Hydrogen webview tab name.+    #[arg(long, default_value_t = String::from("Storyboard"))]+    tab_name: String,+    /// Force a specific Hydrogen pane id. Omit to auto-pick a pane+    /// that does NOT contain VS Code.+    #[arg(long)]+    panel_id: Option<String>,+    /// Disable the file-watcher (which by default polls the manifest+    /// every 500ms and live-updates the UI when it changes).+    #[arg(long)]+    no_watch: bool,+    /// Run HTTP server only — skip opening a Hydrogen webview tab.+    /// Use when reviewing in pup or driving via browser_eval.+    #[arg(long)]+    no_open: bool,+}++#[derive(Subcommand)]+enum ManifestCmd {+    /// Create an empty manifest file.+    Init {+        file: PathBuf,+        #[arg(long, default_value_t = String::from("Untitled"))]+        title: String,+    },+    /// Append a clip entry to the manifest. Auto-creates the file+    /// if it doesn't exist (so the demo script doesn't have to+    /// call `init` first).+    Add {+        file: PathBuf,+        #[arg(long)]+        id: String,+        #[arg(long)]+        title: String,+        #[arg(long)]+        description: Option<String>,+        #[arg(long)]+        raw: Option<PathBuf>,+        #[arg(long)]+        fast: Option<PathBuf>,+        #[arg(long)]+        image: Option<PathBuf>,+        #[arg(long = "console-png")]+        console_png: Option<PathBuf>,+        /// `SPEED:LABEL`, repeatable. e.g. `--speedup "20:Exporting gerbers"`+        #[arg(long = "speedup")]+        speedups: Vec<String>,+        /// Free-form bullet for the action log, repeatable.+        #[arg(long = "action")]+        actions: Vec<String>,+        /// Per-clip warning shown in the UI, repeatable.+        #[arg(long = "warning")]+        warnings: Vec<String>,+        /// TTS narration script (the spoken text that was muxed into the+        /// clip's audio track). Surfaced in the storyboard UI as a+        /// collapsible disclosure, distinct from `--description` (which+        /// captures the demo author's INTENT for the clip).+        #[arg(long)]+        narration: Option<String>,+    },+    /// Print a human-readable summary of the manifest.+    Inspect { file: PathBuf },+    /// Remove a clip by id (for re-shoots).+    Remove {+        file: PathBuf,+        #[arg(long)]+        id: String,+    },+}++#[derive(Args)]+struct AppendStillArgs {+    /// Source video.+    #[arg(long)]+    video: PathBuf,+    /// Image to append (PNG, JPG, etc.).+    #[arg(long)]+    image: PathBuf,+    /// How long to hold the still, in seconds.+    #[arg(long, default_value_t = 3.0)]+    duration: f64,+    /// Output webm path.+    #[arg(long)]+    output: PathBuf,+}++#[derive(Args)]+struct ConcatArgs {+    /// Manifest to read clips from.+    #[arg(long)]+    manifest: PathBuf,+    /// Output webm.+    #[arg(long)]+    output: PathBuf,+    /// `raw` or `fast` — which path on each clip to concat.+    #[arg(long, default_value_t = String::from("fast"))]+    kind: String,+}++#[derive(Args)]+struct VoiceoverArgs {+    /// Input video (typically the -fast.webm from `process`)+    #[arg(long)]+    input: PathBuf,+    /// Output path. Default: <input-stem>-narrated.<ext>+    #[arg(long)]+    output: Option<PathBuf>,+    /// HTTP server port for the voiceover UI+    #[arg(long, default_value_t = 8796)]+    port: u16,+    /// Hydrogen webview tab name+    #[arg(long, default_value_t = String::from("Adom Video Post-Processing"))]+    tab_name: String,+    /// Hydrogen panel ID to host the tab. Defaults to the focused panel.+    /// Use this to host the tab on a specific pane instead of whatever is+    /// currently focused.+    #[arg(long)]+    panel_id: Option<String>,+    /// Optional markers file from an earlier `adom-video-post process` run.+    /// When present, the app shows a Speedup Timeline card with the+    /// source video timeline, highlighted speedup regions, and summary+    /// stats (input vs output duration, % saved).+    #[arg(long)]+    markers: Option<PathBuf>,+    /// Run HTTP server only — skip opening a Hydrogen webview tab.+    /// Use when reviewing in pup or driving via browser_eval.+    #[arg(long)]+    no_open: bool,+}++#[derive(Args)]+struct EvalArgs {+    /// JavaScript snippet. Evaluated inside the UI as the body of an+    /// `async function (ctx) { ... }`, so `await` works and `return`+    /// at the top level is valid. `ctx` is the UI's `window.__appCtx`.+    code: String,+    /// Port of the running adom-video-post web UI. Defaults to 8796+    /// (voiceover). For storyboard, pass `--port 8797`.+    #[arg(long, default_value_t = 8796)]+    port: u16,+    /// Give up after N seconds waiting for the snippet's result.+    #[arg(long, default_value_t = 30)]+    timeout: u64,+}++#[derive(Args)]+struct CtrlArgs {+    /// Target instance: `latest`, a subcommand name (`voiceover`,+    /// `storyboard`), a port number (e.g. `8796`), a bare pid, or+    /// `pid:<pid>`.+    target: String,+    /// Action to perform (see `adom-video-post ctrl --help` or the+    /// subcommand-level --help for this variant).+    #[command(subcommand)]+    action: CtrlActionArgs,+}++#[derive(Subcommand)]+enum CtrlActionArgs {+    /// GET /state — print the target's current state JSON.+    State,+    /// GET /console — print the in-app log buffer.+    Console {+        /// Only print the last N messages.+        #[arg(long)]+        tail: Option<usize>,+        /// Only print messages whose `seq` is strictly greater than this.+        #[arg(long)]+        since: Option<u64>,+        /// Long-poll the console endpoint and stream new messages as+        /// they arrive (blocks until Ctrl-C).+        #[arg(long)]+        follow: bool,+    },+    /// POST /eval + poll result — run a JS snippet in the target UI.+    Eval {+        code: String,+        #[arg(long, default_value_t = 30)]+        timeout: u64,+    },+    /// POST /shutdown — exit the target instance cleanly.+    Shutdown,+    /// Raw HTTP call against an app-specific endpoint (GET/POST/+    /// PUT/DELETE). Use for handlers not covered by the built-in+    /// actions above (e.g. voiceover's /start-recording).+    Call {+        method: String,+        path: String,+        /// Optional JSON request body.+        #[arg(long)]+        body: Option<String>,+    },+}++#[derive(Args)]+struct PublishArgs {+    /// Final video to upload+    #[arg(long)]+    input: PathBuf,+    /// Wiki page ref like apps/adom-desktop, skills/symbol-creator, molecules/drv8411a-breakout+    #[arg(long)]+    page: String,+    /// Caption shown next to the video on the wiki page+    #[arg(long)]+    caption: Option<String>,+    /// Asset type. Default: video. Other valid: hero_image, screenshot+    #[arg(long, default_value_t = String::from("video"))]+    asset_type: String,+}++fn main() -> ExitCode {+    let cli = Cli::parse();+    let result = match cli.cmd {+        Cmd::Health => cmd_health(),+        Cmd::Mark(MarkCmd::Init { file }) => cmd_mark_init(&file),+        Cmd::Mark(MarkCmd::Start { speed, label, file }) => cmd_mark_start(&file, speed, &label),+        Cmd::Mark(MarkCmd::End { file }) => cmd_mark_end(&file),+        Cmd::Wrap(args) => return cmd_wrap(args),+        Cmd::Inspect(args) => cmd_inspect(&args.file),+        Cmd::Process(args) => cmd_process(args),+        Cmd::App(args) => cmd_app(args),+        Cmd::Voiceover(args) => cmd_voiceover(args),+        Cmd::Storyboard(args) => cmd_storyboard(args),+        Cmd::Manifest(cmd) => cmd_manifest(cmd),+        Cmd::AppendStill(args) => cmd_append_still(args),+        Cmd::Concat(args) => cmd_concat(args),+        Cmd::Publish(args) => cmd_publish(args),+        Cmd::Eval(args) => cmd_eval(args),+        Cmd::List => ctrl::cmd_list(),+        Cmd::Ctrl(args) => {+            let action = match args.action {+                CtrlActionArgs::State => ctrl::CtrlAction::State,+                CtrlActionArgs::Console { tail, since, follow } => {+                    ctrl::CtrlAction::Console { tail, since, follow }+                }+                CtrlActionArgs::Eval { code, timeout } => {+                    ctrl::CtrlAction::Eval { code, timeout }+                }+                CtrlActionArgs::Shutdown => ctrl::CtrlAction::Shutdown,+                CtrlActionArgs::Call { method, path, body } => {+                    ctrl::CtrlAction::Call { method, path, body }+                }+            };+            ctrl::cmd_ctrl(&args.target, &action)+        }+        Cmd::Install => cmd_install(),+    };+    match result {+        Ok(()) => ExitCode::SUCCESS,+        Err(msg) => {+            err(&msg);+            // If the message contains a "Hint:" segment, print it as a hint line.+            if let Some((_, h)) = msg.split_once("Hint: ") {+                hint(h);+            }+            ExitCode::from(1)+        }+    }+}++fn cmd_health() -> Result<(), String> {+    let (ffmpeg_v, ffprobe_v) = ffmpeg::check_versions()?;+    ok(format!("ffmpeg {} + ffprobe {} ready.", ffmpeg_v, ffprobe_v));+    Ok(())+}++fn cmd_mark_init(file: &str) -> Result<(), String> {+    let epoch = markers::init(file)?;+    ok(format!("markers initialized at {CYAN}{file}{RESET} (recording_start epoch={epoch:.3})"));+    Ok(())+}++fn cmd_mark_start(file: &str, speed: u32, label: &str) -> Result<(), String> {+    if !(2..=100).contains(&speed) {+        return Err(format!("speed must be in [2, 100], got {}", speed));+    }+    let event = markers::Event::SpeedupStart {+        epoch: markers::now_epoch(),+        speed,+        label: label.to_string(),+    };+    markers::append(file, &event)?;+    ok(format!("dropped speedup_start (speed={CYAN}{speed}x{RESET}, label={CYAN}{label}{RESET})"));+    Ok(())+}++fn cmd_mark_end(file: &str) -> Result<(), String> {+    let event = markers::Event::SpeedupEnd {+        epoch: markers::now_epoch(),+    };+    markers::append(file, &event)?;+    ok("dropped speedup_end");+    Ok(())+}++fn cmd_wrap(args: WrapArgs) -> ExitCode {+    if !(2..=100).contains(&args.speed) {+        err(format!("speed must be in [2, 100], got {}", args.speed));+        return ExitCode::from(1);+    }+    if args.command.is_empty() {+        err("wrap requires a command after `--`");+        hint("Example: adom-video-post wrap --speed 20 --label 'Exporting' -- adom-desktop fusion_export_gerbers");+        return ExitCode::from(1);+    }+    // Show a live Hydrogen caption so the USER watching the recording+    // knows which sections will be sped up later. Without this the+    // recording operator has no live feedback that a speedup marker+    // is in effect, so they can't tell which slow parts got caught.+    // Best-effort: if adom-cli isn't available or the command fails,+    // we silently proceed (CLI keeps working outside Hydrogen too).+    let live_caption = format!("{}x SPEEDUP: {}", args.speed, args.label);+    let _ = std::process::Command::new("adom-cli")+        .args([+            "hydrogen", "caption", "show", &live_caption,+            "-d", "0",       // indefinite, we'll hide explicitly on completion+            "-s", "medium",  // safe size per the ralph-test rules+            "-p", "top",+        ])+        .output();++    // Drop start marker+    let start_event = markers::Event::SpeedupStart {+        epoch: markers::now_epoch(),+        speed: args.speed,+        label: args.label.clone(),+    };+    if let Err(e) = markers::append(&args.file, &start_event) {+        err(format!("failed to write start marker: {}", e));+        let _ = std::process::Command::new("adom-cli")+            .args(["hydrogen", "caption", "hide"])+            .output();+        return ExitCode::from(1);+    }+    // Run the command+    let mut cmd = std::process::Command::new(&args.command[0]);+    if args.command.len() > 1 {+        cmd.args(&args.command[1..]);+    }+    let status = cmd.status();+    // Drop end marker (always, even if the command failed)+    let end_event = markers::Event::SpeedupEnd {+        epoch: markers::now_epoch(),+    };+    if let Err(e) = markers::append(&args.file, &end_event) {+        warn(format!("failed to write end marker: {} (the command did finish)", e));+    }+    // Hide the live caption now that the wrapped command is done.+    let _ = std::process::Command::new("adom-cli")+        .args(["hydrogen", "caption", "hide"])+        .output();+    match status {+        Ok(s) => {+            if s.success() {+                ExitCode::SUCCESS+            } else {+                ExitCode::from(s.code().unwrap_or(1) as u8)+            }+        }+        Err(e) => {+            err(format!("failed to spawn '{}': {}", args.command[0], e));+            ExitCode::from(1)+        }+    }+}++fn cmd_inspect(file: &str) -> Result<(), String> {+    let events = markers::read_all(file)?;+    let parsed = markers::parse(&events)?;++    let now = markers::now_epoch();+    let elapsed = now - parsed.recording_start;++    let total_source: f64 = parsed.segments.iter().map(|s| s.duration()).sum();+    let total_sped: f64 = parsed.segments.iter().map(|s| s.sped_duration()).sum();+    let savings_pct = if total_source > 0.0 {+        100.0 * (1.0 - total_sped / total_source)+    } else {+        0.0+    };++    ok(format!(+        "Recording started {CYAN}{:.1}s{RESET} ago, {CYAN}{}{RESET} speedup segments, ~{CYAN}{:.1}s{RESET} of source video → ~{CYAN}{:.1}s{RESET} after speedup ({:.1}% reduction)",+        elapsed,+        parsed.segments.len(),+        total_source,+        total_sped,+        savings_pct+    ));+    for seg in &parsed.segments {+        note!(+            "  - {DIM}{:6.1}s{RESET} @ {CYAN}{:>3}x{RESET}: \"{}\"  →  {DIM}{:.1}s{RESET}",+            seg.duration(),+            seg.speed,+            seg.label,+            seg.sped_duration()+        );+    }+    if !parsed.warnings.is_empty() {+        for w in &parsed.warnings {+            warn(w);+        }+    }+    if !parsed.unclosed.is_empty() {+        warn(format!("{} unclosed speedup_start markers (will be ended at end-of-video on process)", parsed.unclosed.len()));+    }+    Ok(())+}++fn cmd_process(args: ProcessArgs) -> Result<(), String> {+    if !args.input.exists() {+        return Err(format!("input file not found: {}. Hint: check the path with ls", args.input.display()));+    }+    let events = markers::read_all(&args.markers)?;+    let mut parsed = markers::parse(&events)?;++    if !parsed.warnings.is_empty() {+        for w in &parsed.warnings {+            warn(w);+        }+    }++    // Cap speeds to max_speed+    for seg in &mut parsed.segments {+        if seg.speed > args.max_speed {+            warn(format!("capping segment '{}' speed from {}x to {}x", seg.label, seg.speed, args.max_speed));+            seg.speed = args.max_speed;+        }+    }++    let duration = ffmpeg::probe_duration(&args.input)?;+    let (width, height) = ffmpeg::probe_dimensions(&args.input)?;+    let font_size = ffmpeg::compute_font_size(width);+    note!(+        "{DIM}input: {}x{} {:.1}s, caption font {}px{RESET}",+        width, height, duration, font_size+    );++    // Inject implicit ends for unclosed segments at end-of-video+    for (start_offset, speed, label) in &parsed.unclosed {+        warn(format!("unclosed speedup_start '{}' will end at end-of-video ({:.1}s)", label, duration));+        parsed.segments.push(markers::Segment {+            start_offset: *start_offset,+            end_offset: duration,+            speed: *speed,+            label: label.clone(),+        });+    }+    parsed.segments.sort_by(|a, b| a.start_offset.partial_cmp(&b.start_offset).unwrap());++    // Resolve --trim-start into an absolute offset (in seconds) that will+    // be dropped from the start of the output.+    let trim_offset: f64 = match args.trim_start.as_deref() {+        None => 0.0,+        Some("auto") => {+            match parsed.segments.first() {+                Some(seg) => (seg.start_offset - 2.0).max(0.0),+                None => 0.0,+            }+        }+        Some(s) => s.parse::<f64>().map_err(|e| {+            format!("--trim-start must be 'auto' or a number of seconds, got '{}': {}", s, e)+        })?,+    };+    if trim_offset > 0.0 {+        note!(+            "{DIM}--trim-start {}: cutting {:.1}s off the front of the video{RESET}",+            args.trim_start.as_deref().unwrap_or(""), trim_offset+        );+        parsed.segments.retain(|s| s.end_offset > trim_offset + 0.001);+        for seg in &mut parsed.segments {+            if seg.start_offset < trim_offset {+                seg.start_offset = trim_offset;+            }+        }+    }++    // Resolve --trim-end into an absolute end offset. Anything after+    // this offset is clipped off. The intent is to cut the AI's post-demo+    // cleanup time that happens between the last meaningful caption and+    // the user hitting Stop.+    let trim_end: f64 = match args.trim_end.as_deref() {+        None => duration,+        Some("auto") => {+            // Use the last speedup_end + 4s buffer. If no segments, leave alone.+            match parsed.segments.last() {+                Some(seg) => (seg.end_offset + 4.0).min(duration),+                None => duration,+            }+        }+        Some(s) => s.parse::<f64>().map_err(|e| {+            format!("--trim-end must be 'auto' or a number of seconds, got '{}': {}", s, e)+        }).map(|n: f64| n.min(duration))?,+    };+    let effective_duration = trim_end;+    if trim_end < duration - 0.001 {+        note!(+            "{DIM}--trim-end {}: cutting {:.1}s off the end of the video (ends at {:.1}s){RESET}",+            args.trim_end.as_deref().unwrap_or(""), duration - trim_end, trim_end+        );+        parsed.segments.retain(|s| s.start_offset < trim_end - 0.001);+        for seg in &mut parsed.segments {+            if seg.end_offset > trim_end {+                seg.end_offset = trim_end;+            }+        }+    }++    if parsed.segments.is_empty() {+        note!("{YELLOW}no speedup segments, copying input to output unchanged{RESET}");+    } else {+        note!("{DIM}{} speedup segments to process{RESET}", parsed.segments.len());+        for seg in &parsed.segments {+            println!(+                "  - {:6.1}s @ {:>3}x: \"{}\"  ->  {:.1}s",+                seg.duration(),+                seg.speed,+                seg.label,+                seg.sped_duration()+            );+        }+    }++    // Compute output path+    let output = args.output.unwrap_or_else(|| {+        let stem = args.input.file_stem().unwrap_or_default().to_string_lossy().to_string();+        let ext = args.input.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| "webm".to_string());+        let parent = args.input.parent().unwrap_or_else(|| std::path::Path::new("."));+        parent.join(format!("{}-fast.{}", stem, ext))+    });++    if args.base_speed < 0.5 || args.base_speed > 10.0 {+        return Err(format!("--base-speed must be in [0.5, 10], got {}", args.base_speed));+    }+    if (args.base_speed - 1.0).abs() > 0.001 {+        note!(+            "{DIM}--base-speed {:.2}: applying global {:.2}x speedup to all unmarked sections{RESET}",+            args.base_speed, args.base_speed+        );+    }+    let filter = ffmpeg::build_filter_complex_full(&parsed.segments, effective_duration, font_size, trim_offset, args.base_speed, args.no_overlay);+    note!("{DIM}filter graph: {} chars, {} segments{RESET}", filter.len(), parsed.segments.len() + 1);++    if args.max_width > 0 && width > args.max_width {+        note!("{DIM}downscaling from {}px to {}px wide (~{:.0}x faster encode){RESET}",+            width, args.max_width, (width as f64 / args.max_width as f64).powi(2));+    }+    note!("{CYAN}running ffmpeg...{RESET}");+    ffmpeg::run_process_full(&args.input, &output, &filter, Some(effective_duration), args.max_width)?;++    let out_dur = ffmpeg::probe_duration(&output)?;+    let total_savings = duration - out_dur;+    let pct = if duration > 0.0 { 100.0 * total_savings / duration } else { 0.0 };++    ok(format!(+        "{CYAN}{}{RESET}\n  input:  {:.1}s  →  output: {:.1}s  ({:.1}% shorter, {:.1}s saved)",+        output.display(),+        duration,+        out_dur,+        pct,+        total_savings+    ));+    Ok(())+}++fn cmd_voiceover(args: VoiceoverArgs) -> Result<(), String> {+    if !args.input.exists() {+        return Err(format!(+            "input video not found: {}. Hint: produce it with `adom-video-post process` first.",+            args.input.display()+        ));+    }+    let output = args.output.unwrap_or_else(|| {+        let stem = args+            .input+            .file_stem()+            .unwrap_or_default()+            .to_string_lossy()+            .to_string();+        let ext = args+            .input+            .extension()+            .map(|e| e.to_string_lossy().to_string())+            .unwrap_or_else(|| "webm".to_string());+        let parent = args.input.parent().unwrap_or_else(|| std::path::Path::new("."));+        parent.join(format!("{}-narrated.{}", stem, ext))+    });++    let cfg = voiceover::VoiceoverConfig {+        input: args.input,+        output: output.clone(),+        port: args.port,+        tab_name: args.tab_name,+        panel_id_override: args.panel_id,+        markers_file: args.markers,+        no_open: args.no_open,+    };++    note!(+        "{CYAN}starting voiceover server on port {}{RESET}",+        cfg.port+    );+    let result = voiceover::run(cfg)?;+    ok(format!(+        "narrated video saved to {CYAN}{}{RESET}",+        result.display()+    ));+    Ok(())+}++fn cmd_publish(args: PublishArgs) -> Result<(), String> {+    let cfg = publish::PublishConfig {+        input: args.input,+        page: args.page,+        caption: args.caption,+        asset_type: args.asset_type,+    };+    let url = publish::run(cfg)?;+    ok(format!("uploaded to wiki: {CYAN}{}{RESET}", url));+    Ok(())+}++fn cmd_install() -> Result<(), String> {+    let path = install::install()?;+    ok(format!("SKILL.md deployed to {CYAN}{}{RESET}", path.display()));+    Ok(())+}++fn cmd_app(args: AppArgs) -> Result<(), String> {+    let cfg = app::AppConfig {+        manifest_path: args.manifest,+        port: args.port,+        tab_name: args.tab_name,+        panel_id_override: args.panel_id,+        watch: !args.no_watch,+        no_open: args.no_open,+    };+    note!(+        "{CYAN}starting adom-video-post app on port {}{RESET}",+        cfg.port+    );+    app::run(cfg)?;+    ok("app session ended");+    Ok(())+}++fn cmd_storyboard(args: StoryboardArgs) -> Result<(), String> {+    let cfg = storyboard::StoryboardConfig {+        manifest_path: args.manifest,+        port: args.port,+        tab_name: args.tab_name,+        panel_id_override: args.panel_id,+        watch: !args.no_watch,+        no_open: args.no_open,+    };+    note!(+        "{CYAN}starting storyboard server on port {}{RESET}",+        cfg.port+    );+    storyboard::run(cfg)?;+    ok("storyboard session ended");+    Ok(())+}++fn cmd_manifest(cmd: ManifestCmd) -> Result<(), String> {+    use storyboard::manifest_cli;+    match cmd {+        ManifestCmd::Init { file, title } => {+            manifest_cli::init(&file, &title)?;+            ok(format!("manifest initialized at {CYAN}{}{RESET} (title: {})", file.display(), title));+        }+        ManifestCmd::Add {+            file,+            id,+            title,+            description,+            raw,+            fast,+            image,+            console_png,+            speedups,+            actions,+            warnings,+            narration,+        } => {+            manifest_cli::add(+                &file,+                &id,+                &title,+                description,+                raw,+                fast,+                image,+                console_png,+                speedups,+                actions,+                warnings,+                narration,+            )?;+            ok(format!("added clip {CYAN}{}{RESET} to {}", id, file.display()));+        }+        ManifestCmd::Inspect { file } => {+            manifest_cli::inspect(&file)?;+        }+        ManifestCmd::Remove { file, id } => {+            manifest_cli::remove(&file, &id)?;+            ok(format!("removed clip {CYAN}{}{RESET} from {}", id, file.display()));+        }+    }+    Ok(())+}++fn cmd_append_still(args: AppendStillArgs) -> Result<(), String> {+    if !args.video.exists() {+        return Err(format!("video not found: {}", args.video.display()));+    }+    if !args.image.exists() {+        return Err(format!("image not found: {}", args.image.display()));+    }+    note!(+        "{DIM}appending {:.1}s still ({}) to {}{RESET}",+        args.duration,+        args.image.display(),+        args.video.display()+    );+    ffmpeg::append_still(&args.video, &args.image, args.duration, &args.output)?;+    ok(format!("wrote {CYAN}{}{RESET}", args.output.display()));+    Ok(())+}++fn cmd_concat(args: ConcatArgs) -> Result<(), String> {+    let mut manifest = storyboard::manifest::Manifest::load(&args.manifest)?;+    let inputs: Vec<PathBuf> = manifest+        .clips+        .iter()+        .filter_map(|c| match args.kind.as_str() {+            "raw" => c.raw_path.clone(),+            "fast" => c.fast_path.clone(),+            _ => None,+        })+        .filter(|p| p.exists())+        .collect();+    if inputs.is_empty() {+        return Err(format!(+            "no usable {} clips in manifest. Hint: every clip needs a {}_path field pointing at an existing file.",+            args.kind, args.kind+        ));+    }+    note!(+        "{DIM}concatenating {} clips → {}{RESET}",+        inputs.len(),+        args.output.display()+    );+    ffmpeg::concat_videos(&inputs, &args.output)?;+    // Write the result back into the manifest so the storyboard UI+    // picks it up via /final-video/<kind>.+    match args.kind.as_str() {+        "raw" => manifest.final_raw_path = Some(args.output.clone()),+        "fast" => manifest.final_fast_path = Some(args.output.clone()),+        _ => {}+    }+    manifest.save(&args.manifest)?;+    ok(format!(+        "wrote {CYAN}{}{RESET} ({} clips, --kind {})",+        args.output.display(),+        inputs.len(),+        args.kind+    ));+    Ok(())+}++/// Run `code` against a live adom-video-post web UI via the /eval hot-patch+/// channel. Shells out to curl so we don't need an HTTP-client dep —+/// both Docker and macOS always have curl. The flow is:+///+///   1. POST /eval with `{"code":"..."}` → parse `{"id":"..."}`+///   2. GET /eval/:id/result (long-polls up to 25s server-side)+///      until a result appears OR the overall --timeout expires.+///   3. Print the returned value (or error) as JSON to stdout and+///      exit 0 on `ok:true` / 1 on `ok:false`.+fn cmd_eval(args: EvalArgs) -> Result<(), String> {+    use std::process::Command;+    let base = format!("http://127.0.0.1:{}", args.port);+    let post_body = serde_json::json!({ "code": args.code }).to_string();++    // Queue the snippet.+    let post = Command::new("curl")+        .args([+            "-s",+            "-X",+            "POST",+            "-H",+            "Content-Type: application/json",+            "-d",+            &post_body,+            "--max-time",+            "5",+            &format!("{}/eval", base),+        ])+        .output()+        .map_err(|e| format!("curl spawn failed: {}. Hint: curl is required for `adom-video-post eval`.", e))?;+    if !post.status.success() {+        return Err(format!(+            "POST /eval failed: {}. Hint: is a adom-video-post web UI running on port {}? Run `adom-video-post voiceover --input ...` or `adom-video-post storyboard ...` first.",+            String::from_utf8_lossy(&post.stderr),+            args.port+        ));+    }+    let post_body = String::from_utf8_lossy(&post.stdout);+    let parsed: serde_json::Value = serde_json::from_str(&post_body)+        .map_err(|e| format!("parse /eval response: {} ({})", e, post_body))?;+    let id = parsed+        .get("id")+        .and_then(|v| v.as_str())+        .ok_or_else(|| format!("/eval response missing 'id' field: {}", post_body))?+        .to_string();++    // Poll for the result. Each curl call long-polls up to 25s on the+    // server side. The outer loop enforces `--timeout` at the client+    // side, retrying if the server returned 204.+    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(args.timeout);+    loop {+        let get = Command::new("curl")+            .args([+                "-s",+                "-w",+                "\n%{http_code}",+                "--max-time",+                "30",+                &format!("{}/eval/{}/result", base, id),+            ])+            .output()+            .map_err(|e| format!("curl spawn failed: {}", e))?;+        let raw = String::from_utf8_lossy(&get.stdout);+        // curl -w appends "\nHTTP_CODE" after the body+        let (body, status_line) = raw+            .rsplit_once('\n')+            .map(|(b, s)| (b.to_string(), s.to_string()))+            .unwrap_or_else(|| (raw.to_string(), "000".to_string()));+        let status: u16 = status_line.trim().parse().unwrap_or(0);++        if status == 200 {+            let result: serde_json::Value = serde_json::from_str(&body)+                .map_err(|e| format!("parse result JSON: {} ({})", e, body))?;+            println!("{}", serde_json::to_string_pretty(&result).unwrap_or(body));+            let ok_flag = result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);+            if ok_flag {+                return Ok(());+            } else {+                return Err(result+                    .get("error")+                    .and_then(|v| v.as_str())+                    .unwrap_or("eval returned ok:false")+                    .to_string());+            }+        }+        if status != 204 {+            return Err(format!(+                "unexpected status {} from /eval/{}/result: {}",+                status, id, body+            ));+        }+        if std::time::Instant::now() >= deadline {+            return Err(format!(+                "timed out after {}s waiting for eval result. Hint: is the UI page open in the Hydrogen webview? The snippet only runs when the UI's poll loop is alive.",+                args.timeout+            ));+        }+    }+}
src/markers.rsadded+197
@@ -0,0 +1,197 @@+//! Marker file reader/writer.+//!+//! The markers file is JSONL: one event per line. The post-processor reads it+//! to figure out where the speedup segments live in the source video.+//!+//! Event types:+//!   recording_start  - written by `mark init`. Must be the first event.+//!   speedup_start    - written by `mark start` or `wrap`. Has speed + label.+//!   speedup_end      - written by `mark end` or `wrap`. Closes the previous start.++use serde::{Deserialize, Serialize};+use std::fs::{File, OpenOptions};+use std::io::{BufRead, BufReader, Write};+use std::path::Path;+use std::time::{SystemTime, UNIX_EPOCH};++#[derive(Debug, Serialize, Deserialize, Clone)]+#[serde(tag = "action", rename_all = "snake_case")]+pub enum Event {+    RecordingStart {+        epoch: f64,+    },+    SpeedupStart {+        epoch: f64,+        speed: u32,+        label: String,+    },+    SpeedupEnd {+        epoch: f64,+    },+}++impl Event {+    pub fn epoch(&self) -> f64 {+        match self {+            Event::RecordingStart { epoch } => *epoch,+            Event::SpeedupStart { epoch, .. } => *epoch,+            Event::SpeedupEnd { epoch } => *epoch,+        }+    }+}++/// Current epoch with millisecond precision.+pub fn now_epoch() -> f64 {+    SystemTime::now()+        .duration_since(UNIX_EPOCH)+        .map(|d| d.as_secs_f64())+        .unwrap_or(0.0)+}++/// Default markers file path. Honors $VIDEO_SPEEDUP_MARKERS env var.+pub fn default_path() -> String {+    std::env::var("VIDEO_SPEEDUP_MARKERS")+        .unwrap_or_else(|_| "/tmp/adom-video-post-markers.jsonl".to_string())+}++/// Initialize the markers file: clear it and write a recording_start event.+pub fn init(path: &str) -> Result<f64, String> {+    let epoch = now_epoch();+    let mut f = File::create(path).map_err(|e| format!("create {}: {}", path, e))?;+    let event = Event::RecordingStart { epoch };+    writeln!(f, "{}", serde_json::to_string(&event).unwrap())+        .map_err(|e| format!("write {}: {}", path, e))?;+    Ok(epoch)+}++/// Append an event to an existing markers file.+pub fn append(path: &str, event: &Event) -> Result<(), String> {+    let mut f = OpenOptions::new()+        .create(true)+        .append(true)+        .open(path)+        .map_err(|e| format!("open {}: {}", path, e))?;+    writeln!(f, "{}", serde_json::to_string(event).unwrap())+        .map_err(|e| format!("write {}: {}", path, e))?;+    Ok(())+}++/// Read all events from a markers file.+pub fn read_all<P: AsRef<Path>>(path: P) -> Result<Vec<Event>, String> {+    let path = path.as_ref();+    let f = File::open(path).map_err(|e| format!("open {}: {}", path.display(), e))?;+    let reader = BufReader::new(f);+    let mut events = Vec::new();+    for (i, line) in reader.lines().enumerate() {+        let line = line.map_err(|e| format!("read line {}: {}", i + 1, e))?;+        let line = line.trim();+        if line.is_empty() {+            continue;+        }+        let event: Event = serde_json::from_str(line)+            .map_err(|e| format!("parse line {}: {} ({})", i + 1, e, line))?;+        events.push(event);+    }+    Ok(events)+}++/// A speedup segment paired up from start/end events. Offset is relative to+/// the recording_start epoch (so 0.0 = first frame of the video).+#[derive(Debug, Clone)]+pub struct Segment {+    pub start_offset: f64,+    pub end_offset: f64,+    pub speed: u32,+    pub label: String,+}++impl Segment {+    pub fn duration(&self) -> f64 {+        self.end_offset - self.start_offset+    }+    pub fn sped_duration(&self) -> f64 {+        self.duration() / self.speed as f64+    }+}++/// Parse the events into a list of speedup segments. Returns the recording+/// start epoch and the segments. Unclosed speedup_start markers are warned+/// about and treated as ending at the end of the video (caller decides where+/// the end is).+pub struct ParseResult {+    pub recording_start: f64,+    pub segments: Vec<Segment>,+    pub warnings: Vec<String>,+    pub unclosed: Vec<(f64, u32, String)>, // for unclosed starts that need an end injected+}++pub fn parse(events: &[Event]) -> Result<ParseResult, String> {+    let mut iter = events.iter();+    let recording_start = match iter.next() {+        Some(Event::RecordingStart { epoch }) => *epoch,+        Some(other) => {+            return Err(format!(+                "first event must be recording_start, got {:?}. Hint: call `adom-video-post mark init` before any other markers.",+                other+            ));+        }+        None => return Err("markers file is empty. Hint: call `adom-video-post mark init` first.".to_string()),+    };++    let mut segments = Vec::new();+    let mut warnings = Vec::new();+    let mut unclosed = Vec::new();+    let mut pending_start: Option<(f64, u32, String)> = None;++    for ev in iter {+        match ev {+            Event::RecordingStart { .. } => {+                warnings.push("duplicate recording_start event ignored".to_string());+            }+            Event::SpeedupStart { epoch, speed, label } => {+                if let Some((prev_epoch, prev_speed, prev_label)) = pending_start.take() {+                    warnings.push(format!(+                        "speedup_start at {:.3} found while previous speedup_start ({}, '{}') at {:.3} was unclosed; previous one will end at this start time",+                        epoch - recording_start, prev_speed, prev_label, prev_epoch - recording_start+                    ));+                    segments.push(Segment {+                        start_offset: prev_epoch - recording_start,+                        end_offset: epoch - recording_start,+                        speed: prev_speed,+                        label: prev_label,+                    });+                }+                pending_start = Some((*epoch, *speed, label.clone()));+            }+            Event::SpeedupEnd { epoch } => {+                if let Some((start_epoch, speed, label)) = pending_start.take() {+                    segments.push(Segment {+                        start_offset: start_epoch - recording_start,+                        end_offset: epoch - recording_start,+                        speed,+                        label,+                    });+                } else {+                    warnings.push(format!(+                        "speedup_end at {:.3} with no matching speedup_start (ignored)",+                        epoch - recording_start+                    ));+                }+            }+        }+    }++    if let Some((epoch, speed, label)) = pending_start {+        unclosed.push((epoch - recording_start, speed, label));+    }++    // Sort segments by start time (defensive — they should already be in order)+    segments.sort_by(|a, b| a.start_offset.partial_cmp(&b.start_offset).unwrap());++    Ok(ParseResult {+        recording_start,+        segments,+        warnings,+        unclosed,+    })+}
src/publish.rsadded+128
@@ -0,0 +1,128 @@+//! Phase 3: publish a final video to the Adom Wiki.+//!+//! Wraps `adom-wiki asset upload <page-ref> --asset-type video --file <path>`+//! so the polished demo video gets attached to the wiki page for whatever+//! you're demoing (an app, a skill, a molecule, a library, a datasheet).++use std::path::Path;+use std::process::Command;++pub struct PublishConfig {+    pub input: std::path::PathBuf,+    pub page: String,+    pub caption: Option<String>,+    pub asset_type: String,+}++pub fn run(cfg: PublishConfig) -> Result<String, String> {+    if !cfg.input.exists() {+        return Err(format!(+            "input video not found: {}. Hint: produce it via `adom-video-post voiceover` first.",+            cfg.input.display()+        ));+    }++    // Validate page-ref shape: <type>/<slug>+    let mut parts = cfg.page.splitn(2, '/');+    let page_type = parts.next().unwrap_or("");+    let page_slug = parts.next().unwrap_or("");+    if page_type.is_empty() || page_slug.is_empty() {+        return Err(format!(+            "invalid --page '{}': expected format <type>/<slug> like apps/adom-desktop, skills/symbol-creator, molecules/drv8411a-breakout. Hint: see https://wiki-ufypy5dpx93o.adom.cloud for valid pages.",+            cfg.page+        ));+    }++    // Validate file extension+    let ext = cfg+        .input+        .extension()+        .and_then(|e| e.to_str())+        .unwrap_or("")+        .to_lowercase();+    let valid_video_exts = ["webm", "mp4", "mov", "mkv"];+    let valid_image_exts = ["png", "jpg", "jpeg"];+    let is_video = valid_video_exts.contains(&ext.as_str());+    let is_image = valid_image_exts.contains(&ext.as_str());++    if cfg.asset_type == "video" && !is_video {+        return Err(format!(+            "input file '{}' is not a recognized video format ({:?}). Hint: pass --asset-type screenshot if uploading a still image.",+            cfg.input.display(),+            valid_video_exts+        ));+    }+    if cfg.asset_type == "screenshot" && !is_image {+        return Err(format!(+            "input file '{}' is not a recognized image format ({:?}).",+            cfg.input.display(),+            valid_image_exts+        ));+    }++    // Check adom-wiki is installed+    if which("adom-wiki").is_none() {+        return Err(+            "adom-wiki CLI not found in PATH. Hint: it ships with every Adom container. Run `which adom-wiki` to verify, or contact your container admin."+                .to_string(),+        );+    }++    // Build the upload command+    let mut cmd = Command::new("adom-wiki");+    cmd.args(["asset", "upload", &cfg.page]);+    cmd.args(["--asset-type", &cfg.asset_type]);+    cmd.args(["--file"]).arg(&cfg.input);+    if let Some(caption) = &cfg.caption {+        cmd.args(["--caption", caption]);+    }++    println!("uploading {} to wiki page {}...", cfg.input.display(), cfg.page);++    let result = cmd.output().map_err(|e| format!("adom-wiki spawn failed: {}", e))?;+    if !result.status.success() {+        let stderr = String::from_utf8_lossy(&result.stderr);+        return Err(format!("adom-wiki upload failed:\n{}", stderr));+    }++    let stdout = String::from_utf8_lossy(&result.stdout);+    // Try to extract the asset URL or id from the JSON response+    let url = extract_asset_url(&stdout, &cfg.page);+    Ok(url)+}++fn extract_asset_url(stdout: &str, page: &str) -> String {+    // adom-wiki returns JSON like {"asset":{"id":42,"asset_type":"video","filename":"...","caption":"...","url":"..."}}+    // Try to parse and extract the URL or fall back to a wiki page link.+    if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(stdout) {+        if let Some(url) = parsed+            .get("asset")+            .and_then(|a| a.get("url"))+            .and_then(|u| u.as_str())+        {+            return url.to_string();+        }+        if let Some(file) = parsed+            .get("asset")+            .and_then(|a| a.get("filename"))+            .and_then(|f| f.as_str())+        {+            return format!(+                "https://wiki-ufypy5dpx93o.adom.cloud/wiki/{} (asset: {})",+                page, file+            );+        }+    }+    format!("https://wiki-ufypy5dpx93o.adom.cloud/wiki/{}", page)+}++fn which(cmd: &str) -> Option<std::path::PathBuf> {+    let path = std::env::var_os("PATH")?;+    for dir in std::env::split_paths(&path) {+        let candidate = dir.join(cmd);+        if candidate.is_file() {+            return Some(candidate);+        }+    }+    None+}
src/storyboard/icon.svgadded+5
@@ -0,0 +1,5 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#e6edf3">+  <!-- Film strip: outer frame with three tall frames inside and+       sprocket holes top + bottom, drawn as a single path compound. -->+  <path d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4zm2 2v2h2V6H5zm4 0v12h6V6H9zm8 0v2h2V6h-2zM5 10v2h2v-2H5zm12 0v2h2v-2h-2zM5 14v2h2v-2H5zm12 0v2h2v-2h-2zM5 18v0h2v0H5zm12 0v0h2v0h-2zM11 8v8h2V8h-2z"/>+</svg>
src/storyboard/manifest.rsadded+194
@@ -0,0 +1,194 @@+//! Storyboard manifest schema.+//!+//! A manifest is the single source of truth for a multi-clip video+//! review session. It's written incrementally by the user's demo+//! script (via `adom-video-post manifest add`, called once per step), and+//! read by every post-processing subcommand (`storyboard`, `process`,+//! `voiceover`, `publish`, `concat`).+//!+//! The demo script stays 100% custom bash/python — adom-video-post does+//! not try to execute recording logic itself. Only the handoff+//! artifact is structured.++use serde::{Deserialize, Serialize};+use std::fs;+use std::path::{Path, PathBuf};++/// Top-level manifest. `clips` is ordered — `concat` stitches them+/// in this order, `storyboard` renders them top-to-bottom, etc.+#[derive(Serialize, Deserialize, Clone, Debug, Default)]+pub struct Manifest {+    /// Shown in the storyboard header and anywhere a human-readable+    /// label is needed. Defaults to "Untitled" when `init` is called+    /// without --title.+    pub title: String,++    /// Ordered list of clips. Append-only in normal usage; `remove`+    /// is available for re-shoots.+    #[serde(default)]+    pub clips: Vec<Clip>,++    /// Absolute path to the raw concat produced by `adom-video-post concat+    /// --kind raw`. Set by that subcommand when it runs.+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub final_raw_path: Option<PathBuf>,++    /// Absolute path to the fast concat produced by `adom-video-post concat+    /// --kind fast`.+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub final_fast_path: Option<PathBuf>,++    /// Optional publish target — where the final narrated video+    /// should land when the user eventually runs `adom-video-post publish`.+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub publish: Option<PublishTarget>,+}++/// One clip in the review. All paths are absolute. Any missing+/// file generates a UI warning on the affected card, never a hard+/// error, so partial / in-progress manifests work.+#[derive(Serialize, Deserialize, Clone, Debug, Default)]+pub struct Clip {+    /// Stable string id — used in URLs for the storyboard's per-clip+    /// `/video/:id/raw` etc. endpoints. Typically `step-01`, `step-02`.+    pub id: String,++    /// Human-readable title shown in the card header and timeline strip.+    pub title: String,++    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub description: Option<String>,++    /// Raw silent recording for this step (absolute path).+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub raw_path: Option<PathBuf>,++    /// Post-speedup version of `raw_path` (absolute path). Produced+    /// by `adom-video-post process`.+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub fast_path: Option<PathBuf>,++    /// Optional pre-generated poster JPG for the video card. When+    /// unset, the storyboard subcommand generates one lazily on load+    /// and writes the path back here.+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub poster_path: Option<PathBuf>,++    /// Optional screenshot (e.g. a Fusion grid capture) shown as a+    /// thumbnail alongside the clip. Click-to-zoom lightbox.+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub image_path: Option<PathBuf>,++    /// Optional console-log PNG — a screenshot of a cmdline output+    /// panel. Useful for data-heavy steps where the sped-up video+    /// blurs through the actual exported values.+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub console_png_path: Option<PathBuf>,++    /// Zero or more speedup events that were applied (or will be+    /// applied) inside this step's clip. `adom-video-post process` uses+    /// them to build its ffmpeg filter graph; the storyboard UI+    /// surfaces them as colored badges on the card.+    #[serde(default, skip_serializing_if = "Vec::is_empty")]+    pub speedup_events: Vec<SpeedupEvent>,++    /// Opaque action log bullets for the card's "what happened here"+    /// list. Free-form strings; the script writer decides what to+    /// put in them.+    #[serde(default, skip_serializing_if = "Vec::is_empty")]+    pub actions: Vec<String>,++    /// Per-card warnings surfaced in the UI (missing file, speedup+    /// out of range, etc.). Populated automatically by the+    /// storyboard loader when it notices problems, plus any strings+    /// the manifest writer injected via `--warning`.+    #[serde(default, skip_serializing_if = "Vec::is_empty")]+    pub warnings: Vec<String>,++    /// The TTS narration script for this clip — the exact text that+    /// was rendered via adom-tts / edge-tts and muxed into the audio+    /// track. Surfaced in the storyboard UI under a collapsible+    /// "▸ TTS narration" disclosure. Distinct from `description`,+    /// which is the demo-author's INTENT for the clip; narration is+    /// the spoken text the viewer will hear.+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub narration: Option<String>,+}++#[derive(Serialize, Deserialize, Clone, Debug)]+pub struct SpeedupEvent {+    pub speed: u32,+    pub label: String,+}++#[derive(Serialize, Deserialize, Clone, Debug)]+pub struct PublishTarget {+    /// Wiki page ref like `apps/adom-desktop`.+    pub page: String,+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub caption: Option<String>,+}++impl Manifest {+    pub fn load(path: &Path) -> Result<Self, String> {+        let body = fs::read_to_string(path)+            .map_err(|e| format!("read {}: {}", path.display(), e))?;+        serde_json::from_str(&body)+            .map_err(|e| format!("parse {}: {}", path.display(), e))+    }++    /// Atomic write via `<path>.tmp` + rename. Concurrent readers+    /// either see the old file or the new one, never a half-written+    /// one — critical for the storyboard's file-watcher thread.+    pub fn save(&self, path: &Path) -> Result<(), String> {+        let tmp = path.with_extension("json.tmp");+        let body = serde_json::to_string_pretty(self)+            .map_err(|e| format!("serialize manifest: {}", e))?;+        if let Some(parent) = path.parent() {+            if !parent.as_os_str().is_empty() {+                fs::create_dir_all(parent)+                    .map_err(|e| format!("mkdir {}: {}", parent.display(), e))?;+            }+        }+        fs::write(&tmp, body)+            .map_err(|e| format!("write {}: {}", tmp.display(), e))?;+        fs::rename(&tmp, path)+            .map_err(|e| format!("rename {} -> {}: {}", tmp.display(), path.display(), e))?;+        Ok(())+    }++    pub fn init(title: &str) -> Self {+        Self {+            title: title.to_string(),+            clips: Vec::new(),+            final_raw_path: None,+            final_fast_path: None,+            publish: None,+        }+    }+}++/// Parse a `speed:label` pair as supplied on the command line+/// (e.g. `--speedup "20:Exporting gerbers"`). Returns an error+/// with a hint if the format is wrong.+pub fn parse_speedup_arg(s: &str) -> Result<SpeedupEvent, String> {+    let (speed_str, label) = s+        .split_once(':')+        .ok_or_else(|| format!(+            "--speedup must be formatted as 'SPEED:LABEL', got {:?}. Hint: try '20:Exporting gerbers'",+            s+        ))?;+    let speed: u32 = speed_str+        .parse()+        .map_err(|_| format!("--speedup speed must be an integer, got {:?}", speed_str))?;+    if !(2..=100).contains(&speed) {+        return Err(format!(+            "--speedup speed must be in [2, 100], got {}. Hint: anything above 100x looks like a glitch",+            speed+        ));+    }+    Ok(SpeedupEvent {+        speed,+        label: label.to_string(),+    })+}
src/storyboard/manifest_cli.rsadded+158
@@ -0,0 +1,158 @@+//! `adom-video-post manifest {init,add,inspect,remove}` handlers.+//!+//! The demo script calls `add` once per completed step, which is+//! the whole contract between the demo-side bash and adom-video-post's+//! post-processing side. Everything else (`storyboard`, `process`,+//! `voiceover`, `publish`, `concat`) reads the resulting file.++use super::manifest::{parse_speedup_arg, Clip, Manifest, SpeedupEvent};+use std::path::{Path, PathBuf};++/// `manifest init <file> [--title "..."]`+pub fn init(file: &Path, title: &str) -> Result<(), String> {+    if file.exists() {+        return Err(format!(+            "manifest already exists at {}. Hint: delete it first, or use `manifest add` to append.",+            file.display()+        ));+    }+    let m = Manifest::init(title);+    m.save(file)?;+    Ok(())+}++/// `manifest add <file> --id step-03 --title "Open BQ25792" ...`+/// All clip fields are optional except `id` and `title`.+#[allow(clippy::too_many_arguments)]+pub fn add(+    file: &Path,+    id: &str,+    title: &str,+    description: Option<String>,+    raw_path: Option<PathBuf>,+    fast_path: Option<PathBuf>,+    image_path: Option<PathBuf>,+    console_png_path: Option<PathBuf>,+    speedups: Vec<String>,+    actions: Vec<String>,+    warnings: Vec<String>,+    narration: Option<String>,+) -> Result<(), String> {+    let mut manifest = if file.exists() {+        Manifest::load(file)?+    } else {+        // Auto-init so the user doesn't have to call `init` first.+        // This matches how demo scripts naturally work: first `add`+        // implicitly creates a fresh manifest.+        Manifest::init("Untitled")+    };++    // Upsert semantics: an existing clip with the same id is replaced+    // in-place, preserving its position in the ordered list. Demo+    // scripts often call `manifest add` twice per step (once early+    // with bare info, once at the end with description/image/speedup+    // populated); the second call updates the first. To force-fail+    // on duplicates, the caller should use `manifest remove` first.+    let existing_index = manifest.clips.iter().position(|c| c.id == id);++    let speedup_events: Result<Vec<SpeedupEvent>, String> =+        speedups.iter().map(|s| parse_speedup_arg(s)).collect();++    // Validate any paths the caller supplied exist — but only warn,+    // don't fail. Partial manifests are fine; the UI surfaces+    // missing files as warnings on the affected card.+    let mut auto_warnings: Vec<String> = Vec::new();+    for (name, p) in [+        ("raw", &raw_path),+        ("fast", &fast_path),+        ("image", &image_path),+        ("console-png", &console_png_path),+    ] {+        if let Some(path) = p {+            if !path.exists() {+                auto_warnings.push(format!("{} path not found on disk: {}", name, path.display()));+            }+        }+    }++    let clip = Clip {+        id: id.to_string(),+        title: title.to_string(),+        description,+        raw_path,+        fast_path,+        poster_path: None,+        image_path,+        console_png_path,+        speedup_events: speedup_events?,+        actions,+        warnings: warnings.into_iter().chain(auto_warnings).collect(),+        narration,+    };++    if let Some(idx) = existing_index {+        manifest.clips[idx] = clip;+    } else {+        manifest.clips.push(clip);+    }+    manifest.save(file)?;+    Ok(())+}++/// `manifest inspect <file>` — print a human-readable summary.+pub fn inspect(file: &Path) -> Result<(), String> {+    let manifest = Manifest::load(file)?;+    println!("title: {}", manifest.title);+    println!("clips: {}", manifest.clips.len());+    for (i, clip) in manifest.clips.iter().enumerate() {+        let n = i + 1;+        println!("  [{}] {} — {}", n, clip.id, clip.title);+        if let Some(p) = &clip.raw_path {+            let present = if p.exists() { "✓" } else { "✗ missing" };+            println!("        raw:  {} ({})", p.display(), present);+        }+        if let Some(p) = &clip.fast_path {+            let present = if p.exists() { "✓" } else { "✗ missing" };+            println!("        fast: {} ({})", p.display(), present);+        }+        if !clip.speedup_events.is_empty() {+            let labels: Vec<String> = clip+                .speedup_events+                .iter()+                .map(|e| format!("{}x:{}", e.speed, e.label))+                .collect();+            println!("        speedup: {}", labels.join(", "));+        }+        if !clip.warnings.is_empty() {+            for w in &clip.warnings {+                println!("        ⚠ {}", w);+            }+        }+    }+    if let Some(p) = &manifest.final_raw_path {+        println!("final raw:  {}", p.display());+    }+    if let Some(p) = &manifest.final_fast_path {+        println!("final fast: {}", p.display());+    }+    if let Some(pub_target) = &manifest.publish {+        println!("publish → {}", pub_target.page);+    }+    Ok(())+}++/// `manifest remove <file> --id step-03`+pub fn remove(file: &Path, id: &str) -> Result<(), String> {+    let mut manifest = Manifest::load(file)?;+    let before = manifest.clips.len();+    manifest.clips.retain(|c| c.id != id);+    if manifest.clips.len() == before {+        return Err(format!(+            "no clip with id {:?} in manifest {}",+            id,+            file.display()+        ));+    }+    manifest.save(file)?;+    Ok(())+}
src/storyboard/mod.rsadded+123
@@ -0,0 +1,123 @@+//! Storyboard subcommand — first phase of post-production review.+//!+//! Reads a manifest file describing a multi-clip video review,+//! opens a Hydrogen webview tab hosting the UI, and blocks until+//! /shutdown. With `--watch` (default), the manifest file is+//! poll-watched for mtime changes so the UI live-updates as the+//! user's demo script appends new clips during a run.+//!+//! The manifest schema and manifest-CRUD subcommands live in the+//! sibling modules; this file is the `run()` glue + the `impl Routes`+//! wiring.++pub mod manifest;+pub mod manifest_cli;+pub mod routes;++use crate::webapp::{run_webapp, WebappConfig};+use manifest::Manifest;++/// Pre-load placeholder icon — see the comment on+/// `voiceover::TAB_ICON_PLACEHOLDER`. Once the storyboard UI+/// finishes loading, Hydrogen overrides this with the favicon+/// served from `src/storyboard/icon.svg`.+const TAB_ICON_PLACEHOLDER: &str = "mdi:filmstrip-box-multiple";+use std::path::PathBuf;+use std::sync::atomic::AtomicU64;+use std::sync::{Arc, RwLock};+use std::time::{Duration, SystemTime};++pub struct StoryboardConfig {+    pub manifest_path: PathBuf,+    pub port: u16,+    pub tab_name: String,+    pub panel_id_override: Option<String>,+    /// Poll the manifest file for mtime changes and live-update the+    /// UI when it changes. Default true; disable with `--no-watch`.+    pub watch: bool,+    /// Skip the Hydrogen webview tab open; HTTP server only. Use+    /// when reviewing in pup or driving via browser_eval.+    pub no_open: bool,+}++pub fn run(cfg: StoryboardConfig) -> Result<(), String> {+    if !cfg.manifest_path.exists() {+        return Err(format!(+            "manifest not found: {}. Hint: create one with `adom-video-post manifest init`.",+            cfg.manifest_path.display()+        ));+    }++    let initial = Manifest::load(&cfg.manifest_path)?;+    let title = if initial.title.is_empty() {+        "Storyboard".to_string()+    } else {+        initial.title.clone()+    };++    // Shared state: the manifest itself under RwLock so the file+    // watcher thread can swap it out on reload, plus a version+    // counter the UI polls via /state to know when to re-fetch.+    let manifest = Arc::new(RwLock::new(initial));+    let version = Arc::new(AtomicU64::new(1));+    let manifest_mtime = Arc::new(std::sync::Mutex::new(+        std::fs::metadata(&cfg.manifest_path)+            .and_then(|m| m.modified())+            .unwrap_or(SystemTime::UNIX_EPOCH),+    ));++    // Spawn a file-watcher thread if --watch is on. This is a+    // 500ms-poll-mtime loop rather than inotify because (a) we+    // only need to react to `manifest add` which writes atomically+    // via rename, (b) inotify on a renamed target is fiddly, and+    // (c) 500ms is well within the "feels live" window.+    if cfg.watch {+        let manifest_for_thread = Arc::clone(&manifest);+        let version_for_thread = Arc::clone(&version);+        let mtime_for_thread = Arc::clone(&manifest_mtime);+        let path_for_thread = cfg.manifest_path.clone();+        std::thread::spawn(move || loop {+            std::thread::sleep(Duration::from_millis(500));+            let new_mtime = match std::fs::metadata(&path_for_thread)+                .and_then(|m| m.modified())+            {+                Ok(t) => t,+                Err(_) => continue,+            };+            let mut last = mtime_for_thread.lock().unwrap();+            if new_mtime == *last {+                continue;+            }+            // File changed — reload and bump the version.+            match Manifest::load(&path_for_thread) {+                Ok(fresh) => {+                    *manifest_for_thread.write().unwrap() = fresh;+                    version_for_thread+                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);+                    *last = new_mtime;+                }+                Err(e) => {+                    eprintln!("storyboard: manifest reload failed: {}", e);+                }+            }+        });+    }++    let routes = Arc::new(routes::StoryboardRoutes {+        manifest,+        version,+        manifest_path: cfg.manifest_path.clone(),+    });++    let webapp_cfg = WebappConfig {+        port: cfg.port,+        tab_name: cfg.tab_name,+        panel_id_override: cfg.panel_id_override,+        display_icon: TAB_ICON_PLACEHOLDER.to_string(),+        subcommand: "storyboard".to_string(),+        title,+        no_open: cfg.no_open,+    };++    run_webapp(webapp_cfg, routes)+}
src/storyboard/routes.rsadded+402
@@ -0,0 +1,402 @@+//! `impl Routes` for the storyboard subcommand.+//!+//! Application-specific endpoints:+//!   GET /state            — version counter + clip count + title (for UI polling)+//!   GET /manifest         — the full manifest JSON (UI fetches on load + on version bump)+//!   GET /video/:id/raw    — range-streamed raw clip+//!   GET /video/:id/fast   — range-streamed fast clip+//!   GET /poster/:id       — first-frame JPG (generated lazily if missing)+//!   GET /image/:id        — optional screenshot PNG+//!   GET /console-png/:id  — optional console-output PNG+//!   GET /final-video/raw  — concatenated final raw webm+//!   GET /final-video/fast — concatenated final fast webm++use super::manifest::{Clip, Manifest};+use crate::ffmpeg;+use crate::webapp::{video as webapp_video, RouteOutcome, Routes};+use std::path::PathBuf;+use std::sync::atomic::{AtomicU64, Ordering};+use std::sync::{Arc, RwLock};+use tiny_http::{Header, Method, Request, Response};++const UI_HTML: &str = include_str!("ui.html");+const FAVICON_SVG: &str = include_str!("icon.svg");++pub struct StoryboardRoutes {+    pub manifest: Arc<RwLock<Manifest>>,+    pub version: Arc<AtomicU64>,+    /// Path to the backing manifest file on disk. Needed because+    /// `POST /concat` mutates the manifest and persists the new+    /// `final_fast_path` / `final_raw_path` back to the same file+    /// the file-watcher is monitoring.+    pub manifest_path: std::path::PathBuf,+}++impl StoryboardRoutes {+    /// Look up a clip by id and return a clone (so the lock can be+    /// released immediately).+    fn find_clip(&self, id: &str) -> Option<Clip> {+        self.manifest+            .read()+            .unwrap()+            .clips+            .iter()+            .find(|c| c.id == id)+            .cloned()+    }+}++impl Routes for StoryboardRoutes {+    fn ui_html(&self) -> &str {+        UI_HTML+    }++    fn favicon_svg(&self) -> &str {+        FAVICON_SVG+    }++    fn handle(&self, request: Request, method: &Method, url: &str) -> RouteOutcome {+        // Exact matches first.+        match (method, url) {+            (Method::Get, "/state") => return RouteOutcome::Handled(self.handle_state(request)),+            (Method::Get, "/manifest") => {+                return RouteOutcome::Handled(self.handle_manifest(request));+            }+            (Method::Post, "/concat") => {+                return RouteOutcome::Handled(self.handle_concat(request));+            }+            _ => {}+        }++        // Dynamic (prefix-based) GET routes. Every one below takes+        // ownership of `request`, so we commit to the branch as soon+        // as its prefix matches.+        if let Method::Get = method {+            if let Some(rest) = url.strip_prefix("/video/") {+                if let Some((id, kind)) = rest.rsplit_once('/') {+                    if kind == "raw" || kind == "fast" {+                        return RouteOutcome::Handled(self.handle_video_for(request, id, kind));+                    }+                }+            }+            if let Some(kind) = url.strip_prefix("/final-video/") {+                if kind == "raw" || kind == "fast" {+                    return RouteOutcome::Handled(self.handle_final_video(request, kind));+                }+            }+            if let Some(id) = url.strip_prefix("/poster/") {+                return RouteOutcome::Handled(self.handle_poster_for(request, id));+            }+            if let Some(id) = url.strip_prefix("/image/") {+                return RouteOutcome::Handled(self.handle_image_for(request, id));+            }+            if let Some(id) = url.strip_prefix("/console-png/") {+                return RouteOutcome::Handled(self.handle_console_png_for(request, id));+            }+        }++        RouteOutcome::NotHandled(request)+    }+}++impl StoryboardRoutes {+    fn handle_state(&self, request: Request) -> Result<(), String> {+        let m = self.manifest.read().unwrap();+        let version = self.version.load(Ordering::Relaxed);+        let body = format!(+            r#"{{"version":{},"clipCount":{},"title":{}}}"#,+            version,+            m.clips.len(),+            serde_json::to_string(&m.title).unwrap()+        );+        request+            .respond(json_response(200, &body))+            .map_err(|e| e.to_string())+    }++    fn handle_manifest(&self, request: Request) -> Result<(), String> {+        let m = self.manifest.read().unwrap();+        // Serialize manifest, then enrich each clip with `mtime_ms` of its+        // raw_path / fast_path so the UI can highlight "NEW since you last+        // saw this page" — fresh re-shoots stand out without forcing the+        // human reviewer to re-watch every clip.+        let mut v = serde_json::to_value(&*m).map_err(|e| e.to_string())?;+        if let Some(clips) = v.get_mut("clips").and_then(|c| c.as_array_mut()) {+            for clip in clips {+                let raw_mtime = clip+                    .get("raw_path")+                    .and_then(|p| p.as_str())+                    .and_then(|p| std::fs::metadata(p).ok())+                    .and_then(|md| md.modified().ok())+                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())+                    .map(|d| d.as_millis() as u64);+                let fast_mtime = clip+                    .get("fast_path")+                    .and_then(|p| p.as_str())+                    .and_then(|p| std::fs::metadata(p).ok())+                    .and_then(|md| md.modified().ok())+                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())+                    .map(|d| d.as_millis() as u64);+                if let Some(t) = raw_mtime {+                    clip["raw_mtime_ms"] = serde_json::json!(t);+                }+                if let Some(t) = fast_mtime {+                    clip["fast_mtime_ms"] = serde_json::json!(t);+                }+            }+        }+        let body = serde_json::to_string(&v).map_err(|e| e.to_string())?;+        request+            .respond(json_response(200, &body))+            .map_err(|e| e.to_string())+    }++    /// `POST /concat` — stitch every clip's `fast_path` into a single+    /// `final_fast_path`, write the result back to the manifest file,+    /// and bump the version so the UI re-fetches. Output lands next+    /// to the manifest file, named after the manifest title slug.+    /// Returns `{ok, path, clipCount}` on success, `{ok:false, error}`+    /// on failure.+    ///+    /// This is the endpoint the "Prepare for narration" button in+    /// the unified `adom-video-post app` UI hits to enable the Narrate+    /// phase without the user having to drop back to the CLI.+    fn handle_concat(&self, request: Request) -> Result<(), String> {+        let inputs: Vec<std::path::PathBuf> = {+            let m = self.manifest.read().unwrap();+            m.clips+                .iter()+                .filter_map(|c| c.fast_path.clone())+                .filter(|p| p.exists())+                .collect()+        };+        if inputs.is_empty() {+            let body = r#"{"ok":false,"error":"no fast clips in manifest — every clip needs a fast_path pointing at an existing file"}"#;+            return request+                .respond(json_response(400, body))+                .map_err(|e| e.to_string());+        }++        // Derive an output path next to the manifest file so it+        // lives in a predictable location without the caller having+        // to pick one.+        let parent = self+            .manifest_path+            .parent()+            .unwrap_or_else(|| std::path::Path::new("."));+        let title_slug: String = {+            let m = self.manifest.read().unwrap();+            m.title+                .chars()+                .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })+                .collect::<String>()+                .trim_matches('-')+                .to_string()+        };+        let slug = if title_slug.is_empty() {+            "adom-video-post".to_string()+        } else {+            title_slug+        };+        let output = parent.join(format!("{}-final-fast.webm", slug));++        println!(+            "concat: {} clips -> {}",+            inputs.len(),+            output.display()+        );+        if let Err(e) = crate::ffmpeg::concat_videos(&inputs, &output) {+            let body = format!(+                r#"{{"ok":false,"error":"ffmpeg concat failed: {}"}}"#,+                e.replace('"', "\\\"")+            );+            return request+                .respond(json_response(500, &body))+                .map_err(|e| e.to_string());+        }++        // Persist to the manifest and bump the version.+        {+            let mut m = self.manifest.write().unwrap();+            m.final_fast_path = Some(output.clone());+            if let Err(e) = m.save(&self.manifest_path) {+                let body = format!(+                    r#"{{"ok":false,"error":"manifest save failed: {}"}}"#,+                    e.replace('"', "\\\"")+                );+                return request+                    .respond(json_response(500, &body))+                    .map_err(|e| e.to_string());+            }+        }+        self.version+            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);++        let body = format!(+            r#"{{"ok":true,"path":"{}","clipCount":{}}}"#,+            output.display().to_string().replace('"', "\\\""),+            inputs.len()+        );+        request+            .respond(json_response(200, &body))+            .map_err(|e| e.to_string())+    }++    fn handle_video_for(&self, request: Request, id: &str, kind: &str) -> Result<(), String> {+        let clip = match self.find_clip(id) {+            Some(c) => c,+            None => {+                let _ = request.respond(text_response(404, &format!("no clip with id {:?}", id)));+                return Ok(());+            }+        };+        let path = match kind {+            "raw" => clip.raw_path,+            "fast" => clip.fast_path,+            _ => None,+        };+        let Some(path) = path else {+            let _ = request.respond(text_response(+                404,+                &format!("clip {:?} has no {} path", id, kind),+            ));+            return Ok(());+        };+        if !path.exists() {+            let _ = request.respond(text_response(+                404,+                &format!("clip {:?} {} file missing on disk: {}", id, kind, path.display()),+            ));+            return Ok(());+        }+        webapp_video::serve_file_range(request, &path, "video/webm")+    }++    fn handle_poster_for(&self, request: Request, id: &str) -> Result<(), String> {+        // Look up the clip and figure out which source we can use+        // to generate a poster from. Prefer fast_path (already+        // processed, smaller, faster to seek), fall back to raw.+        let clip = match self.find_clip(id) {+            Some(c) => c,+            None => {+                let _ = request.respond(text_response(404, &format!("no clip with id {:?}", id)));+                return Ok(());+            }+        };+        if let Some(p) = &clip.poster_path {+            if p.exists() {+                return webapp_video::serve_file_range(request, p, "image/jpeg");+            }+        }+        let source = clip+            .fast_path+            .as_ref()+            .filter(|p| p.exists())+            .or_else(|| clip.raw_path.as_ref().filter(|p| p.exists()));+        let Some(source) = source else {+            let _ = request.respond(text_response(+                404,+                &format!("clip {:?} has no video path to generate poster from", id),+            ));+            return Ok(());+        };+        // Generate lazily next to the source video.+        let dest: PathBuf = source.with_extension("poster.jpg");+        if !dest.exists() {+            if let Err(e) = ffmpeg::generate_poster(source, &dest) {+                let _ = request.respond(text_response(500, &format!("poster generation failed: {}", e)));+                return Ok(());+            }+        }+        // Write the generated path back into the manifest so a reload+        // picks it up. Best-effort — a write failure is not fatal.+        {+            let mut m = self.manifest.write().unwrap();+            if let Some(c) = m.clips.iter_mut().find(|c| c.id == id) {+                c.poster_path = Some(dest.clone());+            }+        }+        webapp_video::serve_file_range(request, &dest, "image/jpeg")+    }++    fn handle_image_for(&self, request: Request, id: &str) -> Result<(), String> {+        let clip = match self.find_clip(id) {+            Some(c) => c,+            None => {+                let _ = request.respond(text_response(404, &format!("no clip with id {:?}", id)));+                return Ok(());+            }+        };+        let Some(path) = clip.image_path else {+            let _ = request.respond(text_response(404, &format!("clip {:?} has no image_path", id)));+            return Ok(());+        };+        if !path.exists() {+            let _ = request.respond(text_response(+                404,+                &format!("clip {:?} image missing: {}", id, path.display()),+            ));+            return Ok(());+        }+        webapp_video::serve_file_range(request, &path, "image/png")+    }++    fn handle_console_png_for(&self, request: Request, id: &str) -> Result<(), String> {+        let clip = match self.find_clip(id) {+            Some(c) => c,+            None => {+                let _ = request.respond(text_response(404, &format!("no clip with id {:?}", id)));+                return Ok(());+            }+        };+        let Some(path) = clip.console_png_path else {+            let _ = request.respond(text_response(404, &format!("clip {:?} has no console_png_path", id)));+            return Ok(());+        };+        if !path.exists() {+            let _ = request.respond(text_response(+                404,+                &format!("clip {:?} console-png missing: {}", id, path.display()),+            ));+            return Ok(());+        }+        webapp_video::serve_file_range(request, &path, "image/png")+    }++    fn handle_final_video(&self, request: Request, kind: &str) -> Result<(), String> {+        let m = self.manifest.read().unwrap();+        let path = match kind {+            "raw" => m.final_raw_path.clone(),+            "fast" => m.final_fast_path.clone(),+            _ => None,+        };+        drop(m);+        let Some(path) = path else {+            let _ = request.respond(text_response(+                404,+                &format!("no final_{}_path in manifest", kind),+            ));+            return Ok(());+        };+        if !path.exists() {+            let _ = request.respond(text_response(+                404,+                &format!("final {} file missing: {}", kind, path.display()),+            ));+            return Ok(());+        }+        webapp_video::serve_file_range(request, &path, "video/webm")+    }+}++fn json_response(status: u16, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(body)+        .with_status_code(status)+        .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap())+}++fn text_response(status: u16, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(body)+        .with_status_code(status)+        .with_header(Header::from_bytes(&b"Content-Type"[..], &b"text/plain"[..]).unwrap())+}
src/storyboard/ui.htmladded+1104
@@ -0,0 +1,1104 @@+<!DOCTYPE html>+<html lang="en">+<head>+<meta charset="UTF-8">+<title>Storyboard</title>+<link rel="icon" type="image/svg+xml" href="favicon.svg">+<style>+@font-face {+  font-family: 'Familjen Grotesk';+  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2');+  font-weight: 700; font-display: swap;+}+@font-face {+  font-family: 'Familjen Grotesk';+  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2');+  font-weight: 600; font-display: swap;+}+@font-face {+  font-family: 'Familjen Grotesk';+  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');+  font-weight: 500; font-display: swap;+}+@font-face {+  font-family: 'Satoshi';+  src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');+  font-weight: 400; font-display: swap;+}+@font-face {+  font-family: 'Satoshi';+  src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');+  font-weight: 500; font-display: swap;+}+@font-face {+  font-family: 'JetBrains Mono';+  src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-400-normal.woff2') format('woff2');+  font-weight: 400; font-display: swap;+}+@font-face {+  font-family: 'JetBrains Mono';+  src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-500-normal.woff2') format('woff2');+  font-weight: 500; font-display: swap;+}+@font-face {+  font-family: 'JetBrains Mono';+  src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-600-normal.woff2') format('woff2');+  font-weight: 600; font-display: swap;+}+  :root {+    --bg: #0d1117;+    --surface: #161b22;+    --elevated: #1c2128;+    --border: #30363d;+    --border-muted: #21262d;+    --text: #e6edf3;+    --text2: #7d8590;+    --text3: #484f58;+    --accent: #00b8b0;+    --accent-bright: #00e6dc;+    --accent-glow: rgba(0,184,176,0.25);+    --purple: #8C6BF7;+    --purple-light: #a890ff;+    --green: #4aa857;+    --red: #f85149;+    --yellow: #d4aa3e;+  }+  * { box-sizing: border-box; }+  body {+    margin: 0;+    background: var(--bg);+    color: var(--text);+    font-family: 'Familjen Grotesk', 'Satoshi', -apple-system, "Segoe UI", sans-serif;+    font-size: 14px;+    line-height: 1.5;+  }++  /* Canonical Adom app-header — see gallia/skills/adom-app-header/SKILL.md.+     Single row, 44 px tall: brand | subject (title + state-pill + meta) |+     hover popovers for app info + subject info. Matches adom-step etc. */+  header {+    height: 44px;+    padding: 0 16px;+    display: flex;+    align-items: center;+    gap: 12px;+    border-bottom: 1px solid var(--border-muted);+    background: var(--surface);+    position: relative;+    font-family: 'Familjen Grotesk', sans-serif;+    flex-shrink: 0;+  }+  header .brand {+    display: flex;+    align-items: center;+    gap: 8px;+    color: var(--accent);+    font-size: 13px;+    font-weight: 600;+    letter-spacing: 0.01em;+    cursor: default;+    flex-shrink: 0;+    outline: none;+  }+  header .brand:hover .app-name { text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: 3px; }+  header .brand:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }+  header .brand svg.logo { width: 16px; height: 16px; flex-shrink: 0; }+  header .brand .app-name { color: var(--accent); }+  header .brand .app-subtitle { color: var(--text2); font-weight: 500; }+  header .brand .sep { color: var(--text3); font-weight: 400; }+  header .vsep {+    width: 1px;+    height: 18px;+    background: var(--border);+    flex-shrink: 0;+  }+  header .subject {+    display: flex;+    align-items: center;+    gap: 8px;+    min-width: 0;+    flex: 1 1 auto;+    cursor: default;+  }+  header .subject-name {+    color: var(--text);+    font-family: 'Familjen Grotesk', sans-serif;+    font-size: 14px;+    font-weight: 600;+    white-space: nowrap;+    overflow: hidden;+    text-overflow: ellipsis;+    min-width: 0;+  }+  header .subject-meta {+    display: flex;+    align-items: center;+    gap: 6px;+    color: var(--text2);+    font-family: 'JetBrains Mono', monospace;+    font-size: 11px;+    white-space: nowrap;+    flex-shrink: 0;+  }+  header .subject-meta .sep { color: var(--text3); }+  @media (max-width: 720px) {+    header .subject-meta { display: none; }+  }+  header .state-pill {+    display: inline-flex;+    align-items: center;+    gap: 6px;+    padding: 2px 8px;+    border-radius: 10px;+    font-family: 'Familjen Grotesk', sans-serif;+    font-size: 10px;+    font-weight: 500;+    letter-spacing: 0.3px;+    text-transform: none;+    background: var(--elevated);+    border: 1px solid var(--border);+    color: var(--text2);+    flex-shrink: 0;+  }+  header .state-pill::before {+    content: '';+    display: inline-block;+    width: 6px;+    height: 6px;+    border-radius: 50%;+    background: var(--text3);+  }+  header .state-pill.live::before { background: var(--red); animation: livepulse 1.6s ease-in-out infinite; }+  header .state-pill.live { color: var(--red); border-color: var(--red); }+  header .state-pill.review::before { background: var(--accent); }+  header .state-pill.review { color: var(--accent); border-color: var(--accent-glow); }+  header .state-pill.preview::before { background: var(--purple); }+  header .state-pill.preview { color: var(--purple); border-color: var(--purple); }+  @keyframes livepulse {+    0%,100% { box-shadow: 0 0 0 0 rgba(248,81,73,0.4); }+    50%     { box-shadow: 0 0 0 4px rgba(248,81,73,0); }+  }++  /* Interactive hover-popover */+  header .hover-popover {+    position: absolute;+    top: calc(100% + 6px);+    left: 16px;+    background: var(--elevated);+    border: 1px solid var(--border);+    border-radius: 8px;+    box-shadow: 0 8px 24px rgba(0,0,0,0.5);+    padding: 12px 14px;+    min-width: 300px;+    max-width: 420px;+    font-family: 'Satoshi', sans-serif;+    font-size: 12px;+    color: var(--text);+    z-index: 99999;+    opacity: 0;+    transform: translateY(-2px);+    pointer-events: none;+    transition: opacity 120ms ease, transform 120ms ease;+  }+  header .hover-popover.show {+    opacity: 1;+    transform: none;+    pointer-events: auto;+  }+  header .hover-popover.compact { min-width: 220px; left: auto; right: 16px; }+  header .hover-popover .popover-title {+    font-family: 'Familjen Grotesk', sans-serif;+    font-size: 14px;+    font-weight: 700;+    color: var(--text);+    margin-bottom: 8px;+  }+  header .hover-popover .popover-row {+    display: flex;+    gap: 12px;+    padding: 3px 0;+    align-items: baseline;+  }+  header .hover-popover .popover-row .k {+    color: var(--text3);+    font-family: 'JetBrains Mono', monospace;+    font-size: 10px;+    text-transform: lowercase;+    width: 64px;+    flex-shrink: 0;+  }+  header .hover-popover .popover-row .v {+    color: var(--text);+    font-family: 'Satoshi', sans-serif;+    font-size: 12px;+    flex: 1;+    word-break: break-word;+  }+  header .hover-popover .popover-actions {+    margin-top: 10px;+    padding-top: 10px;+    border-top: 1px solid var(--border-muted);+    display: flex;+    gap: 12px;+  }+  header .hover-popover .popover-link {+    color: var(--accent);+    text-decoration: none;+    font-family: 'Familjen Grotesk', sans-serif;+    font-size: 12px;+    font-weight: 600;+  }+  header .hover-popover .popover-link:hover { text-decoration: underline; }++  /* Timeline strip — scrolls with the rest of the page. */+  .timeline {+    display: flex;+    align-items: center;+    gap: 6px;+    background: var(--elevated);+    border: 1px solid var(--border);+    padding: 14px 18px;+    margin: 18px 32px;+    border-radius: 8px;+    overflow-x: auto;+    white-space: nowrap;+  }+  .tl-step {+    flex-shrink: 0;+    padding: 7px 13px;+    border-radius: 6px;+    background: var(--surface);+    border: 1px solid var(--border);+    font-size: 12px;+    color: var(--text);+    cursor: pointer;+    text-decoration: none;+    display: inline-flex;+    flex-direction: column;+    gap: 2px;+  }+  .tl-step.speedup { border-color: var(--purple); background: #1f1530; }+  .tl-step:hover { border-color: var(--accent-bright); }+  .tl-step .num { color: var(--text3); font-size: 10px; font-weight: 600; }+  .tl-step .mark { color: var(--purple); font-size: 10px; }++  .finals {+    margin: 0 32px 18px;+    display: flex;+    gap: 16px;+    flex-wrap: wrap;+  }+  .finals .card {+    flex: 1;+    min-width: 320px;+    background: var(--surface);+    border: 1px solid var(--border);+    border-radius: 10px;+    padding: 16px;+  }+  .finals h3 {+    margin: 0 0 8px;+    font-size: 12px;+    text-transform: uppercase;+    letter-spacing: 0.8px;+    color: var(--text3);+  }++  .steps { padding: 0 32px 80px; }+  .step-card {+    background: var(--surface);+    border: 1px solid var(--border);+    border-radius: 10px;+    padding: 24px;+    margin-top: 20px;+  }+  .step-card h2 {+    margin: 0 0 6px;+    font-size: 22px;+    color: var(--accent-bright);+    display: flex;+    align-items: center;+    gap: 12px;+  }+  .step-card h2 .badge {+    background: var(--accent);+    color: var(--bg);+    padding: 4px 10px;+    border-radius: 16px;+    font-size: 13px;+    font-weight: 700;+  }+  .step-card .desc { color: var(--text2); font-size: 13px; margin-bottom: 14px; }++  .speedup-pill {+    display: inline-block;+    padding: 4px 10px;+    border-radius: 12px;+    background: var(--purple);+    color: #fff;+    font-size: 12px;+    font-weight: 600;+    margin-left: 8px;+  }+  /* "Just updated" affordance. Sentence case (per human-ui-patterns+     §1e — never ALL CAPS for status pills). No emoji glyph (the 🆕+     character renders as a colored "NEW" badge in most fonts and+     would double up with the label). Tooltip on the pill explains+     what "Just updated" means + when it changed (per §1a). */+  .new-pill {+    display: inline-flex;+    align-items: center;+    gap: 6px;+    padding: 4px 10px;+    border-radius: 12px;+    background: var(--accent-bright);+    color: var(--bg);+    font-size: 12px;+    font-weight: 600;+    letter-spacing: 0.02em;+    text-transform: none;+    margin-left: 8px;+    cursor: help;+  }+  .new-pill .new-pill-dot {+    width: 7px;+    height: 7px;+    border-radius: 50%;+    background: var(--bg);+    animation: new-pulse 1.6s ease-in-out infinite;+  }+  .new-pill .new-pill-when {+    color: rgba(13,17,23,0.65);+    font-weight: 500;+    font-family: 'JetBrains Mono', monospace;+    font-size: 11px;+  }+  .step-card.is-new {+    border-color: var(--accent-bright);+    box-shadow: 0 0 0 1px var(--accent-glow), 0 0 20px var(--accent-glow);+  }+  .stat-new { color: var(--accent-bright); font-weight: 600; }+  @keyframes new-pulse {+    0%,100% { transform: scale(1); opacity: 0.5; }+    50%     { transform: scale(1.4); opacity: 1; }+  }++  .actions-list {+    background: var(--bg);+    border: 1px solid var(--border);+    border-radius: 6px;+    padding: 12px 16px;+    font-family: 'JetBrains Mono', monospace;+    font-size: 12px;+    line-height: 1.7;+    color: var(--text2);+    margin-top: 12px;+    max-height: 160px;+    overflow-y: auto;+  }+  .actions-list .muted { color: var(--text3); }++  .warnings {+    background: #2d2412;+    border: 1px solid #a8791e;+    color: #f0c260;+    border-radius: 6px;+    padding: 10px 14px;+    font-size: 12px;+    margin-top: 12px;+  }++  .clip-row { display: grid; grid-template-columns: 1fr; gap: 16px; margin-top: 18px; }+  .clip-block { display: flex; flex-direction: column; gap: 6px; }+  .clip-label {+    font-size: 11px;+    text-transform: uppercase;+    letter-spacing: 1px;+    font-weight: 600;+  }+  .clip-label.raw { color: var(--accent-bright); }+  .clip-label.fast { color: var(--purple); }+  .clip-label-hint {+    color: var(--text3);+    font-weight: 400;+    text-transform: none;+    letter-spacing: 0.2px;+    font-size: 10px;+    margin-left: 6px;+  }+  /* Collapsible TTS narration disclosure — folded by default. */+  .narration-fold {+    margin-top: 10px;+    border: 1px solid var(--border-muted);+    border-radius: 6px;+    background: var(--elevated);+  }+  .narration-fold summary {+    cursor: pointer;+    padding: 8px 12px;+    color: var(--text2);+    font-size: 12px;+    font-weight: 500;+    user-select: none;+    list-style: none;+  }+  .narration-fold summary::-webkit-details-marker { display: none; }+  .narration-fold summary:hover { color: var(--text); }+  .narration-fold[open] summary { color: var(--accent); border-bottom: 1px solid var(--border-muted); }+  .narration-fold .narration-body {+    padding: 10px 14px 12px;+    color: var(--text);+    font-size: 13px;+    line-height: 1.55;+    font-family: 'Familjen Grotesk', sans-serif;+    white-space: pre-wrap;+  }+  .clip-url {+    color: var(--text3);+    font-family: 'JetBrains Mono', monospace;+    font-size: 11px;+    margin-top: 4px;+  }++  .video-wrap {+    position: relative;+    aspect-ratio: 16 / 10;+    background: #000;+    border-radius: 6px;+    border: 1px solid var(--border);+    overflow: hidden;+  }+  .video-wrap video {+    width: 100%;+    height: 100%;+    display: block;+    background: #000;+    object-fit: contain;+  }++  /* Lightbox for image_path + console_png_path */+  .step-card .thumb {+    cursor: zoom-in;+    transition: opacity 0.15s;+    max-width: 100%;+    border-radius: 6px;+    border: 1px solid var(--border);+  }+  .step-card .thumb:hover { opacity: 0.85; }+  .lightbox {+    display: none;+    position: fixed;+    inset: 0;+    z-index: 1000;+    background: rgba(0,0,0,0.92);+    align-items: center;+    justify-content: center;+    padding: 40px;+    cursor: zoom-out;+  }+  .lightbox.open { display: flex; }+  .lightbox img {+    max-width: 100%;+    max-height: 100%;+    object-fit: contain;+    box-shadow: 0 8px 32px rgba(0,0,0,0.6);+    border: 1px solid var(--border);+  }+  .lightbox .close {+    position: absolute;+    top: 20px;+    right: 32px;+    color: var(--text);+    font-size: 32px;+    cursor: pointer;+    background: none;+    border: none;+    padding: 0;+  }+  .lightbox .caption {+    position: absolute;+    bottom: 20px;+    left: 0;+    right: 0;+    color: var(--text2);+    text-align: center;+    font-size: 13px;+    font-family: 'JetBrains Mono', monospace;+  }++  /* Fullscreen video overlay — works inside iframes that block the+     native Fullscreen API because it's pure CSS positioning. */+  .video-fullscreen {+    display: none;+    position: fixed;+    inset: 0;+    z-index: 2000;+    background: #000;+    align-items: center;+    justify-content: center;+  }+  .video-fullscreen.open { display: flex; }+  .video-fullscreen video {+    max-width: 100%;+    max-height: 100%;+    width: auto;+    height: auto;+    background: #000;+  }+  .video-fullscreen .close {+    position: absolute;+    top: 16px;+    right: 20px;+    z-index: 2001;+    background: rgba(0,0,0,0.85);+    border: 1px solid rgba(255,255,255,0.3);+    color: #fff;+    font-size: 20px;+    cursor: pointer;+    padding: 8px 14px;+    border-radius: 4px;+    font-family: inherit;+  }+  .video-fullscreen .close:hover { border-color: var(--accent-bright); color: var(--accent-bright); }+  .video-fullscreen .label {+    position: absolute;+    top: 16px;+    left: 20px;+    z-index: 2001;+    color: var(--text2);+    font-size: 13px;+    font-family: 'JetBrains Mono', monospace;+    background: rgba(0,0,0,0.85);+    padding: 8px 12px;+    border-radius: 4px;+  }++  .empty-state {+    margin: 60px 32px;+    padding: 40px;+    background: var(--elevated);+    border: 1px dashed var(--border);+    border-radius: 10px;+    text-align: center;+    color: var(--text3);+    font-family: 'JetBrains Mono', monospace;+  }+  .empty-state strong { color: var(--text); }+</style>+</head>+<body>+<header>+  <div class="brand" data-tooltip-target="app-info" tabindex="0">+    <svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">+      <path d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4zm2 2v2h2V6H5zm4 0v12h6V6H9zm8 0v2h2V6h-2zM5 10v2h2v-2H5zm12 0v2h2v-2h-2zM5 14v2h2v-2H5zm12 0v2h2v-2h-2zM5 18v0h2v0H5zm12 0v0h2v0h-2zM11 8v8h2V8h-2z"/>+    </svg>+    <span class="app-name">adom-video-post</span>+    <span class="sep">·</span>+    <span class="app-subtitle">storyboard</span>+  </div>++  <div class="vsep" aria-hidden="true"></div>++  <div class="subject" data-tooltip-target="subject-info">+    <span class="subject-name" id="title">Storyboard</span>+    <span id="statePill" class="state-pill">Loading</span>+    <span class="subject-meta" id="stats"></span>+  </div>++  <div class="hover-popover" id="app-info" role="dialog" aria-label="App information">+    <div class="popover-title">adom-video-post</div>+    <div class="popover-row"><span class="k">about</span>+      <span class="v">Per-clip review, voiceover narration, and final concat for Adom feature-tour demos.</span></div>+    <div class="popover-row"><span class="k">screen</span><span class="v">storyboard · per-clip review</span></div>+    <div class="popover-row"><span class="k">version</span><span class="v">v0.3.0</span></div>+    <div class="popover-row"><span class="k">author</span><span class="v">Adom Inc · adom.inc</span></div>+    <div class="popover-row"><span class="k">tts</span>+      <span class="v"><a class="popover-link" href="https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-tts" target="_blank" rel="noopener" data-tooltip="Open the adom-tts wiki page — shared edge-tts wrapper with pronunciation overrides. Run `adom-tts` to launch the playground locally.">adom-tts ↗</a> — house TTS engine + pronunciation overrides shared by every Adom tool that speaks. Mispronounced terms? Add them once there.</span></div>+    <div class="popover-actions">+      <a class="popover-link" href="https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-video-post" target="_blank" rel="noopener">Open wiki ↗</a>+      <a class="popover-link" href="https://github.com/adom-inc/adom-video-post" target="_blank" rel="noopener">GitHub ↗</a>+    </div>+  </div>++  <div class="hover-popover compact" id="subject-info" role="dialog">+    <div class="popover-row"><span class="k">manifest</span><span class="v" id="tipManifest">—</span></div>+    <div class="popover-row"><span class="k">clips</span><span class="v" id="tipClips">—</span></div>+    <div class="popover-row"><span class="k">duration</span><span class="v" id="tipDuration">—</span></div>+  </div>+</header>++<script>+// SHOW_DELAY = 500 ms per human-ui-patterns §1b; HIDE_GRACE = 220 ms.+(function attachHeaderPopovers(){+  const SHOW_DELAY = 500;+  const HIDE_GRACE = 220;+  const wire = () => {+    document.querySelectorAll('header [data-tooltip-target]').forEach(trigger => {+      if (trigger.__popoverWired) return;+      trigger.__popoverWired = true;+      const target = document.getElementById(trigger.dataset.tooltipTarget);+      if (!target) return;+      let showTimer = null, hideTimer = null;+      const show = () => {+        clearTimeout(hideTimer);+        if (target.classList.contains('show')) return;+        clearTimeout(showTimer);+        showTimer = setTimeout(() => target.classList.add('show'), SHOW_DELAY);+      };+      const queueHide = () => {+        clearTimeout(showTimer);+        clearTimeout(hideTimer);+        hideTimer = setTimeout(() => target.classList.remove('show'), HIDE_GRACE);+      };+      trigger.addEventListener('mouseenter', show);+      trigger.addEventListener('mouseleave', queueHide);+      trigger.addEventListener('focus', show);+      trigger.addEventListener('blur', queueHide);+      target.addEventListener('mouseenter', show);+      target.addEventListener('mouseleave', queueHide);+      trigger.addEventListener('click', e => {+        clearTimeout(showTimer);+        if (target.classList.contains('show')) target.classList.remove('show');+        else target.classList.add('show');+        e.stopPropagation();+      });+    });+    document.addEventListener('click', e => {+      document.querySelectorAll('header .hover-popover.show').forEach(p => {+        if (!p.contains(e.target)) {+          const trigger = document.querySelector(`[data-tooltip-target="${p.id}"]`);+          if (!trigger || !trigger.contains(e.target)) p.classList.remove('show');+        }+      });+    }, true);+  };+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);+  else wire();+})();+</script>++<div id="finals" class="finals" style="display:none;"></div>+<div id="timeline" class="timeline" style="display:none;"></div>+<div id="steps" class="steps"></div>++<!-- Lightbox for click-to-zoom on screenshot thumbs -->+<div id="lightbox" class="lightbox" onclick="closeLightbox(event)">+  <button class="close" onclick="closeLightbox()">&times;</button>+  <img id="lightbox-img" alt="">+  <div class="caption" id="lightbox-caption"></div>+</div>++<!-- Fullscreen video overlay -->+<div id="video-fs" class="video-fullscreen" onclick="closeVideoFs(event)">+  <button class="close" onclick="closeVideoFs()">&times; Close</button>+  <div class="label" id="video-fs-label"></div>+  <video id="video-fs-player" controls autoplay></video>+</div>++<!-- EVAL_CLIENT -->++<script>+// Expose appCtx early so /eval snippets can inspect even before+// the first /manifest fetch resolves.+window.__appCtx = window.__appCtx || {};+window.__appCtx.subcommand = 'storyboard';+window.__appCtx.manifest = null;+window.__appCtx.version = 0;++// --- console forwarding (same pattern as voiceover/ui.html) ---+(function() {+  const orig = { log: console.log, warn: console.warn, error: console.error };+  function forward(level, args) {+    orig[level].apply(console, args);+    try {+      fetch('console', {+        method: 'POST',+        headers: { 'Content-Type': 'application/json' },+        body: JSON.stringify({+          level: level,+          message: args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '),+          ts: Date.now(),+        }),+      }).catch(() => {});+    } catch (_) {}+  }+  console.log = function() { forward('log', arguments); };+  console.warn = function() { forward('warn', arguments); };+  console.error = function() { forward('error', arguments); };+})();++function esc(s) {+  return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');+}++// --- lightbox / fullscreen helpers ---+window.closeLightbox = function(e) {+  if (e && e.target && e.target.tagName === 'IMG') return;+  document.getElementById('lightbox').classList.remove('open');+};+// expandVideo() removed: the iframe now has fullscreen permissions+// granted (Adom Hydrogen + pup proxy) so the native <video> element's+// fullscreen control handles enlargement directly. The custom Expand+// button + overlay player are no longer needed.+window.closeVideoFs = function(e) {+  if (e && e.target && e.target.tagName === 'VIDEO') return;+  const fs = document.getElementById('video-fs');+  const player = document.getElementById('video-fs-player');+  player.pause();+  player.src = '';+  fs.classList.remove('open');+};+document.addEventListener('keydown', (e) => {+  if (e.key === 'Escape') { closeLightbox(); closeVideoFs(); }+});++// --- render the manifest ---+let liveTimer = null;++// "Just updated" tracking. Each manifest+clip gets its own localStorage+// entry recording the highest mtime ever observed. When fetchManifest+// returns a clip whose mtime > the stored last-seen, mark it as+// recently updated. After the user has had ~30 s to look at the page,+// bump last-seen so it's no longer flagged on the next reload.+//+// IMPORTANT: on the very first visit to a manifest (no localStorage+// keys yet) we record current mtimes silently so NOTHING is flagged.+// "Everything is new" is a useless state — the user wants to know+// what changed *between* visits, not what existed on first load.+function newSinceLastSeenKey(manifest, clip, kind) {+  const slug = (manifest.title || 'storyboard').toLowerCase().replace(/[^a-z0-9]+/g, '-');+  return `vp:lastseen:${slug}:${clip.id}:${kind}`;+}+function manifestEverSeenKey(manifest) {+  const slug = (manifest.title || 'storyboard').toLowerCase().replace(/[^a-z0-9]+/g, '-');+  return `vp:everseen:${slug}`;+}+function isFirstVisit(manifest) {+  return !localStorage.getItem(manifestEverSeenKey(manifest));+}+function isNewSinceLastSeen(manifest, clip) {+  if (isFirstVisit(manifest)) return false;+  const rawMtime = clip.raw_mtime_ms || 0;+  if (!rawMtime) return false;+  const seen = parseInt(localStorage.getItem(newSinceLastSeenKey(manifest, clip, 'raw')) || '0', 10);+  return rawMtime > seen;+}+function markClipSeen(manifest, clip) {+  if (clip.raw_mtime_ms) {+    localStorage.setItem(newSinceLastSeenKey(manifest, clip, 'raw'), String(clip.raw_mtime_ms));+  }+  if (clip.fast_mtime_ms) {+    localStorage.setItem(newSinceLastSeenKey(manifest, clip, 'fast'), String(clip.fast_mtime_ms));+  }+}+function markManifestEverSeen(manifest) {+  localStorage.setItem(manifestEverSeenKey(manifest), String(Date.now()));+}+// Friendly relative-time string for "this clip was updated <when>".+function relativeUpdated(mtimeMs) {+  if (!mtimeMs) return '';+  const ageMs = Date.now() - mtimeMs;+  const sec = Math.round(ageMs / 1000);+  if (sec < 60) return `${sec}s ago`;+  const min = Math.round(sec / 60);+  if (min < 60) return `${min}m ago`;+  const hr = Math.round(min / 60);+  if (hr < 24) return `${hr}h ago`;+  return `${Math.round(hr / 24)}d ago`;+}++function renderManifest(manifest, version) {+  document.title = manifest.title || 'Storyboard';+  document.getElementById('title').textContent = manifest.title || 'Storyboard';++  const clipCount = manifest.clips.length;+  const totalSpeedups = manifest.clips.reduce((n, c) => n + (c.speedup_events?.length || 0), 0);+  const withFast = manifest.clips.filter(c => c.fast_path).length;+  const newCount = manifest.clips.filter(c => isNewSinceLastSeen(manifest, c)).length;++  // Stats row (subject-meta) + tooltip mirrors+  const parts = [`${clipCount} clip${clipCount === 1 ? '' : 's'}`];+  if (totalSpeedups > 0) parts.push(`${totalSpeedups} speedup${totalSpeedups === 1 ? '' : 's'}`);+  if (withFast > 0) parts.push(`${withFast} sped-up`);+  if (newCount > 0) parts.push(`<span class="stat-new">${newCount} new</span>`);+  document.getElementById('stats').innerHTML = parts+    .map(p => `<span>${p}</span>`)+    .join('<span class="sep">·</span>');+  const tipClips = document.getElementById('tipClips');+  const tipManifest = document.getElementById('tipManifest');+  if (tipClips) tipClips.textContent = `${clipCount} (${withFast} sped-up)`;+  if (tipManifest && manifest.title) tipManifest.textContent = manifest.title;++  // State pill — preview if no fast clips yet, review if all present,+  // live if the last manifest mtime change was recent.+  const pill = document.getElementById('statePill');+  if (window.__appCtx.lastManifestChange && (Date.now() - window.__appCtx.lastManifestChange) < 5000) {+    pill.className = 'state-pill live';+    pill.textContent = 'Live';+  } else if (withFast === clipCount && clipCount > 0) {+    pill.className = 'state-pill review';+    pill.textContent = 'Review';+  } else {+    pill.className = 'state-pill preview';+    pill.textContent = 'Preview';+  }++  // Finals+  const finalsEl = document.getElementById('finals');+  let finalsHtml = '';+  if (manifest.final_raw_path) {+    finalsHtml += `<div class="card">+      <h3>Raw concat</h3>+      <div class="video-wrap">+        <video controls preload="metadata" src="final-video/raw" data-label="Final raw"></video>+      </div>+    </div>`;+  }+  if (manifest.final_fast_path) {+    finalsHtml += `<div class="card">+      <h3>Fast concat</h3>+      <div class="video-wrap">+        <video controls preload="metadata" src="final-video/fast" data-label="Final fast"></video>+      </div>+    </div>`;+  }+  finalsEl.innerHTML = finalsHtml;+  finalsEl.style.display = finalsHtml ? 'flex' : 'none';++  // Timeline strip+  const timelineEl = document.getElementById('timeline');+  if (clipCount > 0) {+    timelineEl.style.display = 'flex';+    timelineEl.innerHTML = manifest.clips.map((c, i) => {+      const n = i + 1;+      const hasSpeedup = (c.speedup_events || []).length > 0;+      const cls = hasSpeedup ? 'tl-step speedup' : 'tl-step';+      const markHtml = hasSpeedup+        ? `<span class="mark">⚡ ${esc(c.speedup_events[0].speed)}x</span>`+        : '';+      return `<a class="${cls}" href="#clip-${esc(c.id)}">+        <span class="num">${n}</span>+        <span>${esc(c.title || c.id).slice(0, 30)}</span>+        ${markHtml}+      </a>`;+    }).join('');+  } else {+    timelineEl.style.display = 'none';+  }++  // Step cards+  const stepsEl = document.getElementById('steps');+  if (clipCount === 0) {+    stepsEl.innerHTML = `<div class="empty-state">+      <strong>No clips yet.</strong><br><br>+      Your demo script should call <code>adom-video-post manifest add</code> once per completed step.<br>+      The UI live-updates as clips are added.+    </div>`;+    return;+  }+  stepsEl.innerHTML = manifest.clips.map((c, i) => renderClip(c, i + 1)).join('');++  // Wire up lightbox on newly-rendered thumbnails+  document.querySelectorAll('.step-card .thumb').forEach(el => {+    el.addEventListener('click', (e) => {+      e.stopPropagation();+      const img = document.getElementById('lightbox-img');+      const cap = document.getElementById('lightbox-caption');+      img.src = el.src;+      cap.textContent = el.getAttribute('alt') || '';+      document.getElementById('lightbox').classList.add('open');+    });+  });+}++function renderClip(c, n) {+  const speedupPill = (c.speedup_events || []).length > 0+    ? `<span class="speedup-pill">⚡ ${esc(c.speedup_events[0].speed)}x ${esc(c.speedup_events[0].label)}</span>`+    : '';+  // "Just updated" badge — clip's raw_mtime_ms exceeds the user's+  // last-seen marker. Captured during render so layout knows whether+  // to add the .is-new class to the card for ring highlighting.+  const isNew = (window.__appCtx && window.__appCtx.manifest)+    ? isNewSinceLastSeen(window.__appCtx.manifest, c)+    : false;+  const updatedAt = isNew ? relativeUpdated(c.raw_mtime_ms) : '';+  // Sentence case (per human-ui-patterns §1e: never ALL CAPS for status+  // pills). Tooltip explains when it changed AND why a NEW badge is+  // showing — the user shouldn't have to guess (per §1a). No emoji+  // (the 🆕 glyph already contains the word "NEW", produces a+  // double-decker label).+  const newTooltip = isNew+    ? `Updated ${updatedAt}. This clip was re-recorded since your last reload — focus your review here. The badge auto-clears after 30 seconds on the page.`+    : '';+  const newPill = isNew+    ? `<span class="new-pill" data-tooltip="${esc(newTooltip)}">+         <span class="new-pill-dot" aria-hidden="true"></span>+         <span class="new-pill-label">Just updated</span>+         <span class="new-pill-when">· ${esc(updatedAt)}</span>+       </span>`+    : '';+  const description = c.description ? `<div class="desc">${esc(c.description)}</div>` : '';+  const warnings = (c.warnings || []).length > 0+    ? `<div class="warnings">⚠ ${c.warnings.map(esc).join('<br>⚠ ')}</div>`+    : '';+  const actions = (c.actions || []).length > 0+    ? `<div class="actions-list">${c.actions.map(a => esc(a)).map(a => '• ' + a).join('<br>')}</div>`+    : '';++  // Thumbnails: image_path (step screenshot) + console_png_path (data close-up)+  const thumbs = [];+  if (c.image_path) {+    thumbs.push(`<img class="thumb" src="image/${esc(c.id)}" alt="${esc(c.title)} — screenshot">`);+  }+  if (c.console_png_path) {+    thumbs.push(`<img class="thumb" src="console-png/${esc(c.id)}" alt="${esc(c.title)} — console output">`);+  }+  const thumbRow = thumbs.length > 0+    ? `<div style="display:flex;gap:12px;flex-wrap:wrap;margin-top:12px;">${thumbs.join('')}</div>`+    : '';++  // Video blocks. Label is "Recorded clip" — note this is post-mux: TTS+  // narration audio has been merged into the silent screen recording.+  // The truly-raw silent capture isn't surfaced (the narrated version is+  // what the demo actually ships).+  let rawBlock = '';+  if (c.raw_path) {+    rawBlock = `<div class="clip-block">+      <div class="clip-label raw">Recorded clip <span class="clip-label-hint">(silent capture + TTS audio muxed)</span></div>+      <div class="video-wrap">+        <video controls preload="metadata" poster="poster/${esc(c.id)}" src="video/${esc(c.id)}/raw" data-label="${esc(c.title)} raw"></video>+      </div>+    </div>`;+  }+  let fastBlock = '';+  if (c.fast_path) {+    fastBlock = `<div class="clip-block">+      <div class="clip-label fast">Fast (after speedup)</div>+      <div class="video-wrap">+        <video controls preload="metadata" poster="poster/${esc(c.id)}" src="video/${esc(c.id)}/fast" data-label="${esc(c.title)} fast"></video>+      </div>+    </div>`;+  }++  // Collapsible narration script (TTS source text). Distinct from the+  // intent description above. Folded by default; click to read what+  // the viewer will hear.+  const narrationBlock = c.narration+    ? `<details class="narration-fold">+         <summary>▸ TTS narration</summary>+         <div class="narration-body">${esc(c.narration)}</div>+       </details>`+    : '';++  return `<div class="step-card${isNew ? ' is-new' : ''}" id="clip-${esc(c.id)}" data-clip-id="${esc(c.id)}">+    <h2><span class="badge">${n}</span>${esc(c.title || c.id)}${speedupPill}${newPill}</h2>+    ${description}+    ${narrationBlock}+    ${warnings}+    ${thumbRow}+    ${actions}+    <div class="clip-row">${rawBlock}${fastBlock}</div>+  </div>`;+}++// --- fetch + live watch ---+async function fetchManifest() {+  const r = await fetch('manifest');+  if (!r.ok) throw new Error('GET /manifest ' + r.status);+  return r.json();+}+async function fetchState() {+  const r = await fetch('state');+  if (!r.ok) throw new Error('GET /state ' + r.status);+  return r.json();+}++// Auto-mark NEW clips as seen after a delay so the badge fades next reload.+// Triggered after every render — we wait 30 s once the user has had a real+// chance to look at fresh clips. Resets on every render that adds new+// "is-new" cards (so re-shoots restart the timer).+let __markSeenTimer = null;+function scheduleMarkSeen(manifest) {+  if (__markSeenTimer) clearTimeout(__markSeenTimer);+  __markSeenTimer = setTimeout(() => {+    if (!manifest || !manifest.clips) return;+    manifest.clips.forEach(c => markClipSeen(manifest, c));+    console.log('auto mark-seen: cleared NEW badge state for', manifest.clips.length, 'clips');+  }, 30000);+}++async function pollLoop() {+  while (true) {+    try {+      const s = await fetchState();+      if (s.version !== window.__appCtx.version) {+        const prevVersion = window.__appCtx.version;+        const m = await fetchManifest();+        // First-visit: silently record current mtimes so nothing+        // is flagged "Just updated" on initial load. Only flag clips+        // whose mtime > previously-stored last-seen on later visits.+        const firstVisit = isFirstVisit(m);+        if (firstVisit) {+          m.clips.forEach(c => markClipSeen(m, c));+          markManifestEverSeen(m);+        }+        window.__appCtx.manifest = m;+        window.__appCtx.version = s.version;+        if (!firstVisit) scheduleMarkSeen(m);+        if (prevVersion > 0) {+          // Version bumped AFTER initial load — this means a live+          // watcher update just fired. Mark it so the pill shows+          // "Live" for 5 seconds.+          window.__appCtx.lastManifestChange = Date.now();+        }+        renderManifest(m, s.version);+        console.log('manifest loaded: version', s.version, 'clips', m.clips.length);+      } else if (window.__appCtx.lastManifestChange &&+                 (Date.now() - window.__appCtx.lastManifestChange) >= 5000) {+        // Recompute pill when the "Live" timeout expires.+        if (window.__appCtx.manifest) {+          renderManifest(window.__appCtx.manifest, s.version);+        }+        window.__appCtx.lastManifestChange = null;+      }+    } catch (e) {+      console.warn('pollLoop error:', e.message);+    }+    await new Promise(r => setTimeout(r, 800));+  }+}++// --- URL hash remote-control (same contract as demo-dry-run.py) ---+function handleTestHash() {+  const m = /^#test=expand-(final|\S+)-(raw|fast)$/.exec(window.location.hash);+  if (!m) return;+  const which = m[1], variant = m[2];+  let needle;+  if (which === 'final') {+    needle = variant === 'raw' ? 'Final raw' : 'Final fast';+  } else {+    // Per-clip expand: find the video whose data-label starts with+    // the clip's title. Use the clip id as the needle first because+    // ids are stable; fall back to title.+    const clip = (window.__appCtx.manifest?.clips || []).find(c => c.id === which);+    if (clip) {+      needle = clip.title + ' ' + variant;+    } else {+      return;+    }+  }+  const target = Array.from(document.querySelectorAll('.video-wrap video'))+    .find(v => (v.getAttribute('data-label') || '').indexOf(needle) !== -1);+  if (target) {+    const btn = target.parentElement.querySelector('.expand');+    if (btn) btn.click();+  }+}+window.addEventListener('load', () => {+  pollLoop();+  setTimeout(handleTestHash, 500);+});+window.addEventListener('hashchange', handleTestHash);+</script>+</body>+</html>
src/voiceover/icon.svgadded+6
@@ -0,0 +1,6 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#e6edf3">+  <!-- Microphone capsule: rounded rectangle body centered near the top -->+  <path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/>+  <!-- U-shaped cradle + stand + base — stroke paths rendered via a single filled combo -->+  <path d="M5 11a1 1 0 0 1 2 0 5 5 0 0 0 10 0 1 1 0 0 1 2 0 7 7 0 0 1-6 6.93V20h3a1 1 0 0 1 0 2H8a1 1 0 0 1 0-2h3v-2.07A7 7 0 0 1 5 11z"/>+</svg>
src/voiceover/mod.rsadded+99
@@ -0,0 +1,99 @@+//! Voiceover subcommand — phase 2 of adom-video-post.+//!+//! Plays the input (sped-up) video back in a Hydrogen webview tab,+//! captures mic audio via Hydrogen's first-class audio API while+//! the user narrates, then muxes the audio with the video to produce+//! a narrated output. The HTTP plumbing is now in `crate::webapp`;+//! this file is the thin wrapper that owns the voiceover-specific+//! config + result slot and hands everything over to `run_webapp`.++pub mod server;++use crate::webapp::{run_webapp, WebappConfig};+use std::path::PathBuf;+use std::process::Command;+use std::sync::{Arc, Mutex};++/// Pre-load placeholder icon. Shown on the Hydrogen tab for ~100ms+/// while the UI HTML loads, then swapped for `src/voiceover/icon.svg`+/// via the `<link rel="icon">` the page ships (see the+/// hydrogen-tab-icons skill). MDI gets the right theme color+/// automatically so the placeholder and the served favicon look+/// nearly identical and the swap is invisible.+const TAB_ICON_PLACEHOLDER: &str = "mdi:microphone-variant";++pub struct VoiceoverConfig {+    pub input: PathBuf,+    pub output: PathBuf,+    pub port: u16,+    pub tab_name: String,+    pub panel_id_override: Option<String>,+    pub markers_file: Option<PathBuf>,+    /// Skip the Hydrogen webview tab open; HTTP server only.+    pub no_open: bool,+}++/// Run the voiceover flow end-to-end. Blocks until the user finalizes+/// a take and hits /shutdown (or an error occurs), then returns the+/// path to the final muxed video.+pub fn run(cfg: VoiceoverConfig) -> Result<PathBuf, String> {+    if !cfg.input.exists() {+        return Err(format!(+            "input video not found: {}. Hint: run `adom-video-post process` first to produce a sped-up video.",+            cfg.input.display()+        ));+    }++    let title = cfg+        .input+        .file_name()+        .map(|n| n.to_string_lossy().to_string())+        .unwrap_or_else(|| "voiceover".to_string());++    let webapp_cfg = WebappConfig {+        port: cfg.port,+        tab_name: cfg.tab_name.clone(),+        panel_id_override: cfg.panel_id_override.clone(),+        display_icon: TAB_ICON_PLACEHOLDER.to_string(),+        subcommand: "voiceover".to_string(),+        title,+        no_open: cfg.no_open,+    };++    let result_slot: Arc<Mutex<Option<Result<PathBuf, String>>>> = Arc::new(Mutex::new(None));+    let routes = Arc::new(server::VoiceoverRoutes::new(cfg, result_slot.clone()));++    run_webapp(webapp_cfg, routes)?;++    let final_result = result_slot.lock().unwrap().take();+    match final_result {+        Some(Ok(path)) => Ok(path),+        Some(Err(e)) => Err(e),+        None => Err("voiceover session ended without producing a final video".to_string()),+    }+}++/// Mux the recorded voiceover audio with the input video.+/// Output is video-only with the new audio track.+pub fn mux(+    input_video: &std::path::Path,+    voiceover_audio: &std::path::Path,+    output: &std::path::Path,+) -> Result<(), String> {+    let result = Command::new("ffmpeg")+        .args(["-y", "-i"])+        .arg(input_video)+        .args(["-i"])+        .arg(voiceover_audio)+        .args(["-c:v", "copy", "-c:a", "libopus", "-shortest"])+        .arg(output)+        .output()+        .map_err(|e| format!("ffmpeg spawn failed: {}", e))?;+    if !result.status.success() {+        let stderr = String::from_utf8_lossy(&result.stderr);+        let tail: Vec<&str> = stderr.lines().rev().take(20).collect();+        let tail: Vec<&str> = tail.into_iter().rev().collect();+        return Err(format!("ffmpeg mux failed:\n{}", tail.join("\n")));+    }+    Ok(())+}
src/voiceover/server.rsadded+877
@@ -0,0 +1,877 @@+//! Voiceover-specific HTTP handlers.+//!+//! Plugs into the shared `webapp` module via `impl Routes for+//! VoiceoverRoutes`. The built-in webapp routes (UI, console, /eval,+//! /shutdown, favicon) are handled by `webapp::server`; this file+//! only contains the voiceover-specific surface: audio start/stop,+//! normalize, finalize/mux, take history, waveform, timeline,+//! reveal-in-explorer, and the /video + /final-video range streams.+//!+//! Voiceover is audio-only: Hydrogen's first-class audio API+//! (`adom-cli hydrogen audio start/stop`) owns mic capture. Our+//! role is to orchestrate start/stop, mux the resulting .webm+//! against the input video, and expose a UI for reviewing takes.++use super::{mux, VoiceoverConfig};+use crate::webapp::{video as webapp_video, RouteOutcome, Routes};+use std::path::PathBuf;+use std::process::Command;+use std::sync::{Arc, Mutex};+use tiny_http::{Header, Method, Request, Response};++#[derive(Default)]+pub struct AppState {+    recording: bool,+    audio_path: Option<PathBuf>,+    audio_size: u64,+    finalize_path: Option<PathBuf>,+    /// Sidecar preview path produced by /normalize. The UI's /final-video+    /// endpoint serves this if set, so users can audition the loudness-+    /// normalized version before committing. /save-final copies this+    /// over finalize_path and clears the preview.+    preview_path: Option<PathBuf>,+    phase: String,+}++pub type SharedState = Arc<Mutex<AppState>>;++/// The voiceover subcommand's `impl Routes`. Captures the config,+/// the shared AppState, and the result slot that `run()` waits on.+pub struct VoiceoverRoutes {+    pub cfg: VoiceoverConfig,+    pub state: SharedState,+    pub result_slot: Arc<Mutex<Option<Result<PathBuf, String>>>>,+}++const UI_HTML: &str = include_str!("ui.html");+const FAVICON_SVG: &str = include_str!("icon.svg");++impl VoiceoverRoutes {+    pub fn new(+        cfg: VoiceoverConfig,+        result_slot: Arc<Mutex<Option<Result<PathBuf, String>>>>,+    ) -> Self {+        Self {+            cfg,+            state: Arc::new(Mutex::new(AppState {+                phase: "idle".to_string(),+                ..Default::default()+            })),+            result_slot,+        }+    }+}++impl Routes for VoiceoverRoutes {+    fn ui_html(&self) -> &str {+        UI_HTML+    }++    fn favicon_svg(&self) -> &str {+        FAVICON_SVG+    }++    fn handle(&self, request: Request, method: &Method, url: &str) -> RouteOutcome {+        let res: Result<(), String> = match (method, url) {+            (Method::Get, "/video") => {+                webapp_video::serve_file_range(request, &self.cfg.input, "video/webm")+            }+            (Method::Get, "/final-video") => {+                // Prefer the preview (normalized) version if one exists,+                // otherwise fall back to the committed final output.+                let path = self+                    .state+                    .lock()+                    .unwrap()+                    .preview_path+                    .clone()+                    .unwrap_or_else(|| self.cfg.output.clone());+                webapp_video::serve_file_range(request, &path, "video/webm")+            }+            (Method::Post, "/normalize") => handle_normalize(request, &self.state, &self.cfg.output),+            (Method::Post, "/revert-to-raw") => handle_revert_to_raw(request, &self.state),+            (Method::Post, "/save-final") => handle_save_final(request, &self.state, &self.cfg.output),+            (Method::Post, "/reveal") => handle_reveal(request, &self.state, &self.cfg.output),+            (Method::Post, "/adopt-latest-audio") => handle_adopt_latest_audio(request, &self.state),+            (Method::Get, "/takes") => handle_list_takes(request, &self.state, &self.cfg.input, &self.cfg.output),+            (Method::Get, "/timeline") => handle_timeline(request, &self.cfg.input, self.cfg.markers_file.as_deref()),+            (Method::Post, "/select-take") => handle_select_take(request, &self.state),+            (Method::Get, path)+                if path == "/waveform" || path == "/waveform-raw" || path == "/waveform-preview" =>+            {+                let which = if path.ends_with("-raw") {+                    "raw"+                } else {+                    "preview"+                };+                handle_waveform(request, &self.state, &self.cfg.output, which)+            }+            (Method::Get, "/state") => handle_get_state(request, &self.state),+            (Method::Post, "/start-recording") => handle_start_recording(request, &self.state),+            (Method::Post, "/stop-recording") => handle_stop_recording(request, &self.state),+            (Method::Post, "/discard") => handle_discard(request, &self.state),+            (Method::Post, "/replay") => handle_replay(request, &self.state, &self.cfg.output),+            (Method::Post, "/finalize") => {+                let r = handle_finalize(request, &self.cfg.input, &self.state, &self.cfg.output);+                if let Some(path) = r {+                    *self.result_slot.lock().unwrap() = Some(Ok(path));+                }+                Ok(())+            }+            _ => return RouteOutcome::NotHandled(request),+        };+        RouteOutcome::Handled(res)+    }+}++fn handle_start_recording(request: Request, state: &SharedState) -> Result<(), String> {+    println!("starting voiceover audio-only recording with caption countdown");+    // `hydrogen audio start` is audio-only (does NOT capture the screen),+    // but has no native countdown primitive. We drive a 3-2-1 overlay+    // server-side via `hydrogen caption show` so both the UI-click path+    // and the external curl/AI path get the same countdown.+    let _ = Command::new("adom-cli")+        .args(["hydrogen", "audio", "enable", "--reason", "Voiceover recording"])+        .output();++    for digit in ["3", "2", "1"] {+        let _ = Command::new("adom-cli")+            .args(["hydrogen", "caption", "show", digit, "-d", "1", "-s", "large", "-p", "center"])+            .output();+        std::thread::sleep(std::time::Duration::from_millis(1000));+    }+    let _ = Command::new("adom-cli")+        .args(["hydrogen", "caption", "show", "GO", "-d", "1", "-s", "large", "-p", "center"])+        .output();+    std::thread::sleep(std::time::Duration::from_millis(400));++    let result = Command::new("adom-cli")+        .args(["hydrogen", "audio", "start"])+        .output()+        .map_err(|e| format!("adom-cli spawn failed: {}", e))?;+    if !result.status.success() {+        let stderr = String::from_utf8_lossy(&result.stderr);+        let _ = request.respond(json_response(+            500,+            &format!(r#"{{"ok":false,"error":"audio start failed: {}"}}"#, stderr.replace('"', "\\\"")),+        ));+        return Ok(());+    }+    {+        let mut s = state.lock().unwrap();+        s.recording = true;+        s.phase = "recording".to_string();+        s.audio_path = None;+        s.audio_size = 0;+    }+    let _ = request.respond(json_response(200, r#"{"ok":true}"#));+    Ok(())+}++fn handle_stop_recording(request: Request, state: &SharedState) -> Result<(), String> {+    println!("stopping Hydrogen audio recording...");+    let result = Command::new("adom-cli")+        .args(["hydrogen", "audio", "stop"])+        .output()+        .map_err(|e| format!("adom-cli spawn failed: {}", e))?;+    if !result.status.success() {+        let stderr = String::from_utf8_lossy(&result.stderr);+        let _ = request.respond(json_response(+            500,+            &format!(r#"{{"ok":false,"error":"audio stop failed: {}"}}"#, stderr.replace('"', "\\\"")),+        ));+        return Ok(());+    }+    let stdout = String::from_utf8_lossy(&result.stdout);+    let parsed: serde_json::Value = serde_json::from_str(&stdout)+        .map_err(|e| format!("parse audio stop response: {} ({})", e, stdout))?;+    let file_rel = parsed+        .get("filePath")+        .and_then(|v| v.as_str())+        .ok_or_else(|| format!("audio stop response missing filePath: {}", stdout))?;+    let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());+    let absolute = PathBuf::from(home).join("project").join(file_rel);+    let size = parsed.get("size").and_then(|v| v.as_u64()).unwrap_or(0);+    println!("Hydrogen saved audio: {} ({} bytes)", absolute.display(), size);++    {+        let mut s = state.lock().unwrap();+        s.recording = false;+        s.phase = "recorded".to_string();+        s.audio_path = Some(absolute.clone());+        s.audio_size = size;+    }++    let body = format!(+        r#"{{"ok":true,"path":"{}","size":{}}}"#,+        absolute.display(),+        size+    );+    let _ = request.respond(json_response(200, &body));+    Ok(())+}++fn next_take_number(output: &std::path::Path) -> u32 {+    let parent = output.parent().unwrap_or_else(|| std::path::Path::new("."));+    let stem = output.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+    let prefix = format!("{}-take-", stem);+    let mut max_n = 0u32;+    if let Ok(read) = std::fs::read_dir(parent) {+        for e in read.flatten() {+            if let Some(name) = e.file_name().to_str().map(|s| s.to_string()) {+                if let Some(rest) = name.strip_prefix(&prefix) {+                    let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();+                    if let Ok(n) = num.parse::<u32>() { if n > max_n { max_n = n; } }+                }+            }+        }+    }+    max_n + 1+}++fn take_raw_path(output: &std::path::Path, n: u32) -> PathBuf {+    let parent = output.parent().unwrap_or_else(|| std::path::Path::new("."));+    let stem = output.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+    let ext = output.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| "webm".to_string());+    parent.join(format!("{}-take-{}.{}", stem, n, ext))+}++fn take_norm_path(output: &std::path::Path, n: u32) -> PathBuf {+    let parent = output.parent().unwrap_or_else(|| std::path::Path::new("."));+    let stem = output.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+    let ext = output.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| "webm".to_string());+    parent.join(format!("{}-take-{}-norm.{}", stem, n, ext))+}++fn run_loudnorm(source: &std::path::Path, dest: &std::path::Path) -> Result<(), String> {+    let result = Command::new("ffmpeg")+        .args(["-y", "-i"])+        .arg(source)+        .args([+            "-af", "loudnorm=I=-16:TP=-1.5:LRA=11",+            "-c:v", "copy",+            "-c:a", "libopus",+            "-b:a", "128k",+        ])+        .arg(dest)+        .output()+        .map_err(|e| format!("ffmpeg spawn failed: {}", e))?;+    if !result.status.success() {+        let stderr = String::from_utf8_lossy(&result.stderr);+        return Err(format!("ffmpeg loudnorm failed: {}", stderr.lines().rev().take(5).collect::<Vec<_>>().join("; ")));+    }+    Ok(())+}++fn handle_finalize(+    request: Request,+    input_video: &std::path::Path,+    state: &SharedState,+    output: &std::path::Path,+) -> Option<PathBuf> {+    let audio = state.lock().unwrap().audio_path.clone();+    let audio = match audio {+        Some(p) if p.exists() => p,+        _ => {+            let _ = request.respond(json_response(+                400,+                r#"{"ok":false,"error":"no audio recorded yet, click Record first"}"#,+            ));+            return None;+        }+    };+    state.lock().unwrap().phase = "finalizing".to_string();++    // Versioned raw mux: output-take-N.webm. Then auto-loudnorm to+    // output-take-N-norm.webm so every finalize produces both tabs+    // (raw and auto-leveled) at once, no extra clicks.+    let take_n = next_take_number(output);+    let raw = take_raw_path(output, take_n);+    let norm = take_norm_path(output, take_n);+    println!("muxing take {}: {} + {} -> {}", take_n, input_video.display(), audio.display(), raw.display());++    if let Err(e) = mux(input_video, &audio, &raw) {+        state.lock().unwrap().phase = "error".to_string();+        let body = format!(r#"{{"ok":false,"error":"{}"}}"#, e.replace('"', "\\\""));+        let _ = request.respond(json_response(500, &body));+        return None;+    }+    println!("mux complete: {}", raw.display());++    let norm_ok = run_loudnorm(&raw, &norm).is_ok();+    if norm_ok {+        println!("auto-loudnorm complete: {}", norm.display());+    } else {+        println!("auto-loudnorm failed; falling back to raw only for take {}", take_n);+    }++    // Default selection: auto-leveled version (usually the user's preferred).+    {+        let mut s = state.lock().unwrap();+        s.phase = "done".to_string();+        s.finalize_path = Some(raw.clone());+        s.preview_path = Some(if norm_ok { norm.clone() } else { raw.clone() });+    }+    let body = format!(+        r#"{{"ok":true,"take":{},"rawPath":"{}","normalizedPath":"{}","normalizedOk":{}}}"#,+        take_n, raw.display(), norm.display(), norm_ok+    );+    let _ = request.respond(json_response(200, &body));+    Some(raw)+}++fn normalized_sidecar_path(output: &std::path::Path) -> PathBuf {+    use std::time::{SystemTime, UNIX_EPOCH};+    let stem = output.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+    let ext = output.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| "webm".to_string());+    let parent = output.parent().unwrap_or_else(|| std::path::Path::new("."));+    let ts = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);+    parent.join(format!("{}-norm-{}.{}", stem, ts, ext))+}++fn handle_normalize(request: Request, state: &SharedState, output: &std::path::Path) -> Result<(), String> {+    let source = {+        let s = state.lock().unwrap();+        s.preview_path.clone().unwrap_or_else(|| {+            s.finalize_path.clone().unwrap_or_else(|| output.to_path_buf())+        })+    };+    if !source.exists() {+        let _ = request.respond(json_response(+            400,+            r#"{"ok":false,"error":"nothing to normalize; finalize a recording first"}"#,+        ));+        return Ok(());+    }+    let preview = normalized_sidecar_path(output);+    println!("normalizing {} -> {} (loudnorm I=-16 TP=-1.5 LRA=11)", source.display(), preview.display());++    let result = Command::new("ffmpeg")+        .args(["-y", "-i"])+        .arg(&source)+        .args([+            "-af", "loudnorm=I=-16:TP=-1.5:LRA=11",+            "-c:v", "copy",+            "-c:a", "libopus",+            "-b:a", "128k",+        ])+        .arg(&preview)+        .output();+    match result {+        Ok(out) if out.status.success() => {+            state.lock().unwrap().preview_path = Some(preview.clone());+            let body = format!(r#"{{"ok":true,"previewPath":"{}"}}"#, preview.display());+            let _ = request.respond(json_response(200, &body));+        }+        Ok(out) => {+            let stderr = String::from_utf8_lossy(&out.stderr);+            let tail: Vec<&str> = stderr.lines().rev().take(10).collect();+            let tail: Vec<&str> = tail.into_iter().rev().collect();+            let err = tail.join("\\n").replace('"', "\\\"");+            let body = format!(r#"{{"ok":false,"error":"ffmpeg loudnorm failed: {}"}}"#, err);+            let _ = request.respond(json_response(500, &body));+        }+        Err(e) => {+            let body = format!(r#"{{"ok":false,"error":"ffmpeg spawn failed: {}"}}"#, e);+            let _ = request.respond(json_response(500, &body));+        }+    }+    Ok(())+}++fn handle_waveform(+    request: Request,+    state: &SharedState,+    output: &std::path::Path,+    which: &str,+) -> Result<(), String> {+    // For "raw" we want the most recent RAW take. That's the+    // `finalize_path` from state (e.g. output-take-1.webm) if a take+    // has been finalized this session, falling back to the canonical+    // `cfg.output` only when it already exists on disk from a previous+    // save-final. For "preview" we want the auto-leveled version —+    // `preview_path` if set, same fallback otherwise.+    let source = {+        let s = state.lock().unwrap();+        match which {+            "raw" => s+                .finalize_path+                .clone()+                .unwrap_or_else(|| output.to_path_buf()),+            _ => s+                .preview_path+                .clone()+                .or_else(|| s.finalize_path.clone())+                .unwrap_or_else(|| output.to_path_buf()),+        }+    };+    if !source.exists() {+        let _ = request.respond(text_response(404, "audio file not found"));+        return Ok(());+    }+    let tmp_png = std::env::temp_dir().join(format!("adom-video-post-waveform-{}.png", which));+    let result = Command::new("ffmpeg")+        .args(["-y", "-i"])+        .arg(&source)+        .args([+            "-filter_complex",+            "showwavespic=s=1024x144:colors=0x00b8b0|0x8C6BF7:split_channels=0",+            "-frames:v", "1",+        ])+        .arg(&tmp_png)+        .output();+    match result {+        Ok(out) if out.status.success() => {+            let bytes = match std::fs::read(&tmp_png) {+                Ok(b) => b,+                Err(e) => {+                    let _ = request.respond(text_response(500, &format!("waveform read failed: {}", e)));+                    return Ok(());+                }+            };+            let resp = Response::from_data(bytes).with_header(+                Header::from_bytes(&b"Content-Type"[..], &b"image/png"[..]).unwrap(),+            );+            let _ = request.respond(resp);+        }+        Ok(out) => {+            let stderr = String::from_utf8_lossy(&out.stderr);+            let _ = request.respond(text_response(500, &format!("ffmpeg showwavespic failed: {}", stderr)));+        }+        Err(e) => {+            let _ = request.respond(text_response(500, &format!("ffmpeg spawn failed: {}", e)));+        }+    }+    Ok(())+}++fn handle_timeline(+    request: Request,+    input: &std::path::Path,+    markers_file: Option<&std::path::Path>,+) -> Result<(), String> {+    let markers_path = match markers_file {+        Some(p) => p,+        None => {+            let _ = request.respond(json_response(+                200,+                r#"{"ok":true,"available":false,"reason":"no markers file passed to --markers"}"#,+            ));+            return Ok(());+        }+    };+    if !markers_path.exists() {+        let body = format!(+            r#"{{"ok":true,"available":false,"reason":"markers file not found: {}"}}"#,+            markers_path.display()+        );+        let _ = request.respond(json_response(200, &body));+        return Ok(());+    }++    let events = match crate::markers::read_all(markers_path) {+        Ok(ev) => ev,+        Err(e) => {+            let body = format!(+                r#"{{"ok":false,"error":"could not parse markers: {}"}}"#,+                e.replace('"', "\\\"")+            );+            let _ = request.respond(json_response(500, &body));+            return Ok(());+        }+    };+    let parsed = match crate::markers::parse(&events) {+        Ok(p) => p,+        Err(e) => {+            let body = format!(+                r#"{{"ok":false,"error":"could not parse markers: {}"}}"#,+                e.replace('"', "\\\"")+            );+            let _ = request.respond(json_response(500, &body));+            return Ok(());+        }+    };++    let probed_input_duration = match crate::ffmpeg::probe_duration(input) {+        Ok(d) => d,+        Err(e) => {+            let body = format!(+                r#"{{"ok":false,"error":"could not probe input duration: {}"}}"#,+                e.replace('"', "\\\"")+            );+            let _ = request.respond(json_response(500, &body));+            return Ok(());+        }+    };++    let mut saved_from_speedups = 0.0_f64;+    for seg in &parsed.segments {+        saved_from_speedups += seg.duration() - seg.sped_duration();+    }+    let raw_duration = probed_input_duration + saved_from_speedups;++    let mut sections: Vec<String> = Vec::new();+    let mut cursor = 0.0_f64;+    let mut speedup_count = 0usize;+    let mut original_speedup_dur = 0.0_f64;+    let mut compressed_speedup_dur = 0.0_f64;++    for seg in &parsed.segments {+        if seg.start_offset > cursor + 0.001 {+            sections.push(format!(+                r#"{{"kind":"normal","start":{:.3},"end":{:.3},"duration":{:.3}}}"#,+                cursor, seg.start_offset, seg.start_offset - cursor+            ));+        }+        let dur = seg.duration();+        let sped = seg.sped_duration();+        original_speedup_dur += dur;+        compressed_speedup_dur += sped;+        speedup_count += 1;+        let label = seg.label.replace('"', "\\\"");+        sections.push(format!(+            r#"{{"kind":"speedup","start":{:.3},"end":{:.3},"duration":{:.3},"speed":{},"label":"{}","compressedDuration":{:.3}}}"#,+            seg.start_offset, seg.end_offset, dur, seg.speed, label, sped+        ));+        cursor = seg.end_offset;+    }+    if cursor < raw_duration - 0.001 {+        sections.push(format!(+            r#"{{"kind":"normal","start":{:.3},"end":{:.3},"duration":{:.3}}}"#,+            cursor, raw_duration, raw_duration - cursor+        ));+    }++    let normal_dur = raw_duration - original_speedup_dur;+    let output_duration = normal_dur + compressed_speedup_dur;+    let input_duration = raw_duration;+    let saved = input_duration - output_duration;+    let reduction_pct = if input_duration > 0.0 { (saved / input_duration) * 100.0 } else { 0.0 };++    let body = format!(+        r#"{{"ok":true,"available":true,"inputDuration":{:.3},"outputDuration":{:.3},"savedSeconds":{:.3},"reductionPct":{:.2},"speedupSegmentCount":{},"segments":[{}]}}"#,+        input_duration, output_duration, saved, reduction_pct, speedup_count, sections.join(",")+    );+    let _ = request.respond(json_response(200, &body));+    Ok(())+}++fn handle_adopt_latest_audio(request: Request, state: &SharedState) -> Result<(), String> {+    let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());+    let audio_dir = PathBuf::from(home).join("project").join("audio");+    let mut entries: Vec<(std::time::SystemTime, PathBuf, u64)> = Vec::new();+    if let Ok(read) = std::fs::read_dir(&audio_dir) {+        for e in read.flatten() {+            let path = e.path();+            if path.extension().and_then(|s| s.to_str()) != Some("webm") {+                continue;+            }+            if let Ok(md) = e.metadata() {+                if let Ok(mt) = md.modified() {+                    entries.push((mt, path, md.len()));+                }+            }+        }+    }+    entries.sort_by(|a, b| b.0.cmp(&a.0));+    match entries.first() {+        Some((_, path, size)) => {+            let mut s = state.lock().unwrap();+            s.audio_path = Some(path.clone());+            s.audio_size = *size;+            s.phase = "recorded".to_string();+            let body = format!(+                r#"{{"ok":true,"path":"{}","size":{}}}"#,+                path.display(),+                size+            );+            let _ = request.respond(json_response(200, &body));+        }+        None => {+            let _ = request.respond(json_response(+                404,+                &format!(r#"{{"ok":false,"error":"no audio files found in {}"}}"#, audio_dir.display()),+            ));+        }+    }+    Ok(())+}++fn handle_list_takes(+    request: Request,+    state: &SharedState,+    input: &std::path::Path,+    output: &std::path::Path,+) -> Result<(), String> {+    let parent = output.parent().unwrap_or_else(|| std::path::Path::new("."));+    let stem = output.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+    let committed_name = format!("{}.webm", stem);++    let mut takes_by_n: std::collections::BTreeMap<u32, (Option<PathBuf>, Option<PathBuf>)> = std::collections::BTreeMap::new();+    let mut legacy_norms: Vec<(PathBuf, u64)> = Vec::new();+    let mut committed: Option<PathBuf> = None;++    if let Ok(read) = std::fs::read_dir(parent) {+        for e in read.flatten() {+            let path = e.path();+            let name = match path.file_name().and_then(|n| n.to_str()) {+                Some(s) => s.to_string(),+                None => continue,+            };+            if path.extension().and_then(|s| s.to_str()) != Some("webm") { continue; }+            if !name.starts_with(&stem) { continue; }++            let take_prefix = format!("{}-take-", stem);+            if let Some(rest) = name.strip_prefix(&take_prefix) {+                let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();+                if let Ok(n) = digits.parse::<u32>() {+                    let after = &rest[digits.len()..];+                    let entry = takes_by_n.entry(n).or_insert((None, None));+                    if after == ".webm" {+                        entry.0 = Some(path);+                    } else if after == "-norm.webm" {+                        entry.1 = Some(path);+                    }+                    continue;+                }+            }+            if name.starts_with(&format!("{}-norm-", stem)) {+                if let Ok(md) = e.metadata() {+                    let mtime = md.modified().ok()+                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())+                        .map(|d| d.as_secs())+                        .unwrap_or(0);+                    legacy_norms.push((path, mtime));+                }+                continue;+            }+            if name == committed_name {+                committed = Some(path);+            }+        }+    }++    let selected = {+        let s = state.lock().unwrap();+        s.preview_path.clone().or_else(|| s.finalize_path.clone())+    };++    let mut items: Vec<String> = Vec::new();++    // 1. Original (synthetic) — points at cfg.input so the user can always+    // go back to the silent source. input is now available via self.cfg.+    let input_size = std::fs::metadata(input).map(|m| m.len()).unwrap_or(0);+    items.push(format!(+        r#"{{"label":"Original","kind":"original","path":"{}","size":{},"selected":false}}"#,+        input.display(), input_size+    ));++    for (n, (raw, norm)) in takes_by_n.iter() {+        if let Some(p) = raw {+            let is_sel = selected.as_ref().map(|s| s == p).unwrap_or(false);+            let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);+            let mtime = std::fs::metadata(p).ok()+                .and_then(|m| m.modified().ok())+                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())+                .map(|d| d.as_secs())+                .unwrap_or(0);+            items.push(format!(+                r#"{{"label":"Take {}","kind":"raw","take":{},"path":"{}","size":{},"mtime":{},"selected":{}}}"#,+                n, n, p.display(), size, mtime, is_sel+            ));+        }+        if let Some(p) = norm {+            let is_sel = selected.as_ref().map(|s| s == p).unwrap_or(false);+            let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);+            let mtime = std::fs::metadata(p).ok()+                .and_then(|m| m.modified().ok())+                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())+                .map(|d| d.as_secs())+                .unwrap_or(0);+            items.push(format!(+                r#"{{"label":"Take {} (auto-leveled)","kind":"normalized","take":{},"path":"{}","size":{},"mtime":{},"selected":{}}}"#,+                n, n, p.display(), size, mtime, is_sel+            ));+        }+    }++    if let Some(p) = &committed {+        let is_sel = selected.as_ref().map(|s| s == p).unwrap_or(false);+        let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);+        let mtime = std::fs::metadata(p).ok()+            .and_then(|m| m.modified().ok())+            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())+            .map(|d| d.as_secs())+            .unwrap_or(0);+        items.push(format!(+            r#"{{"label":"Recovered mux","kind":"committed","path":"{}","size":{},"mtime":{},"selected":{}}}"#,+            p.display(), size, mtime, is_sel+        ));+    }++    legacy_norms.sort_by(|a, b| a.1.cmp(&b.1));+    for (i, (p, mtime)) in legacy_norms.iter().enumerate() {+        let is_sel = selected.as_ref().map(|s| s == p).unwrap_or(false);+        let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);+        items.push(format!(+            r#"{{"label":"Legacy #{}","kind":"legacy","path":"{}","size":{},"mtime":{},"selected":{}}}"#,+            i + 1, p.display(), size, mtime, is_sel+        ));+    }++    let body = format!(r#"{{"ok":true,"takes":[{}]}}"#, items.join(","));+    let _ = request.respond(json_response(200, &body));+    Ok(())+}++fn handle_select_take(mut request: Request, state: &SharedState) -> Result<(), String> {+    let mut body = String::new();+    request.as_reader().read_to_string(&mut body).map_err(|e| e.to_string())?;+    let parsed: serde_json::Value = match serde_json::from_str(&body) {+        Ok(v) => v,+        Err(e) => {+            let _ = request.respond(json_response(+                400,+                &format!(r#"{{"ok":false,"error":"invalid json: {}"}}"#, e),+            ));+            return Ok(());+        }+    };+    let path_str = match parsed.get("path").and_then(|v| v.as_str()) {+        Some(s) => s.to_string(),+        None => {+            let _ = request.respond(json_response(+                400,+                r#"{"ok":false,"error":"missing 'path' field"}"#,+            ));+            return Ok(());+        }+    };+    let path = PathBuf::from(&path_str);+    if !path.exists() {+        let _ = request.respond(json_response(+            404,+            &format!(r#"{{"ok":false,"error":"file not found: {}"}}"#, path_str),+        ));+        return Ok(());+    }+    state.lock().unwrap().preview_path = Some(path);+    let _ = request.respond(json_response(200, r#"{"ok":true}"#));+    Ok(())+}++fn handle_reveal(request: Request, state: &SharedState, output: &std::path::Path) -> Result<(), String> {+    let target = {+        let s = state.lock().unwrap();+        s.preview_path.clone().unwrap_or_else(|| {+            s.finalize_path.clone().unwrap_or_else(|| output.to_path_buf())+        })+    };+    if !target.exists() {+        let _ = request.respond(json_response(+            404,+            &format!(r#"{{"ok":false,"error":"no file to reveal at {}"}}"#, target.display()),+        ));+        return Ok(());+    }+    let code_bin = "/usr/lib/code-server/lib/vscode/bin/remote-cli/code-server";+    let result = Command::new(code_bin)+        .args(["--reuse-window"])+        .arg(&target)+        .output();+    match result {+        Ok(_) => {+            let body = format!(r#"{{"ok":true,"path":"{}"}}"#, target.display());+            let _ = request.respond(json_response(200, &body));+        }+        Err(e) => {+            let body = format!(r#"{{"ok":false,"error":"code-server spawn failed: {}"}}"#, e);+            let _ = request.respond(json_response(500, &body));+        }+    }+    Ok(())+}++fn handle_revert_to_raw(request: Request, state: &SharedState) -> Result<(), String> {+    state.lock().unwrap().preview_path = None;+    let _ = request.respond(json_response(200, r#"{"ok":true}"#));+    Ok(())+}++fn handle_save_final(request: Request, state: &SharedState, output: &std::path::Path) -> Result<(), String> {+    let preview = state.lock().unwrap().preview_path.clone();+    match preview {+        Some(p) if p.exists() => {+            if let Err(e) = std::fs::copy(&p, output) {+                let body = format!(r#"{{"ok":false,"error":"save failed: {}"}}"#, e);+                let _ = request.respond(json_response(500, &body));+                return Ok(());+            }+            let body = format!(r#"{{"ok":true,"path":"{}","preservedSidecar":"{}"}}"#, output.display(), p.display());+            let _ = request.respond(json_response(200, &body));+        }+        _ => {+            let body = format!(r#"{{"ok":true,"path":"{}","note":"nothing to save, output already holds the raw mux"}}"#, output.display());+            let _ = request.respond(json_response(200, &body));+        }+    }+    Ok(())+}++fn handle_replay(request: Request, state: &SharedState, output: &std::path::Path) -> Result<(), String> {+    if !output.exists() {+        let _ = request.respond(json_response(+            404,+            &format!(r#"{{"ok":false,"error":"final file not found: {}"}}"#, output.display()),+        ));+        return Ok(());+    }+    {+        let mut s = state.lock().unwrap();+        s.phase = "done".to_string();+        s.finalize_path = Some(output.to_path_buf());+    }+    let _ = request.respond(json_response(200, r#"{"ok":true}"#));+    Ok(())+}++fn handle_discard(request: Request, state: &SharedState) -> Result<(), String> {+    println!("discarding voiceover take");+    let mut s = state.lock().unwrap();+    s.recording = false;+    s.phase = "idle".to_string();+    s.audio_path = None;+    s.audio_size = 0;+    s.finalize_path = None;+    drop(s);+    let _ = request.respond(json_response(200, r#"{"ok":true}"#));+    Ok(())+}++fn handle_get_state(request: Request, state: &SharedState) -> Result<(), String> {+    let s = state.lock().unwrap();+    let audio_path = s.audio_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default();+    let final_path = s.finalize_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default();+    let body = format!(+        r#"{{"phase":"{}","recording":{},"audioPath":"{}","audioSize":{},"finalizePath":"{}"}}"#,+        s.phase, s.recording, audio_path, s.audio_size, final_path+    );+    let _ = request.respond(json_response(200, &body));+    Ok(())+}++fn text_response(status: u16, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(body)+        .with_status_code(status)+        .with_header(+            Header::from_bytes(&b"Content-Type"[..], &b"text/plain"[..]).unwrap(),+        )+}++fn json_response(status: u16, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(body)+        .with_status_code(status)+        .with_header(+            Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),+        )+}
src/voiceover/ui.htmladded+1559
@@ -0,0 +1,1559 @@+<!DOCTYPE html>+<html>+<head>+<meta charset="utf-8">+<title>Adom Video Post-Processing</title>+<link rel="icon" type="image/svg+xml" href="favicon.svg">+<style>+@font-face {+  font-family: 'Familjen Grotesk';+  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2');+  font-weight: 700; font-display: swap;+}+@font-face {+  font-family: 'Familjen Grotesk';+  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');+  font-weight: 500; font-display: swap;+}+@font-face {+  font-family: 'Satoshi';+  src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');+  font-weight: 400; font-display: swap;+}+@font-face {+  font-family: 'Satoshi';+  src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');+  font-weight: 500; font-display: swap;+}++* { margin: 0; padding: 0; box-sizing: border-box; }++:root {+  --bg: #0d1117;+  --surface: #161b22;+  --elevated: #1c2128;+  --overlay: #21262d;+  --border: #30363d;+  --border-muted: #21262d;+  --text: #e6edf3;+  --text2: #8b949e;+  --text3: #484f58;+  --accent: #00b8b0;+  --accent-bright: #00e6dc;+  --accent-glow: rgba(0,184,177,0.2);+  --accent-tint: rgba(0,184,177,0.08);+  --purple: #8C6BF7;+  --purple-light: #C5B3FF;+  --green: #3fb950;+  --red: #f85149;+}++html, body { min-height: 100vh; }+body {+  background:+    radial-gradient(1200px 800px at 30% -20%, rgba(0,184,177,0.08) 0%, transparent 60%),+    radial-gradient(1000px 700px at 90% 110%, rgba(140,107,247,0.06) 0%, transparent 60%),+    linear-gradient(180deg, #0d1117 0%, #0a0e13 100%);+  color: var(--text);+  font-family: 'Satoshi', sans-serif;+  font-weight: 400;+  padding: 24px;+  max-width: 1100px;+  margin: 0 auto;+}++* { transition: background 0.15s, color 0.15s, border-color 0.15s, box-shadow 0.15s; }++/* Canonical Adom app-header — see gallia/skills/adom-app-header/SKILL.md.+   Single row, 44 px tall. */+header {+  height: 44px;+  margin: -16px -16px 18px;+  padding: 0 16px;+  display: flex;+  align-items: center;+  gap: 12px;+  border-bottom: 1px solid var(--border-muted);+  background: var(--surface, var(--bg));+  position: relative;+  font-family: 'Familjen Grotesk', sans-serif;+  flex-shrink: 0;+}+header .brand {+  display: flex;+  align-items: center;+  gap: 8px;+  color: var(--accent);+  font-size: 13px;+  font-weight: 600;+  letter-spacing: 0.01em;+  cursor: default;+  flex-shrink: 0;+  outline: none;+}+header .brand:hover .app-name { text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: 3px; }+header .brand svg.logo { width: 16px; height: 16px; flex-shrink: 0; }+header .brand .app-name { color: var(--accent); }+header .brand .app-subtitle { color: var(--text2); font-weight: 500; }+header .brand .sep { color: var(--text3); font-weight: 400; }+header .vsep {+  width: 1px; height: 18px;+  background: var(--border);+  flex-shrink: 0;+}+header .subject {+  display: flex; align-items: center; gap: 8px;+  min-width: 0; flex: 1 1 auto;+}+header .subject-name {+  color: var(--text);+  font-family: 'Familjen Grotesk', sans-serif;+  font-size: 14px; font-weight: 600;+  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;+  min-width: 0;+}+header .subject-meta {+  color: var(--text2);+  font-family: 'JetBrains Mono', monospace;+  font-size: 11px;+  white-space: nowrap;+  flex-shrink: 0;+}+@media (max-width: 720px) {+  header .subject-meta { display: none; }+}+header .hover-popover {+  position: absolute; top: calc(100% + 6px); left: 16px;+  background: var(--elevated); border: 1px solid var(--border);+  border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.5);+  padding: 12px 14px; min-width: 300px; max-width: 420px;+  font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text);+  z-index: 99999; opacity: 0; transform: translateY(-2px);+  pointer-events: none;+  transition: opacity 120ms ease, transform 120ms ease;+}+header .hover-popover.show { opacity: 1; transform: none; pointer-events: auto; }+header .hover-popover .popover-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 8px; }+header .hover-popover .popover-row { display: flex; gap: 12px; padding: 3px 0; align-items: baseline; }+header .hover-popover .popover-row .k { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 10px; text-transform: lowercase; width: 64px; flex-shrink: 0; }+header .hover-popover .popover-row .v { color: var(--text); font-family: 'Satoshi', sans-serif; font-size: 12px; flex: 1; word-break: break-word; }+header .hover-popover .popover-actions { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-muted); display: flex; gap: 12px; }+header .hover-popover .popover-link { color: var(--accent); text-decoration: none; font-family: 'Familjen Grotesk', sans-serif; font-size: 12px; font-weight: 600; }+header .hover-popover .popover-link:hover { text-decoration: underline; }+.subtitle {+  color: var(--text2);+  font-size: 14px;+  font-weight: 400;+  margin-bottom: 24px;+  max-width: 720px;+  line-height: 1.5;+}++/* ---------- card (frosted glass) ---------- */+.card {+  background: rgba(22,27,34,0.92);+  backdrop-filter: blur(16px);+  -webkit-backdrop-filter: blur(16px);+  border: 1px solid var(--accent-glow);+  border-radius: 12px;+  box-shadow: 0 12px 40px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.02);+  padding: 18px;+  margin-bottom: 18px;+  position: relative;+  z-index: 1;+}+/* Lift a card above its siblings whenever a tooltip inside it is+   being hovered. Without this, a tooltip anchored to a button near+   the bottom of one card gets painted underneath the next card+   (the tooltip is an ::after pseudo-element confined to the+   button's ancestor stacking context, so bumping its own z-index+   is not enough — the containing card must climb above the next+   one in document order). Relies on CSS `:has()`, supported in+   every modern browser we care about. */+.card:has([data-tooltip]:hover) {+  z-index: 1000;+}+.card .card-title {+  font-family: 'Familjen Grotesk', sans-serif;+  font-weight: 500;+  font-size: 11px;+  text-transform: uppercase;+  letter-spacing: 0.08em;+  color: var(--purple);+  margin-bottom: 12px;+}++/* ---------- video + tab strip ---------- */+.video-tabs {+  display: flex;+  gap: 4px;+  overflow-x: auto;+  overflow-y: hidden;+  padding: 6px 6px 0 6px;+  margin-bottom: -1px;+  background: transparent;+  scrollbar-width: thin;+  scrollbar-color: var(--border) transparent;+}+.video-tabs::-webkit-scrollbar { height: 6px; }+.video-tabs::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }++.video-tab {+  flex: 0 0 auto;+  background: var(--elevated);+  border: 1px solid var(--border-muted);+  border-bottom: none;+  border-radius: 8px 8px 0 0;+  padding: 8px 14px 10px 14px;+  font-family: 'Satoshi', sans-serif;+  font-size: 12px;+  font-weight: 500;+  color: var(--text2);+  cursor: pointer;+  display: flex;+  align-items: center;+  gap: 8px;+  transition: background 0.15s, color 0.15s, border-color 0.15s;+  position: relative;+}+.video-tab:hover {+  background: var(--accent-tint);+  color: var(--text);+}+.video-tab.selected {+  background: #000;+  color: var(--text);+  border-color: var(--border);+  border-bottom: 1px solid #000;+}+.video-tab.selected::after {+  content: '';+  position: absolute;+  left: 0; right: 0; top: 0;+  height: 2px;+  background: var(--accent);+  border-radius: 2px 2px 0 0;+}+.video-tab.original .dot { background: var(--text2); }+.video-tab.raw .dot { background: var(--text); }+.video-tab.normalized .dot { background: var(--purple); }+.video-tab.committed .dot { background: var(--accent-bright); }+.video-tab.legacy .dot { background: var(--text3); }+.video-tab .dot {+  width: 7px; height: 7px; border-radius: 50%;+  flex: 0 0 7px;+}+.video-tab.selected.normalized::after { background: var(--purple); }+.video-tab.selected.committed::after { background: var(--accent-bright); }++.video-wrap {+  background: #000;+  border-radius: 0 10px 10px 10px;+  overflow: hidden;+  border: 1px solid var(--border);+  position: relative;+}+video { display: block; width: 100%; max-height: 480px; }+.now-playing {+  position: absolute;+  top: 10px; left: 10px;+  display: flex;+  align-items: center;+  gap: 6px;+  background: rgba(13,17,23,0.85);+  backdrop-filter: blur(12px);+  -webkit-backdrop-filter: blur(12px);+  border: 1px solid var(--border);+  border-radius: 999px;+  padding: 6px 12px 6px 10px;+  font-family: 'Satoshi', sans-serif;+  font-size: 11px;+  font-weight: 500;+  color: var(--text);+  pointer-events: none;+  z-index: 5;+}+.now-playing .dot {+  width: 8px; height: 8px; border-radius: 50%;+  background: var(--text3);+}+.now-playing.source .dot { background: var(--text3); }+.now-playing.raw .dot { background: var(--text); }+.now-playing.preview .dot { background: var(--purple); box-shadow: 0 0 8px rgba(140,107,247,0.6); }+.now-playing .kind { font-weight: 500; }+.now-playing.preview { border-color: rgba(140,107,247,0.3); }++/* ---------- countdown overlay ---------- */+.countdown {+  position: absolute; inset: 0;+  display: none;+  align-items: center; justify-content: center;+  background: rgba(0,0,0,0.85);+  color: var(--accent-bright);+  font-size: 220px;+  font-weight: 700;+  font-family: 'Familjen Grotesk', sans-serif;+  text-shadow: 0 0 40px rgba(0,184,177,0.6);+  pointer-events: none;+  z-index: 10;+}+.countdown.visible { display: flex; }++/* Wrap-up countdown: small pill in the top-right of the video during+   the last 5 seconds of playback while recording, so the narrator+   knows to say "thanks for watching" on beat. */+.wrap-countdown {+  position: absolute;+  top: 10px; right: 10px;+  display: none;+  align-items: center;+  gap: 8px;+  background: rgba(248,81,73,0.92);+  backdrop-filter: blur(12px);+  -webkit-backdrop-filter: blur(12px);+  border: 1px solid rgba(255,255,255,0.15);+  border-radius: 999px;+  padding: 8px 14px 8px 12px;+  font-family: 'Satoshi', sans-serif;+  font-size: 12px;+  font-weight: 500;+  color: #fff;+  pointer-events: none;+  z-index: 6;+  box-shadow: 0 6px 18px rgba(248,81,73,0.4);+}+.wrap-countdown.visible { display: flex; }+.wrap-countdown .big {+  font-family: 'Familjen Grotesk', sans-serif;+  font-weight: 700;+  font-size: 18px;+  min-width: 14px;+  text-align: center;+}++/* ---------- controls ---------- */+.controls {+  display: flex;+  gap: 10px;+  flex-wrap: wrap;+  align-items: center;+}+.controls + .controls { margin-top: 12px; }++button {+  background: var(--elevated);+  color: var(--text);+  border: 1px solid var(--border);+  border-radius: 8px;+  padding: 10px 18px;+  font-family: 'Satoshi', sans-serif;+  font-size: 14px;+  font-weight: 500;+  cursor: pointer;+}+button:hover:not(:disabled) {+  border-color: var(--accent);+  color: var(--accent-bright);+  background: var(--accent-tint);+}+button:disabled { opacity: 0.35; cursor: not-allowed; }++.btn-record {+  background: var(--red);+  color: #fff;+  border-color: var(--red);+  padding: 12px 22px;+  font-weight: 500;+  box-shadow: 0 4px 14px rgba(248,81,73,0.25);+}+.btn-record:hover:not(:disabled) {+  background: #ff5a52;+  border-color: #ff5a52;+  color: #fff;+  box-shadow: 0 6px 18px rgba(248,81,73,0.4);+}+.btn-record.recording {+  background: var(--elevated);+  color: var(--red);+  border-color: var(--red);+  box-shadow: 0 0 0 3px rgba(248,81,73,0.15);+  animation: pulse 1.6s ease-in-out infinite;+}+.btn-record.recording::before { content: '⏹ '; }+@keyframes pulse { 0%, 100% { box-shadow: 0 0 0 3px rgba(248,81,73,0.15); } 50% { box-shadow: 0 0 0 6px rgba(248,81,73,0.35); } }++.btn-finalize {+  background: var(--accent);+  color: var(--bg);+  border-color: var(--accent);+  font-weight: 500;+  box-shadow: 0 4px 14px rgba(0,184,177,0.3);+}+.btn-finalize:hover:not(:disabled) {+  background: var(--accent-bright);+  border-color: var(--accent-bright);+  /* Explicit color because the generic `button:hover` rule sets color+     to --accent-bright, which would match the hover background and+     make the label invisible. */+  color: var(--bg);+  box-shadow: 0 6px 18px rgba(0,184,177,0.45);+}++.btn-normalize {+  background: var(--purple);+  color: #fff;+  border-color: var(--purple);+  font-weight: 500;+  box-shadow: 0 4px 14px rgba(140,107,247,0.3);+}+.btn-normalize:hover:not(:disabled) {+  background: var(--purple-light);+  border-color: var(--purple-light);+  color: var(--bg);+  box-shadow: 0 6px 18px rgba(140,107,247,0.45);+}+.btn-normalize.working {+  background: var(--elevated);+  color: var(--purple);+  border-color: var(--purple);+}++.btn-save {+  background: var(--accent);+  color: var(--bg);+  border-color: var(--accent);+  font-weight: 500;+  box-shadow: 0 4px 14px rgba(0,184,177,0.3);+}+.btn-save:hover:not(:disabled) {+  background: var(--accent-bright);+  border-color: var(--accent-bright);+  /* Same fix as .btn-finalize:hover — generic button:hover forces+     color to --accent-bright, which would make the label invisible+     against the matching hover background. */+  color: var(--bg);+  box-shadow: 0 6px 18px rgba(0,184,177,0.45);+}++.timer {+  font-family: 'JetBrains Mono', monospace;+  font-size: 14px;+  color: var(--text3);+  padding: 0 8px;+}+.timer.recording { color: var(--red); font-weight: 500; }++/* ---------- tooltips ----------+   Hover-delayed frosted-glass tooltips that show only after ~500ms,+   so they don't pop in every time the pointer moves across the UI.+   Per app-creator skill: every button (and any non-obvious label) needs+   a tooltip, and the tooltip should be gentle (delay, smooth fade).+   Works on any element with a data-tooltip attribute, not just buttons.++   Positioning rule: the tooltip anchors its LEFT edge to the trigger's+   LEFT edge and grows rightward. The max-width is clamped to the+   viewport so it can never clip on the right side. For triggers near+   the right edge of the viewport, add data-tooltip-align="right" so+   the tooltip anchors its RIGHT edge to the trigger's right edge+   instead and grows leftward. */+[data-tooltip] { position: relative; }+[data-tooltip]::after {+  content: attr(data-tooltip);+  position: absolute;+  bottom: calc(100% + 8px);+  left: 0;+  right: auto;+  background: rgba(22,27,34,0.96);+  backdrop-filter: blur(16px);+  -webkit-backdrop-filter: blur(16px);+  border: 1px solid var(--accent-glow);+  border-radius: 10px;+  padding: 12px 16px;+  font-family: 'Satoshi', sans-serif;+  font-size: 12px;+  font-weight: 400;+  line-height: 1.55;+  color: var(--text);+  text-align: left;+  width: max-content;+  max-width: min(360px, calc(100vw - 48px));+  white-space: pre-wrap;+  pointer-events: none;+  opacity: 0;+  transform: translateY(4px);+  box-shadow: 0 12px 40px rgba(0,0,0,0.55);+  transition: opacity 0.2s ease 0s, transform 0.2s ease 0s;+  z-index: 100;+}+[data-tooltip][data-tooltip-align="right"]::after {+  left: auto;+  right: 0;+}+/* Flip tooltip to appear BELOW the trigger when there isn't enough+   space above (for buttons near the bottom of the card/viewport, or+   rows whose tooltips are naturally tall and would clip off the top). */+[data-tooltip][data-tooltip-placement="below"]::after {+  bottom: auto;+  top: calc(100% + 8px);+  transform: translateY(-4px);+}+[data-tooltip][data-tooltip-placement="below"]:hover::after {+  transform: translateY(0);+}+/* All buttons in the post-production controls row live at the bottom+   of the card and always get tall tooltips. Default them to below. */+#postControls [data-tooltip]::after {+  bottom: auto;+  top: calc(100% + 8px);+  transform: translateY(-4px);+}+#postControls [data-tooltip]:hover::after {+  transform: translateY(0);+}+[data-tooltip]:hover::after {+  opacity: 1;+  transform: translateY(0);+  /* 500ms delay before the tooltip fades in. A moved pointer that+     doesn't linger never triggers it, so it's not noisy. */+  transition: opacity 0.2s ease 0.5s, transform 0.2s ease 0.5s;+}+/* Help cursor when a span has a tooltip, so users know to hover it. */+span[data-tooltip] { cursor: help; text-decoration: underline dotted var(--text3); text-underline-offset: 3px; }++/* ---------- status card ---------- */+.status {+  background: rgba(13,17,23,0.5);+  border: 1px solid var(--border-muted);+  border-radius: 8px;+  padding: 14px 18px;+  font-family: 'JetBrains Mono', monospace;+  font-size: 12px;+  line-height: 1.6;+  color: var(--text2);+  min-height: 52px;+  white-space: pre-wrap;+}+.status.success { border-color: var(--accent-glow); color: var(--accent-bright); background: rgba(0,184,177,0.05); }+.status.error { border-color: var(--red); color: var(--red); background: rgba(248,81,73,0.05); }++/* ---------- waveform chart ---------- */+.waveform-card { padding: 14px 18px; }+.waveform-row {+  display: flex;+  flex-direction: column;+  gap: 12px;+}+.waveform-block { display: flex; flex-direction: column; gap: 6px; }+.waveform-label {+  display: flex;+  justify-content: space-between;+  align-items: baseline;+  font-family: 'Satoshi', sans-serif;+  font-weight: 500;+  font-size: 11px;+  text-transform: uppercase;+  letter-spacing: 0.06em;+  color: var(--text2);+}+.waveform-label .meta {+  font-family: 'JetBrains Mono', monospace;+  font-size: 11px;+  color: var(--text3);+  text-transform: none;+  letter-spacing: 0;+}+.waveform-img-wrap {+  background: var(--bg);+  border: 1px solid var(--border-muted);+  border-radius: 6px;+  overflow: hidden;+  height: 72px;+  display: flex;+  align-items: center;+  justify-content: center;+}+.waveform-img-wrap img {+  width: 100%;+  height: 100%;+  display: block;+}+.waveform-img-wrap.empty {+  color: var(--text3);+  font-size: 11px;+  font-family: 'JetBrains Mono', monospace;+}++/* ---------- speedup timeline ---------- */+.timeline-summary {+  display: flex;+  align-items: baseline;+  gap: 10px;+  font-family: 'Satoshi', sans-serif;+  font-size: 12px;+  color: var(--text2);+  margin-bottom: 18px;+  flex-wrap: wrap;+}+.timeline-summary .stat-label {+  color: var(--text3);+  text-transform: uppercase;+  letter-spacing: 0.06em;+  font-size: 10px;+  font-weight: 500;+}+.timeline-summary .stat-val {+  color: var(--text);+  font-family: 'JetBrains Mono', monospace;+  font-size: 13px;+  font-weight: 500;+}+.timeline-summary .stat-saved {+  color: var(--accent-bright);+  font-family: 'JetBrains Mono', monospace;+  font-size: 13px;+  font-weight: 500;+}+.timeline-summary .stat-sep { color: var(--text3); }+.timeline-track-wrap {+  display: flex;+  flex-direction: column;+  gap: 6px;+}+.timeline-track-label {+  font-family: 'Satoshi', sans-serif;+  font-size: 10px;+  font-weight: 500;+  text-transform: uppercase;+  letter-spacing: 0.06em;+  color: var(--text3);+  margin-top: 6px;+}+.timeline-track {+  display: flex;+  height: 40px;+  background: var(--bg);+  border: 1px solid var(--border-muted);+  border-radius: 6px;+  overflow: hidden;+}+.timeline-seg {+  display: flex;+  align-items: center;+  justify-content: center;+  font-family: 'Satoshi', sans-serif;+  font-size: 10px;+  font-weight: 500;+  color: var(--text);+  overflow: hidden;+  white-space: nowrap;+  text-overflow: ellipsis;+  padding: 0 6px;+  position: relative;+}+.timeline-seg.normal {+  background: var(--elevated);+  color: var(--text2);+  border-right: 1px solid var(--border-muted);+}+.timeline-seg.normal:last-child { border-right: none; }+.timeline-seg.speedup {+  background: linear-gradient(180deg, rgba(140,107,247,0.9) 0%, rgba(140,107,247,0.7) 100%);+  color: #fff;+  border-right: 1px solid rgba(0,0,0,0.2);+  text-shadow: 0 1px 2px rgba(0,0,0,0.4);+}+.timeline-seg.speedup:last-child { border-right: none; }++/* ---------- takes history ---------- */+.takes-subtitle {+  color: var(--text2);+  font-size: 12px;+  line-height: 1.5;+  margin-bottom: 14px;+  max-width: 640px;+}+.takes-list {+  display: flex;+  flex-direction: column;+  gap: 6px;+}+.takes-row {+  display: grid;+  grid-template-columns: 100px 1fr 90px 130px 90px;+  align-items: center;+  gap: 12px;+  padding: 10px 14px;+  background: var(--elevated);+  border: 1px solid var(--border-muted);+  border-radius: 8px;+  cursor: pointer;+  transition: all 0.15s;+}+.takes-row:hover {+  border-color: var(--accent-glow);+  background: var(--accent-tint);+}+.takes-row.selected {+  border-color: var(--accent);+  background: rgba(0,184,177,0.08);+  box-shadow: 0 0 0 2px rgba(0,184,177,0.12);+}+.takes-row .kind-pill {+  font-family: 'Familjen Grotesk', sans-serif;+  font-weight: 500;+  font-size: 10px;+  text-transform: uppercase;+  letter-spacing: 0.05em;+  text-align: center;+  padding: 5px 10px;+  border-radius: 999px;+  border: 1px solid var(--border);+  background: var(--bg);+}+.takes-row .kind-pill.committed { border-color: var(--accent); color: var(--accent-bright); }+.takes-row .kind-pill.normalized { border-color: var(--purple); color: var(--purple-light); }+.takes-row .kind-pill.take { border-color: var(--border); color: var(--text); }+.takes-row .path {+  font-family: 'JetBrains Mono', monospace;+  font-size: 11px;+  color: var(--text);+  overflow: hidden;+  text-overflow: ellipsis;+  white-space: nowrap;+}+.takes-row .size, .takes-row .mtime {+  font-family: 'JetBrains Mono', monospace;+  font-size: 11px;+  color: var(--text2);+  text-align: right;+}+.takes-row .status-badge {+  font-family: 'Satoshi', sans-serif;+  font-size: 11px;+  font-weight: 500;+  color: var(--accent);+  text-align: right;+}+.takes-row:not(.selected) .status-badge { visibility: hidden; }++/* ---------- tip callout ---------- */+.tip {+  color: var(--text2);+  font-size: 12px;+  line-height: 1.6;+  padding: 12px 16px 12px 18px;+  border-left: 2px solid var(--accent);+  background: var(--accent-tint);+  border-radius: 0 8px 8px 0;+  margin-top: 18px;+}+.tip strong { color: var(--accent-bright); font-weight: 500; }+</style>+</head>+<body>+<!-- Canonical Adom app-header — see gallia/skills/adom-app-header. -->+<header>+  <div class="brand" data-tooltip-target="app-info" tabindex="0">+    <svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">+      <path d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4zm2 2v2h2V6H5zm4 0v12h6V6H9zm8 0v2h2V6h-2zM5 10v2h2v-2H5zm12 0v2h2v-2h-2zM5 14v2h2v-2H5zm12 0v2h2v-2h-2zM5 18v0h2v0H5zm12 0v0h2v0h-2zM11 8v8h2V8h-2z"/>+    </svg>+    <span class="app-name">adom-video-post</span>+    <span class="sep">·</span>+    <span class="app-subtitle">voiceover</span>+  </div>+  <div class="vsep" aria-hidden="true"></div>+  <div class="subject">+    <span class="subject-name">Voiceover</span>+    <span class="subject-meta">Narrate the sped-up demo · save the final cut</span>+  </div>+  <div class="hover-popover" id="app-info" role="dialog" aria-label="App information">+    <div class="popover-title">adom-video-post · voiceover</div>+    <div class="popover-row"><span class="k">about</span>+      <span class="v">Mic-driven narration over the sped-up demo. 3-2-1 countdown, then talk while the video plays.</span></div>+    <div class="popover-row"><span class="k">version</span><span class="v">v0.3.0</span></div>+    <div class="popover-row"><span class="k">author</span><span class="v">Adom Inc · adom.inc</span></div>+    <div class="popover-row"><span class="k">tts</span>+      <span class="v"><a class="popover-link" href="https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-tts" target="_blank" rel="noopener">adom-tts ↗</a> — for AI narration instead of mic, run `adom-tts say "<text>"`. Pronunciation overrides for Adom terms (KiCad, adom-tsci, etc.) apply uniformly.</span></div>+    <div class="popover-actions">+      <a class="popover-link" href="https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-video-post" target="_blank" rel="noopener">Open wiki ↗</a>+      <a class="popover-link" href="https://github.com/adom-inc/adom-video-post" target="_blank" rel="noopener">GitHub ↗</a>+    </div>+  </div>+</header>+<script>+// SHOW_DELAY = 500 ms per human-ui-patterns §1b; HIDE_GRACE = 220 ms.+(function attachHeaderPopovers(){+  const SHOW_DELAY = 500;+  const HIDE_GRACE = 220;+  const wire = () => {+    document.querySelectorAll('header [data-tooltip-target]').forEach(trigger => {+      if (trigger.__popoverWired) return;+      trigger.__popoverWired = true;+      const target = document.getElementById(trigger.dataset.tooltipTarget);+      if (!target) return;+      let showTimer = null, hideTimer = null;+      const show = () => {+        clearTimeout(hideTimer);+        if (target.classList.contains('show')) return;+        clearTimeout(showTimer);+        showTimer = setTimeout(() => target.classList.add('show'), SHOW_DELAY);+      };+      const queueHide = () => {+        clearTimeout(showTimer);+        clearTimeout(hideTimer);+        hideTimer = setTimeout(() => target.classList.remove('show'), HIDE_GRACE);+      };+      trigger.addEventListener('mouseenter', show);+      trigger.addEventListener('mouseleave', queueHide);+      target.addEventListener('mouseenter', show);+      target.addEventListener('mouseleave', queueHide);+      trigger.addEventListener('click', e => {+        clearTimeout(showTimer);+        if (target.classList.contains('show')) target.classList.remove('show');+        else target.classList.add('show');+        e.stopPropagation();+      });+    });+  };+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);+  else wire();+})();+</script>+<div class="subtitle">Hydrogen captures the mic directly, no iframe permission needed. Click Record voiceover to get a 3-2-1 countdown, then start speaking when the video plays.</div>++<div id="timelineCard" class="card" style="display:none;">+  <div class="card-title">Speedup Timeline</div>+  <div class="timeline-summary" id="timelineSummary">+    <span class="stat-label">Source</span> <span class="stat-val" id="tlInputDur">--</span>+    <span class="stat-sep">→</span>+    <span class="stat-label">After speedup</span> <span class="stat-val" id="tlOutputDur">--</span>+    <span class="stat-sep">·</span>+    <span class="stat-saved" id="tlSaved">--</span>+    <span class="stat-sep">·</span>+    <span class="stat-label">Speedup segments</span> <span class="stat-val" id="tlCount">--</span>+  </div>+  <div class="timeline-track-wrap">+    <div class="timeline-track-label">Source video</div>+    <div class="timeline-track" id="timelineSource"></div>+    <div class="timeline-track-label">After speedup</div>+    <div class="timeline-track" id="timelineOutput"></div>+  </div>+</div>++<div class="card">+  <div id="videoTabs" class="video-tabs"></div>+  <div class="video-wrap">+    <video id="vid" controls preload="auto" src="video"></video>+    <div id="countdown" class="countdown"></div>+    <div id="wrapCountdown" class="wrap-countdown" data-tooltip="Appears during the final five seconds of playback while recording, so you have time to wrap up your narration with something like 'thanks for watching' or 'that's all for today'. When the video ends, the recording automatically stops so you never get silence tacked on the end.">+      <span>Wrap up in</span>+      <span class="big" id="wrapCountdownNum">5</span>+    </div>+  </div>++  <div class="controls" style="margin-top:14px;">+    <button id="btnRecord" class="btn-record" data-tooltip="Start a fresh voiceover take. You will get a 3-2-1 caption countdown, then the source video plays from the top (silent) while Hydrogen captures your mic. The previous takes stay as tabs above the video so you can come back to them any time.">Record voiceover</button>+    <span id="timer" class="timer">00:00</span>+    <button id="btnDiscard" disabled data-tooltip="Throw away the current in-progress recording before it gets turned into a take. The audio file on disk is preserved; this only clears the app's in-memory state so the next record is a fresh start.">Discard &amp; redo</button>+    <button id="btnFinalize" class="btn-finalize" disabled data-tooltip="Mux the captured audio with the sped-up video into Take N, then automatically run loudness normalization (EBU R128) to produce Take N (auto-leveled) as well. Both appear as new tabs above the video and the auto-leveled one plays back first.">Finalize &amp; mux</button>+  </div>++  <div id="postControls" class="controls" style="display:none; margin-top:10px;">+    <button id="btnReveal" data-tooltip-align="right" data-tooltip="Open the currently playing file in VS Code. The file explorer sidebar will auto-scroll to reveal it so you can see exactly where on disk this version lives.">Reveal in VS Code</button>+    <button id="btnSaveFinal" class="btn-save" data-tooltip-align="right" data-tooltip="Copy the currently selected tab (whichever version is playing) over the canonical output path. The tab files themselves stay on disk untouched so nothing is destroyed; this just sets the 'canonical final' slot to whichever version you picked.">Save as final</button>+  </div>+</div>++<div id="wavCard" class="card waveform-card" style="display:none;">+  <div class="card-title">Audio waveform</div>+  <div class="waveform-row">+    <div class="waveform-block">+      <div class="waveform-label">+        <span>Original mux</span>+        <span class="meta" id="wavOrigMeta">--</span>+      </div>+      <div id="wavOrigWrap" class="waveform-img-wrap empty">generating...</div>+    </div>+    <div id="wavNormBlock" class="waveform-block" style="display:none;">+      <div class="waveform-label">+        <span>After auto-level</span>+        <span class="meta" id="wavNormMeta">--</span>+      </div>+      <div id="wavNormWrap" class="waveform-img-wrap empty">generating...</div>+    </div>+  </div>+</div>++<div id="takesCard" class="card" style="display:none;">+  <div class="card-title">Takes history</div>+  <div class="takes-subtitle">Every mux and every auto-level pass is preserved on disk. Click a row to load and play it; Save as final will then commit whichever one you picked.</div>+  <div id="takesList" class="takes-list"></div>+</div>++<div id="status" class="status">Click Record voiceover to start. You will get a 3-2-1 countdown, then the video plays from the top while Hydrogen captures your mic.</div>++<div class="tip"><strong>Tip:</strong> If you mess up, click Discard &amp; redo to start fresh. After finalize, the muxed video auto-plays so you can hear the result. Click Auto-level audio to normalize loudness to EBU R128 broadcast standard (-16 LUFS target, -1.5 dBTP peak ceiling), then Save as final to commit the normalized version.</div>++<!-- EVAL_CLIENT -->+<script>+// Expose voiceover state to the /eval hot-patch channel so snippets+// can inspect current phase, current take, etc. via `ctx.*`.+window.__appCtx = window.__appCtx || {};+window.__appCtx.subcommand = 'voiceover';+</script>+<script>+// Forward UI console output to the server so the AI can read it via GET /console.+// Per app-creator skill: webview apps must surface their JS logs to the server.+(function() {+  const orig = { log: console.log, warn: console.warn, error: console.error };+  function forward(level, args) {+    orig[level].apply(console, args);+    try {+      // Use a relative URL (no leading slash) so it resolves under the+      // Coder /proxy/<port>/ prefix when running inside the Hydrogen webview.+      // A leading slash would hit hydrogen.adom.inc/console instead.+      fetch('console', {+        method: 'POST',+        headers: {'Content-Type':'application/json'},+        body: JSON.stringify({+          level,+          ts: Date.now(),+          message: Array.prototype.slice.call(args).map(a => {+            try { return typeof a === 'string' ? a : JSON.stringify(a); }+            catch { return String(a); }+          }).join(' ')+        })+      }).catch(() => {});+    } catch {}+  }+  console.log = function() { forward('log', arguments); };+  console.warn = function() { forward('warn', arguments); };+  console.error = function() { forward('error', arguments); };+  window.addEventListener('error', e => forward('error', [e.message + ' at ' + (e.filename||'?') + ':' + (e.lineno||'?')]));+  window.addEventListener('unhandledrejection', e => forward('error', ['unhandledrejection: ' + (e.reason && e.reason.message || e.reason)]));+  console.log('voiceover UI loaded');++  // Count how many iframe layers we're nested inside. Cross-origin blocks+  // reading the parent's DOM, but walking the window proxy chain (==/!==+  // comparisons and .parent traversal) is allowed in every browser. This+  // tells us exactly how many postMessage hops a MediaStreamTrack transfer+  // would need to survive to reach us from Hydrogen's top window.+  try {+    let depth = 0;+    let w = window;+    while (w !== w.top) {+      depth++;+      w = w.parent;+      if (depth > 10) break; // safety+    }+    const atTop = (window === window.top);+    const parentIsTop = (window.parent === window.top);+    console.log('iframe depth:', depth, '| atTop:', atTop, '| parentIsTop:', parentIsTop);+  } catch (e) {+    console.warn('iframe depth probe failed:', e.message);+  }+})();++const vid = document.getElementById('vid');+const videoTabs = document.getElementById('videoTabs');+const wrapCountdownEl = document.getElementById('wrapCountdown');+const wrapCountdownNum = document.getElementById('wrapCountdownNum');++// Install video listeners once. They only DO anything when phase=recording,+// so it is safe to leave them attached across the full app lifecycle.+vid.addEventListener('timeupdate', () => {+  if (lastPhase !== 'recording') {+    wrapCountdownEl.classList.remove('visible');+    return;+  }+  const duration = isFinite(vid.duration) ? vid.duration : 0;+  if (!duration) return;+  const remaining = duration - vid.currentTime;+  if (remaining <= 5 && remaining > 0) {+    wrapCountdownEl.classList.add('visible');+    wrapCountdownNum.textContent = String(Math.max(1, Math.ceil(remaining)));+  } else {+    wrapCountdownEl.classList.remove('visible');+  }+});+vid.addEventListener('ended', () => {+  if (lastPhase === 'recording') {+    console.log('video ended while recording, auto-stopping');+    wrapCountdownEl.classList.remove('visible');+    stopRecording();+  }+});++// The active video tab IS the "currently playing" indicator now.+// This function stays as a no-op so older call sites keep working.+function setNowPlaying(_kind) {}+const btnRecord = document.getElementById('btnRecord');+const btnDiscard = document.getElementById('btnDiscard');+const btnFinalize = document.getElementById('btnFinalize');+const btnSaveFinal = document.getElementById('btnSaveFinal');+const btnReveal = document.getElementById('btnReveal');+const postControls = document.getElementById('postControls');+const takesCard = document.getElementById('takesCard');+const takesList = document.getElementById('takesList');+const timerEl = document.getElementById('timer');+const status = document.getElementById('status');+const countdownEl = document.getElementById('countdown');++let lastPhase = null;+let recordStartTime = 0;+let timerInterval = null;++function setStatus(msg, kind) {+  status.textContent = msg;+  status.className = 'status' + (kind ? ' ' + kind : '');+}++function fmtDuration(ms) {+  const s = Math.floor(ms / 1000);+  const m = Math.floor(s / 60);+  const sec = s % 60;+  return String(m).padStart(2,'0') + ':' + String(sec).padStart(2,'0');+}++// --- state-driven UI ----------------------------------------------------+// Server phase is the single source of truth. The UI polls /state every+// 400ms and reacts to transitions. Any AI with curl can trigger the same+// transitions by POSTing to the endpoints, and the UI will animate the+// video, buttons, and timer identically. This is the "2-way comms" part+// of the app-creator pattern: AI controls frontend via server state.+function applyPhase(phase) {+  if (phase === lastPhase) return;+  console.log('phase transition:', lastPhase, '->', phase);+  lastPhase = phase;+  if (phase === 'idle') {+    btnRecord.disabled = false;+    btnRecord.classList.remove('recording');+    btnRecord.textContent = 'Record voiceover';+    btnDiscard.disabled = true;+    btnFinalize.disabled = true;+    postControls.style.display = 'none';+    vid.pause();+    vid.currentTime = 0;+    if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }+    timerEl.classList.remove('recording');+    timerEl.textContent = '00:00';+    setStatus('Click Record voiceover to start. Get a 3-2-1 countdown, then the video plays from the top while Hydrogen captures your mic.');+  } else if (phase === 'recording') {+    btnRecord.disabled = false;+    btnRecord.classList.add('recording');+    btnRecord.textContent = 'Stop';+    btnDiscard.disabled = true;+    btnFinalize.disabled = true;+    postControls.style.display = 'none';+    // Swap the player back to the source video (silent) so the user+    // narrates against the raw input, NOT against a previously-finalized+    // mux which would contain their earlier voice. This is the key+    // detail that makes re-recording safe: no double voices.+    if (vid.src.indexOf('final-video') !== -1 || !vid.src.endsWith('video')) {+      vid.src = 'video?t=' + Date.now();+      vid.load();+    }+    setNowPlaying('source');+    vid.currentTime = 0;+    vid.play().catch(e => console.warn('video play failed:', e.message));+    recordStartTime = Date.now();+    timerEl.classList.add('recording');+    if (timerInterval) clearInterval(timerInterval);+    timerInterval = setInterval(() => {+      timerEl.textContent = fmtDuration(Date.now() - recordStartTime);+    }, 200);+    setStatus('Recording... narrate over the video. Click Stop to finish.');+  } else if (phase === 'recorded') {+    btnRecord.disabled = false;+    btnRecord.classList.remove('recording');+    btnRecord.textContent = 'Record voiceover';+    btnDiscard.disabled = false;+    btnFinalize.disabled = false;+    vid.pause();+    if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }+    timerEl.classList.remove('recording');+    setStatus('Recorded. Click Finalize to mux with the video, or Discard to redo.');+  } else if (phase === 'finalizing') {+    btnRecord.disabled = true;+    btnDiscard.disabled = true;+    btnFinalize.disabled = true;+    setStatus('Running ffmpeg mux...');+  } else if (phase === 'done') {+    // Keep Record and Discard enabled so the user can start a new take+    // at any time if they don't like the current result. Previous takes+    // and normalize sidecars stay on disk untouched.+    btnRecord.disabled = false;+    btnRecord.classList.remove('recording');+    btnRecord.textContent = 'Record voiceover';+    btnDiscard.disabled = false;+    btnFinalize.disabled = true;+    postControls.style.display = 'flex';+    btnSaveFinal.disabled = false;+    loadWaveform('raw');+    loadTakes();+    // Fetch the final path from the server and auto-play the muxed result+    // so the user can immediately hear their voiceover over the sped-up+    // video. Bust the browser's cache on the video element by appending+    // a timestamp, otherwise a prior /final-video fetch will be reused.+    (async () => {+      try {+        const s = await (await fetch('state')).json();+        const finalPath = s.finalizePath || '(unknown path)';+        setStatus('Final narrated video saved to:\n  ' + finalPath + '\n\nPlaying the raw mux now. Click Auto-level audio to normalize loudness (EBU R128), or Save as final if you are happy with it.', 'success');+        vid.src = 'final-video?t=' + Date.now();+        vid.load();+        setNowPlaying('raw');+        vid.currentTime = 0;+        vid.play().catch(e => console.warn('final video autoplay failed:', e.message));+      } catch (e) {+        setStatus('Done, but could not read final state: ' + e.message, 'error');+      }+    })();+  } else if (phase === 'error') {+    btnRecord.disabled = false;+    btnDiscard.disabled = false;+    btnFinalize.disabled = false;+    setStatus('An error occurred. Check the server logs.', 'error');+  }+}++// --- speedup timeline ------------------------------------------------+// Renders a visual of the source video with speedup regions highlighted.+// Drives from GET /timeline which parses the markers file passed on+// the CLI. Two bars are drawn: the source timeline (full input duration)+// and the after-speedup timeline (compressed). Both bars are stretched+// to the SAME width so the difference between bar segments visually+// shows how much shorter the output is.+const timelineCard = document.getElementById('timelineCard');+const tlInputDur = document.getElementById('tlInputDur');+const tlOutputDur = document.getElementById('tlOutputDur');+const tlSaved = document.getElementById('tlSaved');+const tlCount = document.getElementById('tlCount');+const timelineSource = document.getElementById('timelineSource');+const timelineOutput = document.getElementById('timelineOutput');++function fmtDurHms(s) {+  if (!isFinite(s) || s < 0) return '--';+  const m = Math.floor(s / 60);+  const sec = s - m * 60;+  if (m > 0) return m + 'm ' + sec.toFixed(1) + 's';+  return sec.toFixed(1) + 's';+}++function tlSegTooltip(seg) {+  if (seg.kind === 'speedup') {+    return [+      seg.label + ' (' + seg.speed + 'x speedup)',+      '',+      'ORIGINAL  ' + fmtDurHms(seg.duration) + '  (' + seg.start.toFixed(1) + 's to ' + seg.end.toFixed(1) + 's)',+      'AFTER     ' + fmtDurHms(seg.compressedDuration),+      'SAVED     ' + fmtDurHms(seg.duration - seg.compressedDuration),+      '',+      'This stretch of the source video is played back at ' + seg.speed + 'x with a red overlay caption labeled "' + seg.speed + 'x SPEEDUP: ' + seg.label + '" on top.'+    ].join('\n');+  }+  return [+    'Normal segment',+    '',+    'FROM   ' + seg.start.toFixed(1) + 's',+    'TO     ' + seg.end.toFixed(1) + 's',+    'DUR    ' + fmtDurHms(seg.duration),+    '',+    'This stretch of the source video is played back at normal speed with no overlay caption.'+  ].join('\n');+}++async function loadTimeline() {+  try {+    const r = await fetch('timeline');+    const d = await r.json();+    if (!d.ok || !d.available) {+      timelineCard.style.display = 'none';+      return;+    }+    timelineCard.style.display = 'block';+    tlInputDur.textContent = fmtDurHms(d.inputDuration);+    tlOutputDur.textContent = fmtDurHms(d.outputDuration);+    const saved = d.savedSeconds;+    tlSaved.textContent = saved > 0+      ? ('Saved ' + fmtDurHms(saved) + ' (' + d.reductionPct.toFixed(1) + '% shorter)')+      : 'No speedup applied';+    tlCount.textContent = String(d.speedupSegmentCount || 0);++    // Render source bar with segment widths proportional to their real+    // durations. Each segment's flex-basis is its duration in seconds+    // of the input; flex-grow 0 pins the basis. Total basis = input+    // duration, so widths are proportional.+    timelineSource.innerHTML = '';+    for (const seg of d.segments) {+      const el = document.createElement('div');+      el.className = 'timeline-seg ' + seg.kind;+      el.style.flex = '0 0 ' + (seg.duration / d.inputDuration * 100) + '%';+      el.setAttribute('data-tooltip', tlSegTooltip(seg));+      if (seg.kind === 'speedup') {+        el.textContent = seg.speed + 'x';+      } else {+        el.textContent = fmtDurHms(seg.duration);+      }+      timelineSource.appendChild(el);+    }++    // Render output bar. Each segment's width is its COMPRESSED duration+    // over the total output duration, so speedup segments look tiny+    // compared to the source bar.+    timelineOutput.innerHTML = '';+    for (const seg of d.segments) {+      const el = document.createElement('div');+      el.className = 'timeline-seg ' + seg.kind;+      const effective = seg.kind === 'speedup' ? seg.compressedDuration : seg.duration;+      el.style.flex = '0 0 ' + (effective / d.outputDuration * 100) + '%';+      el.setAttribute('data-tooltip', tlSegTooltip(seg));+      if (seg.kind === 'speedup') {+        el.textContent = seg.speed + 'x';+      } else {+        el.textContent = fmtDurHms(seg.duration);+      }+      timelineOutput.appendChild(el);+    }+  } catch (e) {+    console.warn('loadTimeline failed:', e.message);+  }+}++// --- waveform chart --------------------------------------------------+// Shows the audio envelope of the current result so the user can see+// (not just hear) how loudnorm changed the levels. Raw (committed mux)+// goes in the top slot; post-normalize preview goes in the bottom slot.+const wavCard = document.getElementById('wavCard');+const wavOrigWrap = document.getElementById('wavOrigWrap');+const wavOrigMeta = document.getElementById('wavOrigMeta');+const wavNormBlock = document.getElementById('wavNormBlock');+const wavNormWrap = document.getElementById('wavNormWrap');+const wavNormMeta = document.getElementById('wavNormMeta');++const RAW_TOOLTIP = 'The raw audio exactly as Hydrogen captured it from your mic, then muxed with the sped-up video. Nothing has been changed. Because Hydrogen uses Chrome\'s default mic settings (automatic gain control on, noise suppression on), the first 1-2 seconds may be louder than the rest, and quiet moments may be slightly squashed. This is the "before" snapshot for comparison.';++const NORM_TOOLTIP = [+  'EBU R128 is a loudness standard used by broadcast TV, podcasts, Spotify, YouTube, and most streaming platforms. It measures how loud audio feels to a human, not just the peak voltage.',+  '',+  '-16 LUFS target:',+  'We re-balanced the overall loudness so the typical level of your voice sits around -16 LUFS, which is the standard target for web and podcast playback. (LUFS means Loudness Units Full Scale; more negative = quieter. -16 is a comfortable medium for headphones and laptop speakers.)',+  '',+  '-1.5 dBTP peak ceiling:',+  'We prevented any single moment from hitting the digital clipping ceiling. The loudest peaks now cap at 1.5 dB below maximum, which leaves safe headroom so nothing distorts when played back on different devices. dBTP means "decibels true peak" and it catches sharp spikes that regular peak meters miss.',+  '',+  'Practical effect:',+  'Quiet moments got slightly louder so you can hear them. Loud moments (like the Chrome AGC warmup bump at the start of the recording) got slightly softer. The whole thing now has consistent volume, and nothing clips. Your original raw file is still on disk, untouched; this is a separate preview sidecar.'+].join('\n');++function fmtBytes(n) {+  if (n < 1024) return n + ' B';+  if (n < 1024*1024) return (n/1024).toFixed(1) + ' KB';+  return (n/1024/1024).toFixed(2) + ' MB';+}+function fmtAgo(epochSec) {+  const delta = Math.max(0, Math.floor(Date.now()/1000) - epochSec);+  if (delta < 60) return delta + 's ago';+  if (delta < 3600) return Math.floor(delta/60) + 'm ago';+  if (delta < 86400) return Math.floor(delta/3600) + 'h ago';+  return Math.floor(delta/86400) + 'd ago';+}+function basename(p) { return (p.split('/').pop() || p); }++// Client-side tracking of the active tab. The server's /takes endpoint+// can only mark a tab as selected if its path matches preview_path or+// finalize_path; the synthetic "Original" tab has an empty path and+// therefore would never show as selected on server-returned data. We+// overlay this client-side value on top of /takes so the visual state+// always matches what the user actually clicked.+let clientSelectedKey = null;  // 'original' | path string | null+function keyForTake(t) {+  return t.kind === 'original' ? 'original' : t.path;+}++async function switchToTake(t) {+  // Handles clicks from both the tab strip and the history card.+  // For the synthetic "Original" tab (kind === "original") we swap+  // vid.src to the source /video endpoint. For everything else we+  // call /select-take to set preview_path server-side, then swap+  // the src to /final-video which follows preview_path.+  console.log('switchToTake', t.kind, t.label, t.path);+  clientSelectedKey = keyForTake(t);+  try {+    if (t.kind === 'original') {+      // Clear preview_path server-side so /final-video reflects the+      // previously-selected committed version, and load the source+      // directly (it's what the user would record voiceover against).+      await fetch('revert-to-raw', { method: 'POST' }).catch(() => {});+      vid.src = 'video?t=' + Date.now();+      vid.load();+      vid.currentTime = 0;+      vid.play().catch(e => console.warn('original play failed:', e.message));+      setStatus('Playing the original silent sped-up video (no voiceover). Click a take tab to hear one of your voiceovers, or click Record voiceover to make a new take.', 'success');+    } else {+      const resp = await fetch('select-take', {+        method: 'POST',+        headers: {'Content-Type':'application/json'},+        body: JSON.stringify({path: t.path})+      });+      const result = await resp.json();+      if (!result.ok) {+        setStatus('Select failed: ' + (result.error || 'unknown'), 'error');+        return;+      }+      vid.src = 'final-video?t=' + Date.now();+      vid.load();+      vid.currentTime = 0;+      vid.play().catch(e => console.warn('take play failed:', e.message));+      setStatus('Playing: ' + t.label + '\n(' + basename(t.path) + ')\n\nClick Save as final to commit this version to the canonical output path, or keep clicking tabs to compare.', 'success');+    }+    await loadTakes();+  } catch (e) {+    setStatus('Tab switch failed: ' + e.message, 'error');+  }+}++function fmtAbsTime(epochSec) {+  if (!epochSec) return '(unknown time)';+  const d = new Date(epochSec * 1000);+  const pad = n => String(n).padStart(2, '0');+  return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) ++         ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());+}++function takeTooltip(t) {+  // Build a rich tooltip with the descriptive blurb up top, followed by a+  // dashed rule and a file-metadata section (path, size, mtime) in+  // monospace so the technical detail is always visible on hover.+  let blurb = '';+  if (t.kind === 'original') {+    blurb = 'The silent sped-up source video.\n\nThis is what plays while you record your voiceover (no audio baked in), and what you can come back to if you want to start over from nothing. Click this tab to preview the source; click Record voiceover to start a new take against it.';+  } else if (t.kind === 'raw') {+    blurb = 'Take ' + t.take + ' - RAW MUX.\n\nYour voice exactly as Hydrogen captured it, combined with the sped-up video. No loudness processing has been applied. Use this tab to hear what you literally recorded, including any AGC warmup bump, clipping, or quiet sections. Its auto-leveled sibling (Take ' + t.take + ' auto-leveled) is usually what you actually want to save.';+  } else if (t.kind === 'normalized') {+    blurb = 'Take ' + t.take + ' - AUTO-LEVELED.\n\nThe raw mux of Take ' + t.take + ' run through ffmpeg loudnorm (EBU R128 broadcast standard: -16 LUFS target, -1.5 dBTP peak ceiling, 11 LRA loudness range). Volume differences across the take have been evened out, loud peaks are capped so nothing clips, and the AGC warmup bump at the start is smoothed out. This is usually the one you want to Save as final.';+  } else if (t.kind === 'committed') {+    blurb = 'Canonical final file.\n\nThis is whatever got copied to the real output path last time Save as final was clicked, OR the recovered mux from a previous session that the app found on disk at startup. Clicking Save as final while another tab is selected will overwrite this slot.';+  } else if (t.kind === 'legacy') {+    blurb = 'Legacy auto-leveled pass.\n\nA loudness-normalized file from an earlier manual Auto-level run, before the app started auto-normalizing on every Finalize. Kept on disk so you can still hear it if it was the one you liked best. Safe to ignore if you do not remember making it.';+  } else {+    blurb = t.label;+  }++  const rows = [];+  rows.push('FILE   ' + (t.path || '(synthetic, no file)'));+  if (typeof t.size === 'number' && t.size > 0) {+    rows.push('SIZE   ' + fmtBytes(t.size));+  }+  if (t.mtime) {+    rows.push('SAVED  ' + fmtAbsTime(t.mtime) + '  (' + fmtAgo(t.mtime) + ')');+  }+  if (typeof t.take === 'number') {+    rows.push('TAKE   #' + t.take + '  (' + (t.kind === 'raw' ? 'raw mux' : 'auto-leveled') + ')');+  }+  rows.push('KIND   ' + t.kind);+  if (t.selected) {+    rows.push('STATE  currently playing in the video player');+  }++  return blurb + '\n\n----------\n' + rows.join('\n');+}++async function loadTakes() {+  try {+    const r = await fetch('takes');+    const d = await r.json();+    if (!d.ok) return;+    const takes = d.takes || [];+    // Determine the effective selected tab. If the user has clicked a+    // specific tab client-side (including the synthetic Original), that+    // wins. Otherwise fall back to whatever the server says.+    const effectiveKey = clientSelectedKey+      || (takes.find(x => x.selected) ? keyForTake(takes.find(x => x.selected)) : null);++    // --- Tab strip (quick switcher) ---+    videoTabs.innerHTML = '';+    for (const t of takes) {+      const isSel = keyForTake(t) === effectiveKey;+      const tab = document.createElement('div');+      tab.className = 'video-tab ' + t.kind + (isSel ? ' selected' : '');+      tab.setAttribute('data-tooltip', takeTooltip({ ...t, selected: isSel }));+      tab.innerHTML = '<span class="dot"></span><span>' + t.label + '</span>';+      tab.addEventListener('click', () => switchToTake(t));+      videoTabs.appendChild(tab);+    }+    // --- History card (rich detail) ---+    if (takes.length === 0) { takesCard.style.display = 'none'; return; }+    takesCard.style.display = 'block';+    takesList.innerHTML = '';+    for (const t of takes) {+      if (t.kind === 'original') continue; // no row for the synthetic tab+      const isSel = keyForTake(t) === effectiveKey;+      const row = document.createElement('div');+      row.className = 'takes-row' + (isSel ? ' selected' : '');+      row.setAttribute('data-tooltip', takeTooltip({ ...t, selected: isSel }));+      const sz = t.size ? fmtBytes(t.size) : '';+      const mt = t.mtime ? fmtAgo(t.mtime) : '';+      row.innerHTML =+        '<div class="kind-pill ' + t.kind + '">' + (t.kind === 'normalized' ? 'auto-leveled' : t.kind) + '</div>' ++        '<div class="path" title="' + (t.path || '') + '">' + t.label + '</div>' ++        '<div class="size">' + sz + '</div>' ++        '<div class="mtime">' + mt + '</div>' ++        '<div class="status-badge">selected</div>';+      row.addEventListener('click', () => switchToTake(t));+      takesList.appendChild(row);+    }+  } catch (e) {+    console.warn('loadTakes failed:', e.message);+  }+}++function loadWaveform(kind) {+  wavCard.style.display = 'block';+  if (kind === 'raw') {+    wavOrigWrap.classList.remove('empty');+    wavOrigWrap.innerHTML = '<img alt="raw waveform" src="waveform-raw?t=' + Date.now() + '">';+    wavOrigMeta.textContent = 'pre-normalize';+    wavOrigMeta.setAttribute('data-tooltip', RAW_TOOLTIP);+  } else if (kind === 'preview') {+    wavNormBlock.style.display = 'flex';+    wavNormWrap.classList.remove('empty');+    wavNormWrap.innerHTML = '<img alt="normalized waveform" src="waveform-preview?t=' + Date.now() + '">';+    wavNormMeta.textContent = 'EBU R128 -16 LUFS / -1.5 dBTP';+    wavNormMeta.setAttribute('data-tooltip', NORM_TOOLTIP);+  } else if (kind === 'reset') {+    wavNormBlock.style.display = 'none';+    wavNormWrap.innerHTML = '';+  }+}++async function pollState() {+  try {+    const resp = await fetch('state');+    if (!resp.ok) return;+    const s = await resp.json();+    applyPhase(s.phase);+  } catch (e) {+    // transient; next tick will retry+  }+}+setInterval(pollState, 400);+pollState();+// Populate tabs on first load regardless of phase, so pre-existing+// takes from previous sessions (recovered state, etc.) show up.+loadTakes();+// Fetch + render the speedup timeline if markers are available.+loadTimeline();++async function startRecording() {+  btnRecord.disabled = true;+  setStatus('Requesting 3-2-1 countdown from Hydrogen...');+  try {+    const resp = await fetch('start-recording', { method: 'POST' });+    const result = await resp.json().catch(() => ({}));+    if (!result.ok) {+      setStatus('Hydrogen audio start failed:\n' + (result.error || 'unknown error'), 'error');+      btnRecord.disabled = false;+      return;+    }+    // Server has already run the 3-2-1 caption countdown and started audio.+    // The state poller will pick up phase=recording on the next tick and+    // drive the video playback + button animation.+  } catch (e) {+    setStatus('Failed to start Hydrogen audio: ' + e.message, 'error');+    btnRecord.disabled = false;+  }+}++async function stopRecording() {+  btnRecord.disabled = true;+  setStatus('Stopping audio recording...');+  try {+    const resp = await fetch('stop-recording', { method: 'POST' });+    const result = await resp.json().catch(() => ({}));+    if (!result.ok) {+      setStatus('Hydrogen audio stop failed:\n' + (result.error || 'unknown error'), 'error');+      btnRecord.disabled = false;+    }+    // State poller picks up phase=recorded and drives the UI.+  } catch (e) {+    setStatus('Failed to stop Hydrogen audio: ' + e.message, 'error');+    btnRecord.disabled = false;+  }+}++btnRecord.addEventListener('click', () => {+  // Record button is a toggle based on current server phase.+  if (lastPhase === 'recording') stopRecording();+  else startRecording();+});++btnDiscard.addEventListener('click', () => {+  // Discard is UI-only reset. The saved audio file is left on disk so the+  // user can still recover it if they change their mind before finalizing.+  // The state poller will push us back to idle once the server is reset.+  fetch('discard', { method: 'POST' }).catch(() => {});+  setStatus('Discarded. Click Record voiceover to try again from the top.');+});++btnReveal.addEventListener('click', async () => {+  try {+    const resp = await fetch('reveal', { method: 'POST' });+    const result = await resp.json();+    if (!result.ok) {+      setStatus('Reveal failed: ' + (result.error || 'unknown'), 'error');+      return;+    }+    setStatus('Opened in VS Code:\n  ' + result.path, 'success');+  } catch (e) {+    setStatus('Reveal request failed: ' + e.message, 'error');+  }+});++btnSaveFinal.addEventListener('click', async () => {+  btnSaveFinal.disabled = true;+  setStatus('Saving the selected tab as the canonical final output...');+  try {+    const resp = await fetch('save-final', { method: 'POST' });+    const result = await resp.json();+    if (!result.ok) {+      setStatus('Save failed:\n' + (result.error || 'unknown error'), 'error');+      btnSaveFinal.disabled = false;+      return;+    }+    setStatus('Saved. Canonical final video is at:\n  ' + result.path + (result.note ? '\n\n(' + result.note + ')' : ''), 'success');+    loadTakes();+    btnSaveFinal.disabled = false;+  } catch (e) {+    setStatus('Save request failed: ' + e.message, 'error');+    btnSaveFinal.disabled = false;+  }+});++btnFinalize.addEventListener('click', async () => {+  btnFinalize.disabled = true;+  btnDiscard.disabled = true;+  btnRecord.disabled = true;+  setStatus('Running ffmpeg mux...');+  try {+    const finalize = await fetch('finalize', { method: 'POST' });+    const result = await finalize.json();+    if (result.ok) {+      // The state poller will pick up phase=done and handle the video+      // swap + auto-playback of the final muxed result. No auto-shutdown.+    } else {+      setStatus('Finalize failed:\n' + (result.error || 'unknown error'), 'error');+      btnRecord.disabled = false;+      btnDiscard.disabled = false;+      btnFinalize.disabled = false;+    }+  } catch (e) {+    setStatus('Finalize failed: ' + e.message, 'error');+    btnRecord.disabled = false;+    btnDiscard.disabled = false;+    btnFinalize.disabled = false;+  }+});+</script>+</body>+</html>
src/webapp/console.rsadded+158
@@ -0,0 +1,158 @@+//! Shared in-app log buffer.+//!+//! Every webapp piping the UI's `console.log` output back to the+//! server via `POST /console` so the CLI operator (and the AI driving+//! the tool) can read what the UI is saying without having to open+//! devtools in the Hydrogen webview. The same buffer is the target+//! of `adom-video-post ctrl <target> console --tail` / `--follow` so the+//! AI can stream UI logs into the Claude Code Monitor tool.++use std::collections::VecDeque;+use std::sync::{Arc, Condvar, Mutex};+use std::time::{Duration, Instant};+use tiny_http::{Header, Request, Response};++pub const CONSOLE_MAX: usize = 500;++/// Shared handle to the in-app log buffer. Append to the back,+/// evict from the front on overflow. Each entry is a pre-serialized+/// JSON string so the GET handler can splat them directly.+pub struct Console {+    pub messages: Mutex<VecDeque<ConsoleEntry>>,+    /// Incremented on every append, used by long-poll clients to+    /// detect "anything new since last poll".+    pub seq: Mutex<u64>,+    pub cond: Condvar,+}++pub struct ConsoleEntry {+    pub seq: u64,+    pub json: String,+}++pub type SharedConsole = Arc<Console>;++pub fn new() -> SharedConsole {+    Arc::new(Console {+        messages: Mutex::new(VecDeque::with_capacity(CONSOLE_MAX)),+        seq: Mutex::new(0),+        cond: Condvar::new(),+    })+}++/// `GET /console[?since=N&wait=MS]` — tail the buffer.+///+/// Without query params, returns every message currently in the+/// ring. With `since=N`, returns only messages whose seq is > N.+/// With `wait=MS` AND nothing new to return, blocks up to MS on a+/// Condvar waiting for a new append. This is the long-poll variant+/// used by `adom-video-post ctrl ... console --follow` to feed realtime+/// UI logs into the Claude Code Monitor tool.+pub fn handle_get(request: Request, console: &SharedConsole, url: &str) -> Result<(), String> {+    let query = url.split_once('?').map(|(_, q)| q).unwrap_or("");+    let mut since: u64 = 0;+    let mut wait_ms: u64 = 0;+    for pair in query.split('&') {+        if let Some(n) = pair.strip_prefix("since=") {+            since = n.parse().unwrap_or(0);+        } else if let Some(n) = pair.strip_prefix("wait=") {+            wait_ms = n.parse().unwrap_or(0);+        }+    }++    let entries = {+        let mut guard = console.messages.lock().unwrap();+        let mut matching: Vec<String> = guard+            .iter()+            .filter(|e| e.seq > since)+            .map(|e| e.json.clone())+            .collect();+        if matching.is_empty() && wait_ms > 0 {+            let deadline = Instant::now() + Duration::from_millis(wait_ms);+            while matching.is_empty() {+                let remaining = deadline.saturating_duration_since(Instant::now());+                if remaining.is_zero() {+                    break;+                }+                let (g, wait_result) = console.cond.wait_timeout(guard, remaining).unwrap();+                guard = g;+                matching = guard+                    .iter()+                    .filter(|e| e.seq > since)+                    .map(|e| e.json.clone())+                    .collect();+                if wait_result.timed_out() {+                    break;+                }+            }+        }+        matching+    };++    let latest_seq = *console.seq.lock().unwrap();+    let body = format!(+        r#"{{"ok":true,"seq":{},"messages":[{}]}}"#,+        latest_seq,+        entries.join(",")+    );+    request+        .respond(json_response(200, &body))+        .map_err(|e| e.to_string())+}++/// `POST /console` — append a JSON-encoded log message. The request+/// body is validated as JSON, re-serialized compactly, and echoed+/// to the server's stdout so the CLI operator sees it too.+pub fn handle_post(mut request: Request, console: &SharedConsole) -> Result<(), String> {+    let mut body = String::new();+    request+        .as_reader()+        .read_to_string(&mut body)+        .map_err(|e| e.to_string())?;+    let msg: serde_json::Value = match serde_json::from_str(&body) {+        Ok(v) => v,+        Err(e) => {+            let _ = request.respond(json_response(+                400,+                &format!(r#"{{"ok":false,"error":"invalid json: {}"}}"#, e),+            ));+            return Ok(());+        }+    };+    let compact = msg.to_string();+    println!("[ui-console] {}", compact);+    append(console, compact);+    request+        .respond(json_response(200, r#"{"ok":true}"#))+        .map_err(|e| e.to_string())+}++/// `DELETE /console` — drop all buffered messages.+pub fn handle_delete(request: Request, console: &SharedConsole) -> Result<(), String> {+    console.messages.lock().unwrap().clear();+    request+        .respond(json_response(200, r#"{"ok":true}"#))+        .map_err(|e| e.to_string())+}++/// Append a pre-serialized JSON string to the buffer, evicting the+/// oldest entry on overflow, bumping the seq counter, and waking+/// any long-poll waiters.+pub fn append(console: &SharedConsole, json: String) {+    let mut seq_guard = console.seq.lock().unwrap();+    *seq_guard += 1;+    let seq = *seq_guard;+    drop(seq_guard);+    let mut guard = console.messages.lock().unwrap();+    if guard.len() >= CONSOLE_MAX {+        guard.pop_front();+    }+    guard.push_back(ConsoleEntry { seq, json });+    console.cond.notify_all();+}++fn json_response(status: u16, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(body)+        .with_status_code(status)+        .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap())+}
src/webapp/eval.rsadded+202
@@ -0,0 +1,202 @@+//! Frontend /eval hot-patch channel — the AI's escape hatch for+//! driving any webapp-backed subcommand's UI without a rebuild.+//!+//! The pattern is specified in `gallia/skills/app-creator/SKILL.md` §7b.+//! AI (or any CLI caller) queues a JS snippet via `POST /eval`, the UI+//! long-polls `GET /eval/pending`, runs the snippet via+//! `new AsyncFunction('ctx', code)(window.__appCtx || {})`, and POSTs+//! the return value (or error) to `POST /eval/:id/result`. The queuer+//! blocks on a Condvar waiting for that result slot to fill.+//!+//! Because this is a full remote-code-execution primitive against+//! the app's own frontend it MUST NOT be exposed in any context+//! other than a dev-mode local webview. Our binding is `127.0.0.1`+//! only so that property holds.++use std::collections::{HashMap, VecDeque};+use std::sync::{Arc, Condvar, Mutex};+use std::time::{Duration, Instant};+use tiny_http::{Header, Request, Response};+use uuid::Uuid;++/// A pending snippet waiting for the UI to pick it up.+pub struct EvalJob {+    pub id: String,+    pub code: String,+}++/// Shared state: a FIFO of pending snippets + a map of returned+/// results keyed by id. A single Condvar wakes both long-poll+/// `GET /eval/pending` waiters (when new jobs arrive) AND+/// `block_on_result` waiters (when the UI posts a result). We+/// demultiplex by checking what we were waiting for on wakeup.+pub struct EvalQueue {+    pub state: Mutex<EvalState>,+    pub cond: Condvar,+}++#[derive(Default)]+pub struct EvalState {+    pub pending: VecDeque<EvalJob>,+    pub results: HashMap<String, serde_json::Value>,+}++pub type SharedEval = Arc<EvalQueue>;++pub fn new() -> SharedEval {+    Arc::new(EvalQueue {+        state: Mutex::new(EvalState::default()),+        cond: Condvar::new(),+    })+}++/// `POST /eval` — queue a new snippet, return its id. Body shape:+/// `{"code":"return 1+1"}`. Response: `{"id":"<uuid>"}`.+pub fn handle_post(mut request: Request, eval: &SharedEval) -> Result<(), String> {+    let mut body = String::new();+    request+        .as_reader()+        .read_to_string(&mut body)+        .map_err(|e| e.to_string())?;+    let parsed: serde_json::Value = match serde_json::from_str(&body) {+        Ok(v) => v,+        Err(e) => {+            let _ = request.respond(json_response(+                400,+                &format!(r#"{{"ok":false,"error":"invalid json: {}"}}"#, e),+            ));+            return Ok(());+        }+    };+    let code = match parsed.get("code").and_then(|v| v.as_str()) {+        Some(c) => c.to_string(),+        None => {+            let _ = request.respond(json_response(+                400,+                r#"{"ok":false,"error":"missing 'code' field"}"#,+            ));+            return Ok(());+        }+    };+    let id = Uuid::new_v4().to_string();+    {+        let mut guard = eval.state.lock().unwrap();+        guard.pending.push_back(EvalJob {+            id: id.clone(),+            code,+        });+    }+    eval.cond.notify_all();+    let body = format!(r#"{{"ok":true,"id":"{}"}}"#, id);+    request+        .respond(json_response(200, &body))+        .map_err(|e| e.to_string())+}++/// `GET /eval/pending` — long-poll for the next pending snippet.+/// Blocks up to 25 seconds waiting for the queue to have something,+/// returns the oldest pending job as `{"id":"...","code":"..."}`,+/// or 204 No Content on timeout. The UI is expected to loop.+pub fn handle_get_pending(request: Request, eval: &SharedEval) -> Result<(), String> {+    let deadline = Instant::now() + Duration::from_secs(25);+    let mut guard = eval.state.lock().unwrap();+    loop {+        if let Some(job) = guard.pending.pop_front() {+            let body = format!(+                r#"{{"id":"{}","code":{}}}"#,+                job.id,+                serde_json::to_string(&job.code).unwrap()+            );+            return request+                .respond(json_response(200, &body))+                .map_err(|e| e.to_string());+        }+        let remaining = deadline.saturating_duration_since(Instant::now());+        if remaining.is_zero() {+            return request+                .respond(empty_response(204))+                .map_err(|e| e.to_string());+        }+        let (g, wait_result) = eval.cond.wait_timeout(guard, remaining).unwrap();+        guard = g;+        if wait_result.timed_out() && guard.pending.is_empty() {+            return request+                .respond(empty_response(204))+                .map_err(|e| e.to_string());+        }+    }+}++/// `GET /eval/:id/result` — the CLI caller (usually+/// `adom-video-post eval`) polls this to retrieve the snippet's return+/// value. Long-polls up to 25 seconds on a Condvar. Returns 200 ++/// the stored result object on success, 204 No Content on timeout.+pub fn handle_get_result(request: Request, eval: &SharedEval, id: &str) -> Result<(), String> {+    let deadline = Instant::now() + Duration::from_secs(25);+    let mut guard = eval.state.lock().unwrap();+    loop {+        if let Some(result) = guard.results.remove(id) {+            let body = result.to_string();+            return request+                .respond(json_response(200, &body))+                .map_err(|e| e.to_string());+        }+        let remaining = deadline.saturating_duration_since(Instant::now());+        if remaining.is_zero() {+            return request+                .respond(empty_response(204))+                .map_err(|e| e.to_string());+        }+        let (g, wait_result) = eval.cond.wait_timeout(guard, remaining).unwrap();+        guard = g;+        if wait_result.timed_out() && !guard.results.contains_key(id) {+            return request+                .respond(empty_response(204))+                .map_err(|e| e.to_string());+        }+    }+}++/// `POST /eval/:id/result` — the UI posts the snippet's return value+/// (or error) back. Body shape: `{"ok":true,"value":...}` or+/// `{"ok":false,"error":"...","stack":"..."}`. We store the whole+/// object in the result map and wake any callers blocked on this id.+pub fn handle_post_result(+    mut request: Request,+    eval: &SharedEval,+    id: &str,+) -> Result<(), String> {+    let mut body = String::new();+    request+        .as_reader()+        .read_to_string(&mut body)+        .map_err(|e| e.to_string())?;+    let parsed: serde_json::Value = match serde_json::from_str(&body) {+        Ok(v) => v,+        Err(e) => {+            let _ = request.respond(json_response(+                400,+                &format!(r#"{{"ok":false,"error":"invalid json: {}"}}"#, e),+            ));+            return Ok(());+        }+    };+    {+        let mut guard = eval.state.lock().unwrap();+        guard.results.insert(id.to_string(), parsed);+    }+    eval.cond.notify_all();+    request+        .respond(json_response(200, r#"{"ok":true}"#))+        .map_err(|e| e.to_string())+}++fn json_response(status: u16, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(body)+        .with_status_code(status)+        .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap())+}++fn empty_response(status: u16) -> Response<std::io::Empty> {+    Response::empty(status)+}
src/webapp/eval_client.jsadded+64
@@ -0,0 +1,64 @@+<script>+// adom-video-post frontend eval channel (app-creator SKILL.md §7b).+//+// Long-polls /eval/pending, runs each pending snippet as an async+// function body with `ctx = window.__appCtx || {}`, POSTs the+// return value (or error) to /eval/:id/result, and loops. The UI+// pages just need to define window.__appCtx with whatever state+// they want eval snippets to touch.+//+// The snippet is evaluated via `new (async function(){}).constructor`+// so `await` is supported and `return` at the top level works.+(function () {+  if (window.__adomEvalClientStarted) return;+  window.__adomEvalClientStarted = true;++  async function runOne(job) {+    let result;+    try {+      const AsyncFn = (async function () {}).constructor;+      const fn = new AsyncFn('ctx', job.code);+      const value = await fn(window.__appCtx || {});+      result = { ok: true, value: value === undefined ? null : value };+    } catch (e) {+      result = {+        ok: false,+        error: e && e.message ? e.message : String(e),+        stack: e && e.stack ? e.stack : null,+      };+    }+    try {+      await fetch('eval/' + encodeURIComponent(job.id) + '/result', {+        method: 'POST',+        headers: { 'Content-Type': 'application/json' },+        body: JSON.stringify(result),+      });+    } catch (_) {+      // Swallow — the CLI caller will just time out waiting for the+      // result. We don't want the poll loop to die because one+      // result POST failed.+    }+  }++  async function pollLoop() {+    while (true) {+      try {+        const r = await fetch('eval/pending');+        if (r.status === 204) continue; // long-poll timeout, go again+        if (!r.ok) {+          await new Promise((x) => setTimeout(x, 1000));+          continue;+        }+        const job = await r.json();+        if (job && job.id && typeof job.code === 'string') {+          runOne(job); // fire-and-forget so a slow snippet doesn't block the next poll+        }+      } catch (_) {+        await new Promise((x) => setTimeout(x, 1000));+      }+    }+  }++  pollLoop();+})();+</script>
src/webapp/icons.rsadded+46
@@ -0,0 +1,46 @@+//! SVG → `data:image/svg+xml;base64,…` helper.+//!+//! Hydrogen's tab strip accepts either `mdi:<name>` icon names or a+//! `data:image/svg+xml;base64,…` URL. MDI strings are shorter but+//! limited to whatever MDI subset Hydrogen ships, and the rendering+//! is sometimes inconsistent across panel types. Embedded SVG data+//! URLs always render and let us ship distinctive, brand-colored+//! icons per subcommand.+//!+//! This module has a small hand-rolled base64 encoder so we don't+//! pull in a dependency just for 15 lines of code.++const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";++fn base64_encode(bytes: &[u8]) -> String {+    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);+    for chunk in bytes.chunks(3) {+        let b0 = chunk[0];+        let b1 = *chunk.get(1).unwrap_or(&0);+        let b2 = *chunk.get(2).unwrap_or(&0);+        let n = ((b0 as u32) << 16) | ((b1 as u32) << 8) | (b2 as u32);+        out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);+        out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);+        out.push(if chunk.len() > 1 {+            ALPHABET[((n >> 6) & 0x3f) as usize] as char+        } else {+            '='+        });+        out.push(if chunk.len() > 2 {+            ALPHABET[(n & 0x3f) as usize] as char+        } else {+            '='+        });+    }+    out+}++/// Wrap an SVG string as a `data:image/svg+xml;base64,...` URL.+/// Kept around for any future caller (inline CSS background,+/// placeholder `--display-icon` values, etc.) even though the+/// canonical tab-icon path is now the served `/favicon.svg`+/// per the hydrogen-tab-icons skill.+#[allow(dead_code)]+pub fn svg_data_url(svg: &str) -> String {+    format!("data:image/svg+xml;base64,{}", base64_encode(svg.as_bytes()))+}
src/webapp/mod.rsadded+101
@@ -0,0 +1,101 @@+//! Shared HTTP + webview infrastructure for every `adom-video-post` subcommand+//! that exposes a web UI (voiceover, storyboard, future review apps).+//!+//! The pattern is: a subcommand implements the `Routes` trait — returning+//! its embedded UI HTML and handling app-specific routes — and calls+//! `run_webapp(cfg, routes)`. `run_webapp` starts a tiny_http server,+//! handles built-in routes (UI, favicon, /console, /eval, /shutdown),+//! delegates unknown routes to the app, opens a Hydrogen webview tab+//! pointed at the server, and blocks until /shutdown fires. On return+//! the webview tab is removed and the instance registry entry is cleaned+//! up.+//!+//! Built-in routes all apps get for free:+//!+//!   GET  /                     embedded UI HTML with EVAL_CLIENT injected+//!   GET  /index.html           same+//!   GET  /favicon.svg          shared favicon+//!   GET  /favicon.ico          shared favicon+//!   GET  /console              tail the in-app log buffer+//!   POST /console              append a log message (UI → server)+//!   DELETE /console            clear the log buffer+//!   POST /eval                 queue a hot-patch JS snippet (app-creator §7b)+//!   GET  /eval/pending         UI long-polls this for pending snippets+//!   POST /eval/:id/result      UI posts the snippet's return value back+//!   POST /shutdown             exit the server cleanly+//!   GET  /shutdown             same, convenient for curl+//!+//! Apps define their own /state shape, /video routes, and any+//! app-specific command endpoints — anything that isn't in the list+//! above falls through to `Routes::handle`.++pub mod console;+pub mod eval;+pub mod icons;+pub mod registry;+pub mod server;+pub mod tab;+pub mod video;++use std::sync::Arc;+use tiny_http::{Method, Request};++/// Per-app configuration consumed by `run_webapp`.+pub struct WebappConfig {+    pub port: u16,+    pub tab_name: String,+    pub panel_id_override: Option<String>,+    pub display_icon: String,+    /// Short identifier of the subcommand for the instance registry+    /// ("voiceover", "storyboard", etc).+    pub subcommand: String,+    /// Human-readable title shown in `adom-video-post list`. Usually the+    /// input filename or manifest title.+    pub title: String,+    /// When true, start the HTTP server but skip the Hydrogen webview+    /// tab open. Useful when the user is reviewing the app in pup+    /// and explicitly does not want a Hydrogen tab to appear, or for+    /// AI sessions that drive the page via browser_eval.+    pub no_open: bool,+}++/// Outcome returned from `Routes::handle`. Because tiny_http's `Request`+/// is a single-owner value, the app either responds to it (and returns+/// `Handled`) or returns it back so the caller can fall through to a+/// 404 (`NotHandled`).+pub enum RouteOutcome {+    Handled(Result<(), String>),+    NotHandled(Request),+}++/// Trait every webapp-backed subcommand implements. The server loop+/// calls `ui_html` at startup to serve GET /, and `handle` for any+/// request not claimed by a built-in route.+pub trait Routes: Send + Sync {+    /// The embedded UI HTML. A `<!-- EVAL_CLIENT -->` marker in the+    /// returned string is replaced with the shared eval_client.js at+    /// serve time so every UI automatically gets the hot-patch channel.+    fn ui_html(&self) -> &str;++    /// The SVG bytes served at `GET /favicon.svg`. Hydrogen reads the+    /// loaded page's `<link rel="icon">` after page-load and writes it+    /// into `panelState.displayIcon`, overriding whatever placeholder+    /// was passed via `--display-icon`. So the stable, post-load tab+    /// icon is whatever this method returns — which means each+    /// subcommand controls its own tab icon via this favicon, not via+    /// the placeholder (see hydrogen-tab-icons skill). Must be a+    /// self-contained, square, monochrome `#e6edf3` SVG per the Adom+    /// brand rule in `gallia/skills/brand/SKILL.md`.+    fn favicon_svg(&self) -> &str;++    /// Handle a request the built-in router did not claim. On+    /// `NotHandled` the caller responds with a 404.+    fn handle(&self, request: Request, method: &Method, url: &str) -> RouteOutcome;+}++/// Run a webapp: start the HTTP server on `cfg.port`, register the+/// instance, open the Hydrogen webview tab, block until /shutdown,+/// then clean up. Returns on clean shutdown or on a hard bind error.+pub fn run_webapp(cfg: WebappConfig, routes: Arc<dyn Routes>) -> Result<(), String> {+    server::serve(cfg, routes)+}
src/webapp/registry.rsadded+61
@@ -0,0 +1,61 @@+//! Instance registry for running adom-video-post web UIs.+//!+//! Every `run_webapp` invocation writes a small JSON file to+//! `~/.adom/adom-video-post/instances/{pid}.json` on startup, and+//! deletes it on clean shutdown or on SIGTERM/SIGINT. The+//! `adom-video-post list` subcommand reads this directory (filtering+//! out stale entries whose pid is dead), and `adom-video-post ctrl`+//! resolves symbolic targets like `voiceover` / `storyboard` /+//! `latest` to a concrete port by scanning it.+//!+//! This module only implements the write/unregister side. The+//! list/ctrl side is in the top-level `ctrl` module (Phase 1.5).++use std::fs;+use std::path::PathBuf;++pub struct InstanceEntry {+    pub pid: u32,+    pub port: u16,+    pub subcommand: String,+    pub title: String,+}++/// Write a registry entry for the current process. Returns the+/// absolute path of the file that was written (so `unregister`+/// can later remove exactly that file).+pub fn register(entry: &InstanceEntry) -> Result<PathBuf, String> {+    let dir = instances_dir()?;+    fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {}", dir.display(), e))?;+    let path = dir.join(format!("{}.json", entry.pid));+    let started_at = std::time::SystemTime::now()+        .duration_since(std::time::UNIX_EPOCH)+        .map(|d| d.as_secs())+        .unwrap_or(0);+    let cwd = std::env::current_dir()+        .map(|p| p.display().to_string())+        .unwrap_or_default();+    let body = format!(+        r#"{{"pid":{},"port":{},"subcommand":{},"title":{},"startedAt":{},"cwd":{}}}"#,+        entry.pid,+        entry.port,+        serde_json::to_string(&entry.subcommand).unwrap(),+        serde_json::to_string(&entry.title).unwrap(),+        started_at,+        serde_json::to_string(&cwd).unwrap(),+    );+    fs::write(&path, body).map_err(|e| format!("write {}: {}", path.display(), e))?;+    Ok(path)+}++/// Remove a previously-written registry file. Silently ignores+/// "file not found" — the SIGTERM handler and normal shutdown+/// path both call this, and racing on the unlink is fine.+pub fn unregister(path: &std::path::Path) {+    let _ = fs::remove_file(path);+}++pub fn instances_dir() -> Result<PathBuf, String> {+    let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;+    Ok(PathBuf::from(home).join(".adom/adom-video-post/instances"))+}
src/webapp/server.rsadded+242
@@ -0,0 +1,242 @@+//! The shared tiny_http request loop.+//!+//! `serve` starts a thread-per-request HTTP server on `cfg.port`,+//! registers the instance in `~/.adom/adom-video-post/instances/`,+//! opens a Hydrogen webview tab pointed at the server, and spins+//! on incoming requests until a `/shutdown` request arrives or+//! the process is killed.+//!+//! Built-in routes are dispatched BEFORE delegating to the app's+//! `Routes::handle`, so apps cannot override them (nor would they+//! want to — they're app-neutral infrastructure).++use super::{console, eval, registry, tab, Routes, WebappConfig};+use std::sync::atomic::{AtomicBool, Ordering};+use std::sync::Arc;+use tiny_http::{Header, Method, Request, Response, Server};++/// The eval-client JS block that gets injected into every UI in+/// place of the `<!-- EVAL_CLIENT -->` marker at serve time.+const EVAL_CLIENT_JS: &str = include_str!("eval_client.js");++/// Blocking entrypoint. Returns on clean shutdown or on bind error.+pub fn serve(cfg: WebappConfig, routes: Arc<dyn Routes>) -> Result<(), String> {+    let addr = format!("127.0.0.1:{}", cfg.port);+    let server = Server::http(&addr).map_err(|e| format!("bind {}: {}", addr, e))?;++    // Register in the instance directory so `adom-video-post list` / ctrl+    // can find us. Any error here is non-fatal — we log and continue.+    let registry_entry = registry::InstanceEntry {+        pid: std::process::id(),+        port: cfg.port,+        subcommand: cfg.subcommand.clone(),+        title: cfg.title.clone(),+    };+    let registry_path = match registry::register(&registry_entry) {+        Ok(p) => Some(p),+        Err(e) => {+            eprintln!("warning: could not register instance: {}", e);+            None+        }+    };++    // Open the Hydrogen webview tab. If this fails, we tear down+    // the server and return — a webapp with no UI is pointless.+    //+    // Append an epoch query param so the Hydrogen webview iframe+    // doesn't reuse a cached copy of the UI HTML from a previous+    // instance that was running on this same port. Combined with+    // the Cache-Control: no-store on serve_ui, this guarantees+    // the first fetch of a freshly-restarted server lands a fresh+    // response, no reload needed.+    let base_url = tab::resolve_url(cfg.port);+    let sep = if base_url.contains('?') { '&' } else { '?' };+    let epoch = std::time::SystemTime::now()+        .duration_since(std::time::UNIX_EPOCH)+        .map(|d| d.as_secs())+        .unwrap_or(0);+    let url = format!("{}{}v={}", base_url, sep, epoch);+    println!("webapp URL: {}", url);+    if cfg.no_open {+        // --no-open: HTTP server only, no Hydrogen tab. The user is+        // driving the UI in pup or via browser_eval and explicitly+        // does NOT want a webview tab to appear.+        println!("webapp running headless on port {} (--no-open)", cfg.port);+    } else if let Err(e) = tab::open(+        &cfg.tab_name,+        &url,+        &cfg.display_icon,+        cfg.panel_id_override.as_deref(),+    ) {+        if let Some(p) = &registry_path {+            registry::unregister(p);+        }+        return Err(e);+    } else {+        println!("webapp tab '{}' open on port {}", cfg.tab_name, cfg.port);+    }++    // Each subcommand gets its own console + eval queue.+    let console = console::new();+    let eval_queue = eval::new();+    let should_exit = Arc::new(AtomicBool::new(false));++    // Each request gets its own worker thread. Without this, a+    // long-poll endpoint like /eval/pending (which waits up to 25s+    // on a Condvar) would block every other request on the single+    // request loop — and since the UI's eval_client poll loop keeps+    // a /eval/pending request open continuously, nothing else would+    // ever respond. Spawning per-request is fine because tiny_http's+    // `Request::respond` is thread-safe and the handlers are+    // self-contained.+    loop {+        if should_exit.load(Ordering::Relaxed) {+            break;+        }+        // Short timeout so the loop can notice should_exit promptly.+        let request = match server.recv_timeout(std::time::Duration::from_millis(250)) {+            Ok(Some(r)) => r,+            Ok(None) => continue,+            Err(e) => {+                eprintln!("webapp accept error: {}", e);+                continue;+            }+        };++        let routes_for_thread = Arc::clone(&routes);+        let console_for_thread = Arc::clone(&console);+        let eval_for_thread = Arc::clone(&eval_queue);+        let should_exit_for_thread = Arc::clone(&should_exit);++        std::thread::spawn(move || {+            let method = request.method().clone();+            let url_full = request.url().to_string();+            let url_path = url_full+                .split('?')+                .next()+                .unwrap_or(&url_full)+                .to_string();++            let handled = dispatch(+                request,+                &method,+                &url_path,+                &url_full,+                &routes_for_thread,+                &console_for_thread,+                &eval_for_thread,+                &should_exit_for_thread,+            );+            if let Err(e) = handled {+                eprintln!("webapp error on {} {}: {}", method, url_path, e);+            }+        });+    }++    // Clean shutdown: remove the webview tab and the registry entry.+    let _ = tab::remove(&cfg.tab_name);+    if let Some(p) = &registry_path {+        registry::unregister(p);+    }+    println!("webapp shut down");+    Ok(())+}++#[allow(clippy::too_many_arguments)]+fn dispatch(+    request: Request,+    method: &Method,+    url_path: &str,+    url_full: &str,+    routes: &Arc<dyn Routes>,+    console: &console::SharedConsole,+    eval_queue: &eval::SharedEval,+    should_exit: &Arc<AtomicBool>,+) -> Result<(), String> {+    // --- Built-in routes, handled before the app sees anything.+    match (method, url_path) {+        (Method::Get, "/") | (Method::Get, "/index.html") => {+            return serve_ui(request, routes.ui_html());+        }+        (Method::Get, "/favicon.svg") | (Method::Get, "/favicon.ico") => {+            return serve_favicon(request, routes.favicon_svg());+        }+        (Method::Get, "/console") => {+            return console::handle_get(request, console, url_full);+        }+        (Method::Post, "/console") => {+            return console::handle_post(request, console);+        }+        (Method::Delete, "/console") => {+            return console::handle_delete(request, console);+        }+        (Method::Post, "/eval") => {+            return eval::handle_post(request, eval_queue);+        }+        (Method::Get, "/eval/pending") => {+            return eval::handle_get_pending(request, eval_queue);+        }+        (Method::Post, "/shutdown") | (Method::Get, "/shutdown") => {+            let _ = request.respond(text_response(200, "OK"));+            should_exit.store(true, Ordering::Relaxed);+            return Ok(());+        }+        _ => {}+    }++    // /eval/:id/result is dynamic — match the prefix/suffix. Both+    // GET (caller polls for a result) and POST (UI submits a result)+    // land here.+    if url_path.starts_with("/eval/") && url_path.ends_with("/result") {+        let id = &url_path["/eval/".len()..url_path.len() - "/result".len()];+        match method {+            Method::Get => return eval::handle_get_result(request, eval_queue, id),+            Method::Post => return eval::handle_post_result(request, eval_queue, id),+            _ => {}+        }+    }++    // --- Nothing matched. Delegate to the app.+    match routes.handle(request, method, url_path) {+        super::RouteOutcome::Handled(result) => result,+        super::RouteOutcome::NotHandled(req) => req+            .respond(text_response(404, "not found"))+            .map_err(|e| e.to_string()),+    }+}++fn serve_ui(request: Request, ui_html: &str) -> Result<(), String> {+    let injected = ui_html.replace("<!-- EVAL_CLIENT -->", EVAL_CLIENT_JS);+    // Cache-Control: no-store is essential. Without it, restarting+    // the server on the same port causes the Hydrogen webview to+    // reuse the previously-cached HTML from the dead instance —+    // meaning code changes don't land until the user hits reload.+    // That's an explicit no-no per the "never ask the user to+    // refresh" rule. Force-fresh every fetch.+    let resp = Response::from_string(injected)+        .with_header(+            Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]).unwrap(),+        )+        .with_header(+            Header::from_bytes(&b"Cache-Control"[..], &b"no-store, must-revalidate"[..]).unwrap(),+        );+    request.respond(resp).map_err(|e| e.to_string())+}++fn serve_favicon(request: Request, svg: &str) -> Result<(), String> {+    // Each subcommand's `Routes::favicon_svg()` returns its own SVG+    // bytes — this is what ends up in the Hydrogen tab chip after+    // page load, per the hydrogen-tab-icons skill.+    let resp = Response::from_string(svg)+        .with_header(Header::from_bytes(&b"Content-Type"[..], &b"image/svg+xml"[..]).unwrap())+        .with_header(+            Header::from_bytes(&b"Cache-Control"[..], &b"no-cache"[..]).unwrap(),+        );+    request.respond(resp).map_err(|e| e.to_string())+}++fn text_response(status: u16, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(body)+        .with_status_code(status)+        .with_header(Header::from_bytes(&b"Content-Type"[..], &b"text/plain"[..]).unwrap())+}
src/webapp/tab.rsadded+212
@@ -0,0 +1,212 @@+//! Hydrogen webview tab lifecycle helpers.+//!+//! Every webapp-backed subcommand wants the same three things on+//! startup/shutdown: pick a good target pane (NEVER the VS Code+//! pane — see the rule in the adom-workspace-control skill), spawn+//! a webview tab pointed at our local HTTP server, and (on shutdown)+//! remove the tab by name.+//!+//! The pane-picking rule: walk the workspace layout tree, find every+//! leaf, filter to leaves whose tabs do NOT include a VS Code tab+//! (panelType `adom/a1b2c3d4-eeee-4000-a000-00000000000e`), and pick+//! one of those. Only fall back to the currently-focused pane when+//! every pane has VS Code in it (single-pane layout). Never ever+//! target the VS Code pane — it hosts the Claude Code chat, and+//! stacking a webview over it hides the user's primary interaction+//! surface. This bug was shipped for everyone before the rule was+//! documented; don't regress it.++use std::process::Command;++/// Canonical VS Code panelType. Any tab carrying this type is a+/// VS Code / code-server instance hosting the Claude Code chat.+const VSCODE_PANEL_TYPE: &str = "adom/a1b2c3d4-eeee-4000-a000-00000000000e";++/// Open a Hydrogen webview tab pointed at `url` with the given+/// display name and MDI icon. Uses `panel_id_override` if set,+/// otherwise picks a target pane that does NOT contain VS Code.+///+/// If a tab with `name` already exists (from a prior session, a+/// crashed instance, or a leftover from the legacy Python pipeline),+/// it is removed first so the new tab can land cleanly. This makes+/// every restart self-healing — the user doesn't have to manually+/// close stale tabs to recover.+pub fn open(+    name: &str,+    url: &str,+    display_icon: &str,+    panel_id_override: Option<&str>,+) -> Result<(), String> {+    // Remove any pre-existing tab with the same name. The remove call+    // returns 404 if no such tab exists, which we ignore — both "no+    // stale tab" and "successfully removed stale tab" are good+    // outcomes here.+    let _ = Command::new("adom-cli")+        .args(["hydrogen", "workspace", "remove-tab", "--name", name])+        .output();++    let panel_id = match panel_id_override {+        Some(p) => p.to_string(),+        None => pick_non_vscode_panel_id()?,+    };++    let initial_state = format!(r#"{{"url":"{}"}}"#, url);+    let result = Command::new("adom-cli")+        .args([+            "hydrogen",+            "workspace",+            "add-tab",+            "--panel-id",+            &panel_id,+            "--panel-type",+            "adom/a1b2c3d4-0031-4000-a000-000000000031",+            "--display-name",+            name,+            "--display-icon",+            display_icon,+            "--initial-state",+            &initial_state,+        ])+        .output()+        .map_err(|e| format!("adom-cli spawn failed: {}", e))?;+    if !result.status.success() {+        return Err(format!(+            "failed to add webview tab: {}",+            String::from_utf8_lossy(&result.stderr)+        ));+    }+    // NB: intentionally NOT calling `adom-cli hydrogen workspace+    // active-tab --name <same name>` here. Doing that right after+    // add-tab silently wipes the displayIcon out of the just-added+    // tab's panelState (verified live on 2026-04-15). add-tab+    // already activates the new tab by default (its own _hint+    // output says "Tab ... created and active"), so the follow-up+    // call is redundant anyway. This is a latent bug in adom-cli+    // or Hydrogen that should be filed separately.+    Ok(())+}++/// Remove the webview tab with the given display name.+pub fn remove(name: &str) -> Result<(), String> {+    let result = Command::new("adom-cli")+        .args(["hydrogen", "workspace", "remove-tab", "--name", name])+        .output()+        .map_err(|e| format!("adom-cli spawn failed: {}", e))?;+    if !result.status.success() {+        return Err(format!(+            "failed to remove webview tab: {}",+            String::from_utf8_lossy(&result.stderr)+        ));+    }+    Ok(())+}++/// Resolve a server port to the correct URL for the Hydrogen webview+/// to fetch. Inside Coder/Gallia containers the webview iframe is+/// same-origin with hydrogen.adom.inc and can't load `http://127.0.0.1:...`+/// directly — `VSCODE_PROXY_URI` is set to a template like+/// `https://<slug>.adom.cloud/proxy/{{port}}/` which the webview CAN+/// reach. Falls back to the loopback URL when the env var is unset.+pub fn resolve_url(port: u16) -> String {+    match std::env::var("VSCODE_PROXY_URI") {+        Ok(template) => template.replace("{{port}}", &port.to_string()),+        Err(_) => format!("http://127.0.0.1:{}/", port),+    }+}++/// Query the workspace layout and pick a target pane that does+/// NOT contain a VS Code tab. Preference order:+///   1. The currently-focused pane IF it is not VS Code.+///   2. The first non-VS-Code pane in tree order.+///   3. The focused pane (fallback, single-pane layout — caller+///      will land a tab on top of VS Code and should hopefully+///      have already warned the user).+///+/// See the adom-workspace-control skill, "NEVER place a new tab on+/// the same pane that hosts VS Code" for the reasoning.+pub fn pick_non_vscode_panel_id() -> Result<String, String> {+    let result = Command::new("adom-cli")+        .args(["hydrogen", "workspace", "get"])+        .output()+        .map_err(|e| format!("adom-cli spawn failed: {}", e))?;+    if !result.status.success() {+        return Err(format!(+            "failed to query workspace: {}",+            String::from_utf8_lossy(&result.stderr)+        ));+    }+    let body = String::from_utf8_lossy(&result.stdout);+    let parsed: serde_json::Value =+        serde_json::from_str(&body).map_err(|e| format!("failed to parse workspace JSON: {}", e))?;++    let focused = parsed+        .get("focusedPanelId")+        .and_then(|v| v.as_str())+        .map(|s| s.to_string());++    let root = parsed+        .get("root")+        .ok_or_else(|| "workspace response has no root".to_string())?;+    let mut leaves: Vec<&serde_json::Value> = Vec::new();+    collect_leaves(root, &mut leaves);++    // Filter out any leaf that has a VS Code tab.+    let non_vscode: Vec<&serde_json::Value> = leaves+        .iter()+        .copied()+        .filter(|leaf| !leaf_contains_vscode(leaf))+        .collect();++    if non_vscode.is_empty() {+        // Single-pane layout — we have no safe choice. Log a warning+        // and return the focused pane so the caller at least doesn't+        // fail outright.+        eprintln!(+            "warning: every workspace pane contains a VS Code tab; falling back to focused pane. \+             The new webview will land on top of VS Code. Split a pane first to avoid this."+        );+        return focused.ok_or_else(|| "no focusedPanelId in workspace response".to_string());+    }++    // Prefer the focused pane IF it is itself non-VS-Code.+    if let Some(f) = &focused {+        for leaf in &non_vscode {+            if leaf.get("id").and_then(|v| v.as_str()) == Some(f.as_str()) {+                return Ok(f.clone());+            }+        }+    }+    // Otherwise take the first non-VS-Code pane in tree order.+    non_vscode[0]+        .get("id")+        .and_then(|v| v.as_str())+        .map(|s| s.to_string())+        .ok_or_else(|| "non-VS-Code leaf had no id".to_string())+}++/// Recursive helper: walk the workspace tree and append every leaf+/// (node with a `tabs` array) to `out`.+fn collect_leaves<'a>(node: &'a serde_json::Value, out: &mut Vec<&'a serde_json::Value>) {+    if node.get("tabs").is_some() {+        out.push(node);+        return;+    }+    if let Some(first) = node.get("first") {+        collect_leaves(first, out);+    }+    if let Some(second) = node.get("second") {+        collect_leaves(second, out);+    }+}++/// Return true if any tab in the leaf carries the VS Code panelType.+fn leaf_contains_vscode(leaf: &serde_json::Value) -> bool {+    leaf.get("tabs")+        .and_then(|t| t.as_array())+        .map(|tabs| {+            tabs.iter().any(|tab| {+                tab.get("panelType").and_then(|v| v.as_str()) == Some(VSCODE_PANEL_TYPE)+            })+        })+        .unwrap_or(false)+}
src/webapp/video.rsadded+94
@@ -0,0 +1,94 @@+//! HTTP byte-range file streaming for `<video>` elements.+//!+//! Python's stock `http.server` returns `HTTP/1.0 200` with the full+//! body for any request, which makes Chromium's `<video>` stall on+//! seek because it's waiting for a `206 Partial Content` response to+//! a `Range: bytes=a-b` request that never comes. We hit this bug+//! live in the session that produced this module — the voiceover+//! subcommand has always done the right thing because it used this+//! code; the storyboard workflow didn't, and we had to write a+//! throwaway Python range-capable server to work around it before+//! this module existed.++use std::fs::File;+use std::io::{Read, Seek, SeekFrom};+use std::path::Path;+use tiny_http::{Header, Request, Response, StatusCode};++/// Stream a file in response to a request, respecting any+/// `Range: bytes=a-b` header. Always sends `Accept-Ranges: bytes`+/// so browsers know seeking is supported. Returns 206 Partial+/// Content if the request had a range header, 200 otherwise.+pub fn serve_file_range(request: Request, path: &Path, content_type: &str) -> Result<(), String> {+    let mut file = File::open(path).map_err(|e| format!("open {}: {}", path.display(), e))?;+    let total_len = file.metadata().map_err(|e| e.to_string())?.len();++    let range = request+        .headers()+        .iter()+        .find(|h| h.field.as_str().as_str().eq_ignore_ascii_case("Range"))+        .and_then(|h| parse_range(h.value.as_str(), total_len));++    let (start, end) = range.unwrap_or((0, total_len.saturating_sub(1)));+    let length = end - start + 1;++    file.seek(SeekFrom::Start(start)).map_err(|e| e.to_string())?;+    let body = ReadLimit {+        inner: file,+        remaining: length,+    };++    let mut resp = Response::new(+        StatusCode(if range.is_some() { 206 } else { 200 }),+        vec![+            Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes()).unwrap(),+            Header::from_bytes(&b"Accept-Ranges"[..], &b"bytes"[..]).unwrap(),+            Header::from_bytes(&b"Cache-Control"[..], &b"no-cache"[..]).unwrap(),+        ],+        body,+        Some(length as usize),+        None,+    );+    if range.is_some() {+        let cr = format!("bytes {}-{}/{}", start, end, total_len);+        resp.add_header(Header::from_bytes(&b"Content-Range"[..], cr.as_bytes()).unwrap());+    }+    request.respond(resp).map_err(|e| e.to_string())+}++/// Wrap a `Read` source so at most `remaining` bytes will ever be+/// yielded. Used to cap tiny_http's streaming body at the end of+/// the requested range.+struct ReadLimit<R: Read> {+    inner: R,+    remaining: u64,+}++impl<R: Read> Read for ReadLimit<R> {+    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {+        if self.remaining == 0 {+            return Ok(0);+        }+        let max = std::cmp::min(buf.len() as u64, self.remaining) as usize;+        let n = self.inner.read(&mut buf[..max])?;+        self.remaining -= n as u64;+        Ok(n)+    }+}++/// Parse an HTTP `Range: bytes=a-b` header into inclusive `(start, end)`+/// offsets. Returns `None` for malformed or out-of-range values —+/// the caller then falls back to serving the whole file.+fn parse_range(header: &str, total: u64) -> Option<(u64, u64)> {+    let s = header.strip_prefix("bytes=")?;+    let mut parts = s.split('-');+    let start = parts.next()?.parse::<u64>().ok()?;+    let end = parts+        .next()+        .and_then(|p| if p.is_empty() { None } else { p.parse::<u64>().ok() })+        .unwrap_or(total.saturating_sub(1));+    if start > end || end >= total {+        return None;+    }+    Some((start, end))+}