demobite 1.0.8 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,115 +1,161 @@
1
- # demobite
1
+ # DemoBites Agentic Recorder
2
2
 
3
- **You prompt, it records.** The agentic recorder films a real browser from a
4
- storyboard — real cursor physics, a measured clock, cinematic camera moves —
5
- and delivers the take into [DemoBites](https://demobites.com), where it becomes
6
- a fully editable demo: AI narration, zooms, cursor rendering, intro and outro,
7
- localization, all tweakable in the studio.
3
+ **Ask your coding agent to record your product. Edit the result. Download the MP4.**
4
+
5
+ Your agent drives a real browser on your machine and records the flow you describe. Connect a free DemoBites account to turn the take into an editable video, with text-to-speech, zooms, captions, and MP4 export included.
6
+
7
+ **Sign in to your app locally. You do not hand DemoBites your app password or upload your recording browser profile.**
8
8
 
9
9
  ```bash
10
- npx demobite
10
+ npx demobite@latest
11
11
  ```
12
12
 
13
- That one command checks your setup, installs the recorder skill for
14
- your coding agent — [Claude Code](https://claude.com/claude-code), Cursor,
15
- or Codex — and wires the DemoBites
16
- management MCP so your agent can also run your Update Center and Demo Center.
17
- Sign-in happens through your own browser — no passwords in the terminal, ever.
18
- Then you just ask your agent:
13
+ **Free Studio · Text-to-speech included · Unlimited exports · No watermark · No credit card**
14
+
15
+ [Website](https://agentic-recorder.com/) · [npm](https://www.npmjs.com/package/demobite) · [Report an issue](https://github.com/demobites/agentic-recorder/issues)
16
+
17
+ ## See the workflow
18
+
19
+ https://github.com/user-attachments/assets/18293b85-82e2-475f-8d94-543f1f9bbc1d
19
20
 
20
- > "Record a demo of how search works on our app, and upload it to DemoBites."
21
+ An illustrated walkthrough of the flow, with waiting time compressed.
21
22
 
22
- > "Create a release with my latest bites and add Spanish."
23
+ ## Start with your own agent
23
24
 
24
- Commands: `npx demobite` (full setup) · `npx demobite login` · `npx demobite mcp`
25
- (register the management MCP with your agent) · `npx demobite logout`.
26
- Anything public — publishing a release, taking a language offline — always
27
- shows you a preview to approve first. Managing by agent requires the Grow plan;
28
- recording works on every plan.
25
+ You need a coding agent that can read skill instructions, run terminal commands, and control a local browser. A browser-only chat session cannot run this local workflow.
29
26
 
30
- The agent storyboards the flow, films it in a real Chrome, and stages the take
31
- for your approval inside DemoBites. You approve in the product; the platform
32
- does the rest.
27
+ Before installing:
33
28
 
34
- ## Manage DemoBites from your agent (MCP)
29
+ - **Node.js 18 or newer** and npm.
30
+ - **Google Chrome**, recommended. The recorder also supports Playwright's Chromium.
31
+ - A **free DemoBites account** for the Studio workflow below.
35
32
 
36
- Recording is half the story. The same package wires the **DemoBites management
37
- MCP** — a control plane your agent uses to run your Update Center and Demo
38
- Center. Your customers' agents read your centers; your agent runs them.
33
+ Run these commands from your project directory:
39
34
 
40
35
  ```bash
41
- npx demobite mcp
36
+ # Install the skill and check your setup.
37
+ npx demobite@latest
38
+
39
+ # Connect this project to your DemoBites workspace.
40
+ npx demobite login
42
41
  ```
43
42
 
44
- Claude Code is registered automatically. Any other MCP client works over
45
- Streamable HTTP: point it at `https://app.demobites.com/api/mcp` with the
46
- `Authorization: Bearer <key>` header the command prints (in Cursor, add both
47
- under Settings → MCP). Then talk to your agent like a teammate:
43
+ The installer installs Playwright, ffmpeg and ffprobe beside the skill if they are not already there. It uses your own ffmpeg when a compatible build is on your PATH. It checks for Chrome and downloads Chromium on the first take if Chrome is missing.
44
+
45
+ To connect, open the link printed in your terminal and approve the connection in your browser. If you are signed out of DemoBites, sign in there first. You do not paste a password into the terminal.
46
+
47
+ Then ask your agent:
48
+
49
+ > Record a demo of inviting a teammate in my app at http://localhost:3000. Open Team settings, invite alex@example.com as an Admin, and show the confirmation. Show me the storyboard before recording.
50
+
51
+ Use your own app URL and a demo account. The agent performs real actions in your app, including sending an invitation if that is part of the approved flow.
52
+
53
+ ### Which agent?
54
+
55
+ The installer currently places the skill at `~/.claude/skills/agentic-recorder/`, the Claude Code skill directory.
56
+
57
+ For Cursor or Codex, make the [recorder instructions](skill/SKILL.md) available to your agent and ensure it can run the installed scripts. The launcher currently does not install into separate Cursor or Codex skill directories. Connecting an MCP client alone does not install the recorder skill.
58
+
59
+ ## From prompt to finished video
60
+
61
+ 1. **Describe the flow.** Give your agent the app URL and what you want to demonstrate.
62
+ 2. **Approve the storyboard.** Review the steps and estimated length before filming. If your app needs a login, sign in yourself in the recorder's local browser window.
63
+ 3. **Let it record.** The agent checks the flow and films it. Recording normally runs in the background.
64
+ 4. **Review the raw take.** Open the review link printed in the terminal. Check the footage, then approve it to create a Bite, an editable video project.
65
+ 5. **Tweak and download.** In the Studio, change a sentence, generate its voice, adjust the zooms or timing, and export your MP4.
66
+
67
+ The storyboard's narration is a starting point. DemoBites rewrites and fits it to the footage; you can edit the final wording in the Studio.
68
+
69
+ ## What connecting to DemoBites adds
70
+
71
+ The recorder supplies footage, action timings, cursor movements, and camera targets. The Studio uses that information to create a video you can keep editing.
72
+
73
+ | Included in the free Studio | What you can do |
74
+ | --- | --- |
75
+ | Script and text-to-speech | Edit the words and generate narration without recording your own voice. |
76
+ | Camera and cursor editing | Adjust automatic zooms, cursor appearance, and timing. |
77
+ | Video finishing | Trim the take, edit captions, and add your background, branding, intro, and outro. |
78
+ | MP4 export | Download Full HD video with narration and no DemoBites watermark. |
48
79
 
49
- > "Create a release with my latest bites, add Spanish, and publish it."
80
+ **The free Studio holds up to 8 Bites at a time, with unlimited exports. Invite three people who sign up and the Studio becomes unlimited, still free.** It has no trial clock and requires no credit card. You bring your own coding agent; its subscription or model usage is separate. An ElevenLabs key is optional, not required for the Studio's included text-to-speech.
50
81
 
51
- > "What's on our Demo Center? Swap the checkout bite for the onboarding one."
82
+ You can use the exported video in your docs, website, release notes, or wherever you need it. Paid publishing and management features are optional. See [current plan details](https://www.demobites.com/pricing).
52
83
 
53
- ### What it can do — 19 tools
84
+ ## One prompt, one result
54
85
 
55
- | Group | Tools |
56
- |---|---|
57
- | Connect | `get_started` · `connect_demobites` · `check_connection` · `get_recording_options` |
58
- | Read | `get_status` · `list_bites` · `list_releases` · `get_release` · `list_languages` |
59
- | Draft & edit | `create_release` · `update_release` · `assign_bites_to_release` · `update_center_settings` · `add_language` · `update_demo_center` |
60
- | Publish — with your approval | `publish_release` · `unpublish_release` · `remove_language` · `publish_demo_center` |
86
+ > Record a demo of Funnels: where customers drop off, which channels work. Show the sidebar and graphs.
61
87
 
62
- The server describes itself: any client's `tools/list` returns every tool with
63
- its full input schema, straight from the running code.
64
88
 
65
- ### You stay in charge
66
89
 
67
- Draft work executes directly, exactly like clicking around the product. But
68
- anything that touches a **public** page is two-phase: the tool returns a
69
- human-readable preview plus a single-use confirmation token, your agent shows
70
- you the preview, and only your go-ahead executes it. Tokens expire in ten
71
- minutes, are bound to the exact action and arguments, and every management
72
- call lands in an audit log.
90
+ https://github.com/user-attachments/assets/02eb7d3e-fe74-4c0d-b532-e16bfc3fa614
73
91
 
74
- ### Plans
75
92
 
76
- Connecting and reading are open. Managing requires the **Grow** plan.
77
- Recording works on every plan.
93
+
94
+ Recorded by the agent, then edited and narrated in the free Studio. This prompt assumes the agent already knows the app URL and can access the Funnels page.
78
95
 
79
- Full guide: [Manage from your agent](https://www.demobites.com/docs/bites/manage-from-your-agent)
96
+ ## Your app login stays local
80
97
 
81
- ## What's in this repository
98
+ **You do not need to hand DemoBites your app password to record a signed-in flow.** Sign in yourself in the recorder's browser on your machine.
82
99
 
83
- | Directory | What it is |
84
- |---|---|
85
- | `launcher/` | The `npx demobite` entry — environment checks, skill install, login |
86
- | `skill/` | The DemoBites recorder skill for coding agents (staging, preview, approval flow) |
87
- | `recorder/` | The open recorder — same filming engine, no account, ends at a polished `demo.mp4` |
88
- | `scripts/` | The shared engine: filming, clock calibration, cutting |
100
+ The recorder uses a separate, persistent Chrome profile at `.recorder/profile/`. Your app session is saved there for later takes. It does not automatically inherit the sessions in your everyday Chrome profile.
89
101
 
90
- ## Just want the recorder, no DemoBites?
102
+ | Data | Where it goes |
103
+ | --- | --- |
104
+ | Your app's browser session | Stays in the local recording profile; the standard local workflow does not upload that profile to DemoBites. |
105
+ | DemoBites recorder key | Saved in `.recorder/config.json` and used to authenticate recorder requests to DemoBites. This is separate from your app login. |
106
+ | Footage and recording metadata | Uploaded to DemoBites for review and processing, including the storyboard, narration intent, cursor events, and camera targets. |
91
107
 
92
- The `recorder/` directory is a standalone skill: the same real-browser filming,
93
- hover-anchor clock calibration and camera work, delivering a finished, styled
94
- `demo.mp4` on your disk — no account, no upload. Point your coding agent at it and
95
- film. When you want narration, zooms, an editable timeline and hosting, the
96
- sibling skill in `skill/` is one login away.
108
+ Recording happens locally. Studio processing and editing use DemoBites online. Anything visible or typed into the recorded flow can appear in the footage or recording metadata, so use suitable demo data. Your coding agent's own data handling still follows its provider and your settings.
97
109
 
98
- ## How updates reach you
110
+ Keep `.recorder/` and take directories out of version control. To disconnect the recorder, run `npx demobite logout`.
99
111
 
100
- Run with `@latest` and every invocation resolves the newest published version —
101
- the skill you install always matches the DemoBites platform it talks to.
102
- Releases are published from GitHub Actions with npm provenance: every version
103
- is cryptographically tied to a public commit in this repository.
112
+ ## Local output without a DemoBites account
104
113
 
105
- ## Requirements
114
+ The repository also contains a [standalone recorder skill](recorder/SKILL.md) and local finishing scripts. These produce a local MP4; optional voiceover uses your own ElevenLabs key.
106
115
 
107
- - Node 18+
108
- - A coding agent — [Claude Code](https://claude.com/claude-code), Cursor, or Codex; the recorder is agent-driven
109
- - Google Chrome (recommended; films with the real browser) — otherwise
110
- Chromium is downloaded on first take
111
- - ffmpeg (`brew install ffmpeg` on macOS)
116
+ This is an advanced, manual workflow. The default `npx demobite@latest` command installs the Studio-connected skill, not the standalone skill. The standalone finishing reference contains legacy instructions; use the standalone skill as the entry point rather than treating it as a second ending of the installed Studio skill.
117
+
118
+ ## A few boundaries
119
+
120
+ - The agentic recorder films **browser workflows**. It does not record native desktop apps or phone screens.
121
+ - The Studio-connected skill is designed for short demos, usually **30–45 seconds**, with a **90-second maximum per Bite**. Split longer stories into separate videos.
122
+ - Some sites block automated browsers. The workflow may need a human sign-in or verification step, and some sites may refuse recording.
123
+ - Filming does not publish anything. You review the take before creating the Bite; public publishing is a separate action.
124
+
125
+ ## Optional: Retake and management
126
+
127
+ When your UI changes, **Retake** can refilm an existing Bite from its saved recipe. It is a paid capability; see [plan details](https://www.demobites.com/pricing).
128
+
129
+ ```bash
130
+ npx demobite retake <biteId> --note "Export moved to the header"
131
+ ```
132
+
133
+ The package also includes a DemoBites management MCP for releases and centers:
134
+
135
+ ```bash
136
+ npx demobite mcp
137
+ ```
138
+
139
+ The default installer also attempts to register that MCP with Claude Code when the current project is already connected. Management access depends on your account and key permissions. You do not need paid management to record, edit, or export a free Bite.
140
+
141
+ [Management documentation](https://www.demobites.com/docs/bites/manage-from-your-agent)
142
+
143
+ ## Repository
144
+
145
+ | Directory | Purpose |
146
+ | --- | --- |
147
+ | `launcher/` | CLI setup, connection commands, and MCP registration. |
148
+ | `skill/` | Studio-connected recorder instructions, login, upload, and Retake. |
149
+ | `scripts/` | Shared browser recording, trimming, and timing calibration. |
150
+ | `recorder/` | Standalone skill and local video finishing tools. |
151
+ | `aliases/` | `agentic-recorder` and `demobites` aliases for the same launcher. |
152
+
153
+ Run `npx demobite@latest` again to update the installed skill. For publishing instructions, see [RELEASING.md](RELEASING.md).
154
+
155
+ Found something confusing or have an example to share? [Open an issue](https://github.com/demobites/agentic-recorder/issues). Include your operating system, agent, and recorder version. Remove keys, cookies, and private app data from logs or recordings before attaching them.
112
156
 
113
157
  ## License
114
158
 
115
- MIT — see [LICENSE](./LICENSE).
159
+ The code in this repository is [MIT licensed](LICENSE). The hosted DemoBites Studio is a separate service; its source is not included here.
160
+
161
+ Built and maintained by [DemoBites](https://www.demobites.com/).
@@ -45,10 +45,9 @@ const chromePaths = [
45
45
  "C:/Program Files/Google/Chrome/Application/chrome.exe",
46
46
  "/usr/bin/google-chrome",
47
47
  ];
48
- if (chromePaths.some((p) => fs.existsSync(p)) || hasBin("google-chrome")) ok("Google Chrome (films with the real browser)");
49
- else warn("Google Chrome not found — the recorder will download Chromium on first take");
50
- if (hasBin("ffmpeg")) ok("ffmpeg");
51
- else warn("ffmpeg not found — install it (macOS: brew install ffmpeg) before recording");
48
+ const haveChrome = chromePaths.some((p) => fs.existsSync(p)) || hasBin("google-chrome") || hasBin("google-chrome-stable");
49
+ if (haveChrome) ok("Google Chrome (films with the real browser)");
50
+ else console.log(" · Google Chrome not found — installing Playwright's Chromium for the recorder");
52
51
  if (hasBin("claude")) ok("Claude Code (drives the recorder; MCP registers automatically)");
53
52
  else warn("Claude Code not found — using Cursor or Codex? They drive the recorder too; MCP setup prints below");
54
53
 
@@ -63,12 +62,41 @@ for (const f of fs.readdirSync(path.join(pkgRoot, "skill/scripts"))) copy(`skill
63
62
  for (const f of fs.readdirSync(path.join(pkgRoot, "scripts"))) copy(`scripts/${f}`, `scripts/${f}`);
64
63
  ok(`Skill installed → ${dest}`);
65
64
 
66
- // Playwright lives with the skill so takes can film.
67
- if (!fs.existsSync(path.join(dest, "node_modules", "playwright"))) {
68
- console.log("\n Installing Playwright (one-time)…");
69
- const r = spawnSync("npm", ["install", "--prefix", dest, "--silent", "playwright"], { stdio: "inherit" });
65
+ // Playwright and the media tools live with the skill so takes can film and
66
+ // finish on any machine. ffmpeg and ffprobe are packaged per platform
67
+ // (@ffmpeg-installer, @ffprobe-installer); a compatible system build is preferred when present
68
+ // (scripts/media-tools.mjs decides). One line installs everything.
69
+ const needed = ["playwright", "@ffmpeg-installer/ffmpeg", "@ffprobe-installer/ffprobe"].filter(
70
+ (m) => !fs.existsSync(path.join(dest, "node_modules", m)),
71
+ );
72
+ if (needed.length) {
73
+ console.log(`\n Installing ${needed.join(", ")} (one-time)…`);
74
+ const r = spawnSync("npm", ["install", "--prefix", dest, "--silent", "--no-audit", "--no-fund", ...needed], { stdio: "inherit" });
70
75
  if (r.status === 0) ok("Playwright ready");
71
- else warn("Playwright install failed — run: npm install --prefix ~/.claude/skills/agentic-recorder playwright");
76
+ else warn(`Install failed — run: npm install --prefix ~/.claude/skills/agentic-recorder ${needed.join(" ")}`);
77
+ }
78
+ // No Chrome on this machine: fetch Playwright's Chromium once so the first
79
+ // take has a browser. Linux needs the shared libraries too (--with-deps).
80
+ if (!haveChrome) {
81
+ const pw = path.join(dest, "node_modules", "playwright", "cli.js");
82
+ const marker = path.join(dest, ".chromium-ready");
83
+ if (!fs.existsSync(marker) && fs.existsSync(pw)) {
84
+ console.log(" Installing Chromium (one-time)…");
85
+ const args = [pw, "install", "chromium", ...(process.platform === "linux" ? ["--with-deps"] : [])];
86
+ const r = spawnSync("node", args, { stdio: "inherit", cwd: dest });
87
+ if (r.status === 0) { fs.writeFileSync(marker, new Date().toISOString()); ok("Chromium ready"); }
88
+ else warn(`Chromium install failed — run: node ${pw} install chromium${process.platform === "linux" ? " --with-deps" : ""}`);
89
+ } else if (fs.existsSync(marker)) ok("Chromium ready");
90
+ }
91
+ {
92
+ // Verify both tools actually run (compatibility, not presence).
93
+ const r = spawnSync("node", [path.join(dest, "scripts", "media-tools.mjs")], { encoding: "utf8" });
94
+ const rows = (r.stdout || "").trim().split("\n").filter(Boolean).map((l) => l.split("\t"));
95
+ for (const [tool, source, info] of rows) {
96
+ if (source === "missing") warn(`${tool}: no working build (${info})`);
97
+ else ok(`${tool} ready (${source === "packaged" ? "packaged with the recorder" : source === "system" ? "your system build" : "from .recorder/config.json"})`);
98
+ }
99
+ if (r.status !== 0) warn("Recording will fail until ffmpeg and ffprobe both work. Re-run this command, or install them on your system.");
72
100
  }
73
101
 
74
102
  // ── 3. Subcommands ─────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "demobite",
3
- "version": "1.0.8",
4
- "description": "The DemoBites agentic recorder — you prompt, it films a real browser, and DemoBites turns the take into an editable demo bite.",
3
+ "version": "1.1.0",
4
+ "description": "The DemoBites agentic recorder \u2014 you prompt, it films a real browser, and DemoBites turns the take into an editable demo bite.",
5
5
  "bin": {
6
6
  "demobite": "launcher/index.mjs"
7
7
  },
@@ -34,5 +34,9 @@
34
34
  "url": "https://github.com/demobites/agentic-recorder/issues"
35
35
  },
36
36
  "license": "MIT",
37
- "type": "module"
37
+ "type": "module",
38
+ "scripts": {
39
+ "check-aliases": "node scripts/check-aliases.mjs",
40
+ "prepublishOnly": "node scripts/check-aliases.mjs"
41
+ }
38
42
  }
@@ -22,15 +22,8 @@ if (!dir) {
22
22
  console.error("Usage: node mux.mjs <takeDir>");
23
23
  process.exit(2);
24
24
  }
25
- const requireTool = (tool) => {
26
- try { execFileSync(tool, ["-version"], { stdio: "ignore" }); }
27
- catch {
28
- console.error(`${tool} is required on PATH. Install it (macOS: brew install ffmpeg) and rerun.`);
29
- process.exit(1);
30
- }
31
- };
32
- requireTool("ffmpeg");
33
- requireTool("ffprobe");
25
+ import { ffmpeg as FFMPEG, ffprobe as FFPROBE, requireMediaTools } from "../../scripts/media-tools.mjs";
26
+ requireMediaTools(["ffmpeg","ffprobe"]);
34
27
 
35
28
  const demoPath = path.join(dir, "demo.mp4");
36
29
  const manPath = path.join(dir, "manifest.json");
@@ -66,7 +59,7 @@ const delays = tts
66
59
  const mix = tts.map((_, i) => `[a${i}]`).join("") + `amix=inputs=${tts.length}:normalize=0[aout]`;
67
60
 
68
61
  const outPath = path.join(dir, "demo-voiced.mp4");
69
- execFileSync("ffmpeg", [
62
+ execFileSync(FFMPEG(), [
70
63
  "-y", "-loglevel", "error",
71
64
  "-i", demoPath,
72
65
  ...inputArgs,
@@ -75,7 +68,7 @@ execFileSync("ffmpeg", [
75
68
  "-c:v", "copy", "-c:a", "aac", "-b:a", "160k",
76
69
  outPath,
77
70
  ], { stdio: "inherit" });
78
- const dur = execFileSync("ffprobe", [
71
+ const dur = execFileSync(FFPROBE(), [
79
72
  "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", outPath,
80
73
  ]).toString().trim();
81
74
  console.log(`demo-voiced.mp4 ready, ${parseFloat(dur).toFixed(2)}s`);
@@ -10,7 +10,10 @@
10
10
  #
11
11
  # Usage: post.sh <takeDir> [backdrop-hex-no-#] (default backdrop 0f1420)
12
12
  set -euo pipefail
13
- command -v ffmpeg >/dev/null 2>&1 || { echo "ffmpeg is required on PATH. Install it (macOS: brew install ffmpeg) and rerun." >&2; exit 1; }
13
+ HERE="$(cd "$(dirname "$0")" && pwd)"
14
+ FFMPEG="$(node "$HERE/../../scripts/media-tools.mjs" 2>/dev/null | awk -F"\t" '$1=="ffmpeg"{print $3}')"
15
+ FFPROBE="$(node "$HERE/../../scripts/media-tools.mjs" 2>/dev/null | awk -F"\t" '$1=="ffprobe"{print $3}')"
16
+ [ -n "$FFMPEG" ] && [ -n "$FFPROBE" ] || { echo "ffmpeg/ffprobe: no working build found. Run npx demobite@latest again, or install them on your system." >&2; exit 1; }
14
17
  command -v node >/dev/null 2>&1 || { echo "node is required on PATH." >&2; exit 1; }
15
18
  D=${1:?Usage: post.sh <takeDir> [backdrop-hex-no-#]}
16
19
  # Backdrop resolution mirrors frame.mjs exactly: CLI arg, then the remembered
@@ -20,7 +23,7 @@ BG=${2:-$(node -p "try{(JSON.parse(require('fs').readFileSync('.recorder/config.
20
23
  [ -f "$D/raw.webm" ] || { echo "$D/raw.webm not found. Run record.mjs first." >&2; exit 1; }
21
24
  [ -f "$D/shadow.png" ] && [ -f "$D/mask.png" ] || { echo "Overlays missing. Run: node frame.mjs $D" >&2; exit 1; }
22
25
  TRIM=$(node -p "JSON.parse(require('fs').readFileSync('$D/manifest.json','utf8')).record_from||0")
23
- ffmpeg -y -loglevel error -i "$D/raw.webm" -i "$D/shadow.png" -i "$D/mask.png" -filter_complex \
26
+ "$FFMPEG" -y -loglevel error -i "$D/raw.webm" -i "$D/shadow.png" -i "$D/mask.png" -filter_complex \
24
27
  "color=c=0x${BG}:s=1920x1080:r=30[bg];[0:v]trim=start=${TRIM},setpts=PTS-STARTPTS,scale=1728:972:flags=lanczos[v];[2:v]format=gray[m];[v][m]alphamerge[va];[bg][1:v]overlay=0:0[b1];[b1][va]overlay=96:54:shortest=1,fps=30,format=yuv420p[out]" \
25
28
  -map "[out]" -c:v libx264 -preset medium -crf 19 -movflags +faststart "$D/demo.mp4"
26
- echo "demo.mp4 duration: $(ffprobe -v error -show_entries format=duration -of csv=p=0 "$D/demo.mp4")s"
29
+ echo "demo.mp4 duration: $("$FFPROBE" -v error -show_entries format=duration -of csv=p=0 "$D/demo.mp4")s"
@@ -20,14 +20,8 @@ if (!dir) {
20
20
  console.error("Usage: node tts.mjs <takeDir>");
21
21
  process.exit(2);
22
22
  }
23
- const requireTool = (tool) => {
24
- try { execFileSync(tool, ["-version"], { stdio: "ignore" }); }
25
- catch {
26
- console.error(`${tool} is required on PATH. Install it (macOS: brew install ffmpeg) and rerun.`);
27
- process.exit(1);
28
- }
29
- };
30
- requireTool("ffprobe");
23
+ import { ffmpeg as FFMPEG, ffprobe as FFPROBE, requireMediaTools } from "../../scripts/media-tools.mjs";
24
+ requireMediaTools(["ffprobe"]);
31
25
 
32
26
  let KEY = process.env.ELEVENLABS_API_KEY || "";
33
27
  if (!KEY) {
@@ -68,7 +62,7 @@ for (const step of narrated) {
68
62
  const buf = Buffer.from(await res.arrayBuffer());
69
63
  const file = `seg${step.n}.mp3`;
70
64
  fs.writeFileSync(path.join(outDir, file), buf);
71
- const dur = parseFloat(execFileSync("ffprobe", [
65
+ const dur = parseFloat(execFileSync(FFPROBE(), [
72
66
  "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path.join(outDir, file),
73
67
  ]).toString());
74
68
  out.push({ n: step.n, text: step.narration.text, file, duration: Math.round(dur * 100) / 100 });
@@ -28,6 +28,8 @@
28
28
  import fs from "node:fs";
29
29
  import path from "node:path";
30
30
  import { spawnSync } from "node:child_process";
31
+ import { ffmpeg as FFMPEG, requireMediaTools } from "./media-tools.mjs";
32
+ requireMediaTools(["ffmpeg"]);
31
33
 
32
34
  const dir = process.argv[2];
33
35
  if (!dir) { console.error("Usage: node calibrate.mjs <takeDir>"); process.exit(2); }
@@ -97,7 +99,7 @@ function roiChanges(bb) {
97
99
  const pad = 6;
98
100
  const x0 = Math.max(0, bb.x - pad), y0 = Math.max(0, bb.y - pad);
99
101
  const w = Math.min(FRAME.w - x0, bb.w + 2 * pad), h = Math.min(FRAME.h - y0, bb.h + 2 * pad);
100
- const res = spawnSync("ffmpeg", [
102
+ const res = spawnSync(FFMPEG(), [
101
103
  "-loglevel", "info", "-i", clean,
102
104
  "-vf", `crop=${w}:${h}:${x0}:${y0},select='gte(scene,0)',metadata=print`, "-f", "null", "-",
103
105
  ], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
@@ -131,10 +133,20 @@ console.log(`calibrate: map video = ${tb.a} * wall + ${tb.b.toFixed(3)} (${tb.me
131
133
  console.log(`calibrate: ${anchors.length} hover anchor${anchors.length === 1 ? "" : "s"} usable`);
132
134
 
133
135
  let anchored = false;
136
+ // BEACON OUTRANKS ANCHORS (2026-09-02, second prod bite-96 take): the head
137
+ // beacon had measured the cut to +-14 ms, yet ONE hover anchor locked onto a
138
+ // stray pixel change 2.66 s away and dragged the clock with it. When the
139
+ // beacon has spoken (trim stamped beaconDelta from >=3 matched flashes),
140
+ // anchors may only REFINE the clock inside a third of a second; they never
141
+ // re-decide it. Without a beacon the wide sweep stays (the pre-beacon world).
142
+ const beaconAnchored = typeof tb?.beaconDelta === "number";
143
+ const SWEEP_LO = beaconAnchored ? -0.35 : -3.0;
144
+ const SWEEP_HI = beaconAnchored ? 0.35 : 1.0;
145
+ if (beaconAnchored) console.log(`calibrate: beacon-anchored clock — hover anchors may refine by at most ${SWEEP_HI.toFixed(2)}s`);
134
146
  if (anchors.length >= 2) {
135
147
  const TOL = 0.067; // two frames
136
148
  let best = { delta: 0, score: -1, resid: Infinity };
137
- for (let d = -3.0; d <= 1.0001; d += 1 / 60) {
149
+ for (let d = SWEEP_LO; d <= SWEEP_HI + 0.0001; d += 1 / 60) {
138
150
  let score = 0, resid = 0, hits = 0;
139
151
  for (const a of anchors) {
140
152
  const target = a.pred + d;
@@ -171,21 +183,27 @@ if (anchors.length >= 2) {
171
183
  console.log(` anchor ${a.label}: no flip within tolerance at this offset (skipped)`);
172
184
  }
173
185
  }
174
- const rms = Math.sqrt(residuals.reduce((s, r) => s + r * r, 0) / residuals.length);
175
- if (rms > 0.08) {
186
+ if (residuals.length < 2) {
187
+ // One surviving anchor is an opinion, not a measurement.
188
+ console.log(`calibrate: only ${residuals.length} anchor survived the lock — clock left ${beaconAnchored ? "on the beacon" : "as stamped"}.`);
189
+ anchored = beaconAnchored;
190
+ best.delta = 0;
191
+ }
192
+ const rms = residuals.length >= 2 ? Math.sqrt(residuals.reduce((s, r) => s + r * r, 0) / residuals.length) : 0;
193
+ if (residuals.length >= 2 && rms > 0.08) {
176
194
  console.error(`CALIBRATE FAILED: hover-anchor rms ${(rms * 1000).toFixed(0)}ms exceeds 80ms — footage disagrees with itself. Do not upload.`);
177
195
  process.exit(1);
178
196
  }
179
- if (Math.abs(best.delta) > 0.005) {
197
+ if (residuals.length >= 2 && Math.abs(best.delta) > 0.005) {
180
198
  tb.b += best.delta;
181
199
  tb.videoRecordFrom = -tb.b;
182
200
  tb.method += ` + hover-anchor ${best.delta.toFixed(3)}s (${best.hits} anchors, rms ${(rms * 1000).toFixed(0)}ms)`;
183
201
  fs.writeFileSync(manPath, JSON.stringify(man, null, 2));
184
202
  console.log(`calibrate: hover anchors moved the clock ${best.delta.toFixed(3)}s — b is now ${tb.b.toFixed(3)} (rms ${(rms * 1000).toFixed(0)}ms)`);
185
- } else {
203
+ } else if (residuals.length >= 2) {
186
204
  console.log(`calibrate: hover anchors confirm the stamp as-is (rms ${(rms * 1000).toFixed(0)}ms)`);
187
205
  }
188
- anchored = true;
206
+ if (residuals.length >= 2) anchored = true;
189
207
  }
190
208
  }
191
209
 
@@ -202,7 +220,7 @@ if (clicks.length === 0) {
202
220
  }
203
221
 
204
222
  function sceneChanges(threshold) {
205
- const res = spawnSync("ffmpeg", [
223
+ const res = spawnSync(FFMPEG(), [
206
224
  "-loglevel", "info", "-i", clean,
207
225
  "-vf", `select='gt(scene,${threshold})',showinfo`, "-f", "null", "-",
208
226
  ], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
@@ -221,6 +239,8 @@ if (scenes.length === 0) {
221
239
  process.exit(0);
222
240
  }
223
241
 
242
+ const SWEEP_LO_FB = beaconAnchored ? -0.35 : -4;
243
+ const SWEEP_HI_FB = beaconAnchored ? 0.35 : 1.0;
224
244
  if (!anchored) {
225
245
  // Old plausible-latency search. Kept ONLY as the no-anchor fallback; its
226
246
  // known failure mode (plausible != true, 0.237s off on a real take) is why
@@ -237,7 +257,7 @@ if (!anchored) {
237
257
  return total;
238
258
  };
239
259
  let bestDelta = 0, bestScore = score(0);
240
- for (let d = -4; d <= 1.0001; d += 1 / 30) {
260
+ for (let d = SWEEP_LO_FB; d <= SWEEP_HI_FB + 0.0001; d += 1 / 30) {
241
261
  const sc = score(d);
242
262
  if (sc < bestScore - 1e-9) { bestScore = sc; bestDelta = d; }
243
263
  }
@@ -272,6 +292,15 @@ if (!anchored && (median < -0.15 || median > 0.9)) {
272
292
  );
273
293
  process.exit(1);
274
294
  }
295
+ if (!beaconAnchored && anchored && (median < -0.6 || median > 2.5)) {
296
+ // INCIDENT 2026-09-02: +4.6 s "latency" on an anchored clock was the head
297
+ // miscut showing through (the anchors had locked onto the wrong beat).
298
+ console.error(`VERIFY FAILED: median click latency ${median.toFixed(3)}s on an anchor-measured clock is not an app delay, it is a broken timebase (the cut point or the anchor lock is wrong). Do not upload — film again.`);
299
+ process.exit(1);
300
+ }
301
+ if (beaconAnchored && (median < -0.6 || median > 2.5)) {
302
+ console.log(`WARNING: median click latency ${median.toFixed(3)}s on a beacon-measured clock — the clock is trusted, so the app genuinely answered late. The footage shows the wait; judge whether the beat reads as slow.`);
303
+ }
275
304
  if (anchored && (median < -0.15 || median > 0.9)) {
276
305
  console.log(`note: median click latency ${median.toFixed(3)}s is unusual, but the clock is anchor-measured — likely a genuinely slow app response. Judge the take on duration and feel.`);
277
306
  }
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ // ALIAS GUARD (founder law, 2026-09-09): `agentic-recorder` and `demobites` on
3
+ // npm are aliases of this package. They depend on `demobite` with a caret
4
+ // range, so minor and patch releases flow through automatically, but a MAJOR
5
+ // bump silently strands them on the old major. This guard runs before every
6
+ // publish of demobite (prepublishOnly) and in CI, and refuses to continue if
7
+ // any alias's dependency range no longer covers the version about to ship.
8
+ //
9
+ // When it fails: bump the range in aliases/<name>/package.json (and the alias
10
+ // version), publish each alias from the founder's own terminal, then publish
11
+ // demobite. See RELEASING.md.
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
17
+ const main = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
18
+ const aliasesDir = path.join(root, "aliases");
19
+ const [major] = main.version.split(".").map(Number);
20
+
21
+ let failed = false;
22
+ for (const name of fs.readdirSync(aliasesDir)) {
23
+ const pkgPath = path.join(aliasesDir, name, "package.json");
24
+ if (!fs.existsSync(pkgPath)) continue;
25
+ const alias = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
26
+ const range = alias.dependencies?.demobite ?? "";
27
+ const m = /^\^(\d+)\./.exec(range);
28
+ const covered = m && Number(m[1]) === major;
29
+ const line = `${alias.name.padEnd(18)} depends on demobite ${range.padEnd(8)} → ${covered ? "covers" : "DOES NOT COVER"} ${main.version}`;
30
+ console.log((covered ? " ✓ " : " ✗ ") + line);
31
+ if (!covered) failed = true;
32
+ const bin = fs.readFileSync(path.join(aliasesDir, name, "index.mjs"), "utf8");
33
+ if (!bin.includes('import "demobite/launcher/index.mjs"')) {
34
+ console.log(` ✗ ${alias.name} does not import the demobite launcher`);
35
+ failed = true;
36
+ }
37
+ }
38
+ if (failed) {
39
+ console.error("\nAlias guard failed: an npm alias would strand on the old major. Update aliases/*/package.json (range + version), publish the aliases, then publish demobite. See RELEASING.md.");
40
+ process.exit(1);
41
+ }
42
+ console.log("alias guard: ok");
@@ -0,0 +1,93 @@
1
+ // media-tools — the ONE place the recorder resolves ffmpeg and ffprobe.
2
+ //
3
+ // Order (founder design 2026-09-09):
4
+ // 1. an explicit override in <project>/.recorder/config.json { "ffmpeg": "...", "ffprobe": "..." }
5
+ // 2. a SYSTEM binary on PATH that passes the compatibility probe (below)
6
+ // 3. the packaged binaries from @ffmpeg-installer/ffmpeg + @ffprobe-installer/ffprobe
7
+ // (one platform each), installed by the launcher beside Playwright in the skill folder
8
+ //
9
+ // Compatibility, not presence: trim/mux need libx264 and the aac encoder. An old
10
+ // or stripped system build fails the probe and the packaged binary wins.
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { execFileSync } from "node:child_process";
14
+ import { fileURLToPath, pathToFileURL } from "node:url";
15
+ import { createRequire } from "node:module";
16
+
17
+ const here = path.dirname(fileURLToPath(import.meta.url));
18
+ // scripts/ lives directly under the skill folder; node_modules sits beside it.
19
+ const skillRoot = path.resolve(here, "..");
20
+
21
+ function probe(bin, tool) {
22
+ try {
23
+ const out = execFileSync(bin, ["-version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 8000 }).toString();
24
+ if (!/^ff(mpeg|probe) version/m.test(out)) return { ok: false, why: "not an ffmpeg build" };
25
+ if (tool === "ffmpeg") {
26
+ const enc = execFileSync(bin, ["-hide_banner", "-encoders"], { stdio: ["ignore", "pipe", "ignore"], timeout: 8000 }).toString();
27
+ if (!/\blibx264\b/.test(enc)) return { ok: false, why: "no libx264 encoder" };
28
+ if (!/\baac\b/.test(enc)) return { ok: false, why: "no aac encoder" };
29
+ }
30
+ return { ok: true };
31
+ } catch (e) {
32
+ return { ok: false, why: e?.code === "ENOENT" ? "not found" : "did not run" };
33
+ }
34
+ }
35
+
36
+ function fromOverride(tool) {
37
+ try {
38
+ const cfg = JSON.parse(fs.readFileSync(path.join(process.cwd(), ".recorder", "config.json"), "utf8"));
39
+ const v = cfg?.[tool];
40
+ return typeof v === "string" && v ? v : null;
41
+ } catch { return null; }
42
+ }
43
+
44
+ function fromPackage(tool) {
45
+ // Per-platform packages: only the current platform's binary is downloaded.
46
+ const pkg = tool === "ffmpeg" ? "@ffmpeg-installer/ffmpeg" : "@ffprobe-installer/ffprobe";
47
+ for (const base of [skillRoot, process.cwd()]) {
48
+ try {
49
+ const req = createRequire(pathToFileURL(path.join(base, "package.json")));
50
+ const p = req(pkg)?.path;
51
+ if (typeof p === "string" && fs.existsSync(p)) return p;
52
+ } catch { /* not installed here */ }
53
+ }
54
+ return null;
55
+ }
56
+
57
+ const cache = {};
58
+ /** Resolve a tool. Returns { path, source, why } — path null when nothing works. */
59
+ export function resolveTool(tool) {
60
+ if (cache[tool]) return cache[tool];
61
+ const tried = [];
62
+ const o = fromOverride(tool);
63
+ if (o) { const r = probe(o, tool); if (r.ok) return (cache[tool] = { path: o, source: "config" }); tried.push(`config ${o}: ${r.why}`); }
64
+ const sys = probe(tool, tool);
65
+ if (sys.ok) return (cache[tool] = { path: tool, source: "system" });
66
+ tried.push(`system: ${sys.why}`);
67
+ const pk = fromPackage(tool);
68
+ if (pk) { const r = probe(pk, tool); if (r.ok) return (cache[tool] = { path: pk, source: "packaged" }); tried.push(`packaged ${pk}: ${r.why}`); }
69
+ else tried.push("packaged: not installed");
70
+ return (cache[tool] = { path: null, source: null, why: tried.join("; ") });
71
+ }
72
+
73
+ export const ffmpeg = () => resolveTool("ffmpeg").path;
74
+ export const ffprobe = () => resolveTool("ffprobe").path;
75
+
76
+ /** Exit with a clear message unless both tools resolve. Used by every script that films or finishes. */
77
+ export function requireMediaTools(tools = ["ffmpeg", "ffprobe"]) {
78
+ const missing = tools.map((t) => [t, resolveTool(t)]).filter(([, r]) => !r.path);
79
+ if (missing.length) {
80
+ for (const [t, r] of missing) console.error(`${t}: no working build found (${r.why}).`);
81
+ console.error("Run `npx demobite@latest` again to install the packaged ffmpeg and ffprobe, or install them on your system.");
82
+ process.exit(1);
83
+ }
84
+ }
85
+
86
+ // `node media-tools.mjs` prints what would be used — the launcher calls this.
87
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
88
+ for (const t of ["ffmpeg", "ffprobe"]) {
89
+ const r = resolveTool(t);
90
+ console.log(r.path ? `${t}\t${r.source}\t${r.path}` : `${t}\tmissing\t${r.why}`);
91
+ }
92
+ process.exit(["ffmpeg", "ffprobe"].every((t) => resolveTool(t).path) ? 0 : 1);
93
+ }
@@ -40,7 +40,7 @@ const DIR = path.resolve(outArg);
40
40
  // RE-TAKE (founder 2026-09-02): the storyboard IS the bite's DNA. Keep a verbatim
41
41
  // copy in the take dir so upload.mjs can stage it as the recording recipe —
42
42
  // selectors, urls, typed text, hideCss — the wire manifest alone cannot re-film.
43
- const ENGINE_VERSION = "1.0.8";
43
+ const ENGINE_VERSION = "1.0.9";
44
44
  fs.mkdirSync(DIR, { recursive: true });
45
45
  // The copy is the RECIPE, not the transport: cdpWsUrl / storageStatePath are
46
46
  // per-take plumbing stamped by the cloud runner (a CDP url carries a session
@@ -615,6 +615,7 @@ async function smoothScroll(dy, ms = 1400, within = null) {
615
615
  }), [dy, ms, within]);
616
616
  }
617
617
 
618
+ const BEACON_GAPS_MS = [300, 450, 600, 750, 900, 1050];
618
619
  // ── Head beacon ────────────────────────────────────────────────────────────
619
620
  // The identity law (video = wall - record_from) assumes raw.webm's t=0 lines
620
621
  // up with T0. That anchoring is otherwise UNMEASURED: on a cold remote-CDP
@@ -634,22 +635,26 @@ try {
634
635
  // stamp error at half a CDP round trip — inside one frame co-located.
635
636
  return { color, wall: (before + after) / 2 };
636
637
  };
637
- // Wait for the screencast to actually be ROLLING (first frames on disk):
638
- // over a remote CDP link the recorder starts late, and a flash before the
639
- // first frame is invisible (mode A smoke, 2026-09-02).
640
- const rollDeadline = Date.now() + 5000;
641
- while (Date.now() < rollDeadline) {
642
- const rolling = fs.readdirSync(DIR).some((f) => f.endsWith(".webm") && fs.statSync(path.join(DIR, f)).size > 4096);
643
- if (rolling) break;
644
- await page.waitForTimeout(100);
645
- }
638
+ // GAP-CODED PATTERN (2026-09-02, after the prod bite-96 miscut): nothing
639
+ // inside this process can see whether the screencast is rolling yet (the
640
+ // video file does not grow while recording), and under load the first
641
+ // frame can land seconds after the page exists. So the beacon no longer
642
+ // relies on being seen from its first flash: seven flashes with strictly
643
+ // increasing gaps form a signature trim.mjs recognises from ANY three
644
+ // consecutive flashes it finds. A screencast that started late still
645
+ // yields the anchor; one that missed every flash stops the take in trim.
646
646
  await page.evaluate(() => { document.documentElement.style.background = "#ffffff"; });
647
- await page.waitForTimeout(600); // let the white state reach the recording
647
+ await page.waitForTimeout(1500); // let the page exist and the white state settle
648
648
  const flips = [];
649
- flips.push(await flip("#ff00ff")); // onset 1: white -> magenta
650
- await page.waitForTimeout(400);
651
- flips.push(await flip("#ffffff")); // onset 2: magenta -> white
652
- await page.waitForTimeout(400);
649
+ let color = "#ff00ff";
650
+ for (const gapMs of BEACON_GAPS_MS) {
651
+ flips.push(await flip(color));
652
+ color = color === "#ff00ff" ? "#ffffff" : "#ff00ff";
653
+ await page.waitForTimeout(gapMs);
654
+ }
655
+ flips.push(await flip(color)); // final onset closes the last gap
656
+ if (color === "#ff00ff") { await page.waitForTimeout(250); flips.push(await flip("#ffffff")); }
657
+ await page.waitForTimeout(300);
653
658
  manifest.beacon = { flips };
654
659
  process.stdout.write(`beacon: flips at ${flips.map((f) => f.wall.toFixed(3)).join("s, ")}s (wall)\n`);
655
660
  } catch (e) {
package/scripts/trim.mjs CHANGED
@@ -15,15 +15,8 @@ if (!dir) {
15
15
  console.error("Usage: node trim.mjs <takeDir>");
16
16
  process.exit(2);
17
17
  }
18
- const requireTool = (tool) => {
19
- try { execFileSync(tool, ["-version"], { stdio: "ignore" }); }
20
- catch {
21
- console.error(`${tool} is required on PATH. Install it (macOS: brew install ffmpeg) and rerun.`);
22
- process.exit(1);
23
- }
24
- };
25
- requireTool("ffmpeg");
26
- requireTool("ffprobe");
18
+ import { ffmpeg as FFMPEG, ffprobe as FFPROBE, requireMediaTools } from "./media-tools.mjs";
19
+ requireMediaTools();
27
20
 
28
21
  const raw = path.join(dir, "raw.webm");
29
22
  const manPath = path.join(dir, "manifest.json");
@@ -56,36 +49,83 @@ const r0 = man.record_from ?? 0;
56
49
  // Shift the cut by it so clean.mp4 truly begins at the record_from moment.
57
50
  let beaconDelta = 0;
58
51
  let beaconMethod = "";
59
- if ((man.beacon?.flips?.length ?? 0) >= 2) {
52
+ if ((man.beacon?.flips?.length ?? 0) >= 3) {
60
53
  const flips = man.beacon.flips;
61
54
  // The flashes are the first big whole-frame changes in the head. Search a
62
55
  // window generous enough for seconds of anchor error in either direction.
63
- const searchEnd = Math.min(Math.max(r0, flips[flips.length - 1].wall) + 8, 40);
64
- const res = spawnSync("ffmpeg", [
56
+ const searchEnd = Math.min(Math.max(r0, flips[flips.length - 1].wall) + 10, 45);
57
+ const res = spawnSync(FFMPEG(), [
65
58
  "-loglevel", "info", "-t", String(searchEnd), "-i", raw,
66
59
  "-vf", "select='gt(scene,0.3)',showinfo", "-f", "null", "-",
67
60
  ], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
68
61
  const onsets = [...`${res.stderr ?? ""}`.matchAll(/pts_time:([0-9.]+)/g)]
69
62
  .map((m) => parseFloat(m[1]));
70
- if (onsets.length >= 2) {
71
- // Pair the first two onsets to the two flips, in order. Anything the page
72
- // paints later (the goto) lands after onset 2 and never enters the pair.
73
- const d1 = flips[0].wall - onsets[0];
74
- const d2 = flips[1].wall - onsets[1];
75
- const spread = Math.abs(d1 - d2);
76
- if (spread <= 0.15) {
77
- beaconDelta = (d1 + d2) / 2;
78
- beaconMethod = ` + head-beacon anchor ${beaconDelta >= 0 ? "+" : ""}${beaconDelta.toFixed(3)}s (2 flips, spread ${(spread * 1000).toFixed(0)}ms)`;
79
- console.log(`beacon: anchor error ${beaconDelta >= 0 ? "+" : ""}${beaconDelta.toFixed(3)}s measured (flip spread ${(spread * 1000).toFixed(0)}ms) — cut corrected`);
80
- } else {
81
- console.log(`beacon: flip deltas disagree (${(spread * 1000).toFixed(0)}ms spread) — ignoring beacon, cut stays as stamped`);
63
+ // Align the KNOWN flash gaps against the DETECTED onset gaps: slide every
64
+ // flip index against every onset index and count consecutive gap matches
65
+ // (tolerance 70 ms). Three flashes in a row (two matching gaps) identify
66
+ // the pattern unambiguously because the gaps strictly increase; the
67
+ // alignment with the most matches wins. Missing leading flashes (a
68
+ // screencast that started late) cost nothing but the pairs they would
69
+ // have added.
70
+ const TOL = 0.07;
71
+ let best = null;
72
+ for (let i = 0; i < flips.length - 2; i++) {
73
+ for (let j = 0; j < onsets.length - 2; j++) {
74
+ let k = 0;
75
+ while (i + k + 1 < flips.length && j + k + 1 < onsets.length) {
76
+ const gf = flips[i + k + 1].wall - flips[i + k].wall;
77
+ const go = onsets[j + k + 1] - onsets[j + k];
78
+ if (Math.abs(gf - go) > TOL) break;
79
+ k++;
80
+ }
81
+ if (k >= 2 && (!best || k > best.k)) best = { i, j, k };
82
+ }
83
+ }
84
+ if (best) {
85
+ const deltas = [];
86
+ for (let m = 0; m <= best.k; m++) deltas.push(flips[best.i + m].wall - onsets[best.j + m]);
87
+ const spread = Math.max(...deltas) - Math.min(...deltas);
88
+ beaconDelta = deltas.reduce((a, b) => a + b, 0) / deltas.length;
89
+ const missed = best.i;
90
+ beaconMethod = ` + head-beacon anchor ${beaconDelta >= 0 ? "+" : ""}${beaconDelta.toFixed(3)}s (${deltas.length} flashes matched, ${missed} missed, spread ${(spread * 1000).toFixed(0)}ms)`;
91
+ console.log(`beacon: anchor error ${beaconDelta >= 0 ? "+" : ""}${beaconDelta.toFixed(3)}s measured from ${deltas.length} flashes (${missed} before the first frame, spread ${(spread * 1000).toFixed(0)}ms) — cut corrected`);
92
+ if (spread > 0.15) {
93
+ console.error(`BEACON FAILED: matched flashes disagree by ${(spread * 1000).toFixed(0)}ms. The cut point cannot be trusted — do not upload, film again.`);
94
+ process.exit(3);
82
95
  }
83
96
  } else {
84
- console.log("beacon: flashes not found in the raw head — cut stays as stamped");
97
+ // INCIDENT 2026-09-02: a missed beacon staged a take whose head was cut
98
+ // seconds late (screencast started after the flashes). The stamped cut
99
+ // is only right when the screencast began at wall 0, which nothing
100
+ // guarantees — so a recorded-but-unfound beacon is a hard stop.
101
+ console.error(`BEACON FAILED: no run of the flash pattern found in the raw head (${onsets.length} onsets seen). The startup lead is unmeasured and the cut point cannot be trusted. Do not upload — film again (a busy machine delays the screencast; let builds finish first).`);
102
+ process.exit(3);
85
103
  }
86
104
  }
87
105
 
88
106
  const cutAt = Math.max(0, r0 - beaconDelta);
107
+
108
+ // TAIL law (vanilla-Linux smoke 2026-09-09): raw.webm keeps receiving frames
109
+ // while the browser context tears down — on a headless shell that was a
110
+ // ~10 s FROZEN tail after the last beat, and the agent had to cut it by hand.
111
+ // The story ends where the recorder's last step ended; keep one breath after
112
+ // it and cut the rest. Clean time = wall - record_from (identity law above),
113
+ // so the story's end in raw time is cutAt + (lastStepEnd - r0). Only applied
114
+ // when the raw file actually runs on past that point.
115
+ const TAIL_BEAT_S = 0.8;
116
+ const lastStepEnd = Math.max(0, ...(man.steps ?? []).map((st) => Number(st.t_end) || 0));
117
+ let tailCut = 0;
118
+ if (lastStepEnd > r0) {
119
+ const storyEndRaw = cutAt + (lastStepEnd - r0) + TAIL_BEAT_S;
120
+ let rawDur = 0;
121
+ try {
122
+ rawDur = parseFloat(execFileSync(FFPROBE(), ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", raw]).toString().trim()) || 0;
123
+ } catch { /* webm without a duration header — fall through, no tail cut */ }
124
+ if (rawDur && rawDur - storyEndRaw > 1.0) {
125
+ tailCut = Math.round(storyEndRaw * 100) / 100;
126
+ console.log(`tail: raw runs ${(rawDur - storyEndRaw).toFixed(1)}s past the last beat — cutting at ${tailCut.toFixed(2)}s (last step ended at ${(lastStepEnd - r0).toFixed(2)}s of clean time)`);
127
+ }
128
+ }
89
129
  if (cutAt !== r0 - beaconDelta) console.log("beacon: corrected cut clamped at 0 — head shorter than the anchor error");
90
130
  man.timebase = {
91
131
  a: 1,
@@ -98,17 +138,17 @@ man.timebase = {
98
138
  fs.writeFileSync(manPath, JSON.stringify(man, null, 2));
99
139
  console.log(`timebase: identity, video = wall - ${r0.toFixed(3)}${beaconMethod ? ` (cut at raw ${cutAt.toFixed(3)}s)` : ""}`);
100
140
 
101
- execFileSync("ffmpeg", [
141
+ execFileSync(FFMPEG(), [
102
142
  "-y", "-loglevel", "error",
103
143
  "-i", raw,
104
- "-filter_complex", `[0:v]trim=start=${cutAt},setpts=PTS-STARTPTS,fps=30,format=yuv420p[out]`,
144
+ "-filter_complex", `[0:v]trim=${tailCut ? `start=${cutAt}:end=${tailCut}` : `start=${cutAt}`},setpts=PTS-STARTPTS,fps=30,format=yuv420p[out]`,
105
145
  "-map", "[out]",
106
146
  "-c:v", "libx264", "-preset", "medium", "-crf", "19",
107
147
  "-movflags", "+faststart",
108
148
  clean,
109
149
  ], { stdio: "inherit" });
110
150
 
111
- const dur = execFileSync("ffprobe", [
151
+ const dur = execFileSync(FFPROBE(), [
112
152
  "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", clean,
113
153
  ]).toString().trim();
114
154
  const duration = parseFloat(dur);
package/skill/SKILL.md CHANGED
@@ -7,7 +7,7 @@ description: Film a product demo by driving a real browser from a storyboard and
7
7
 
8
8
  You are the camera operator, the director, and the editor. You film a real browser doing a real flow, narrate it, and deliver a clean take into DemoBites, where everything — voice, camera, cursor, look — becomes editable. There is ONE delivery: a DemoBites bite. Never ask how the demo should be delivered.
9
9
 
10
- All scripts live in `scripts/` beside this file. They are plain Node ESM. Requirements: Node 18+, `playwright` installed with Chromium (`npm i playwright && npx playwright install chromium`), and `ffmpeg` on PATH. Run every script from the project directory so `.recorder/` lands next to the project.
10
+ All scripts live in `scripts/` beside this file. They are plain Node ESM. Requirements: Node 18+. `npx demobite` installs Playwright, ffmpeg and ffprobe beside the skill; every script resolves the media tools through `scripts/media-tools.mjs` (a compatible system build first, then the packaged one). Never call `ffmpeg` or `ffprobe` by bare name in a new script. Run every script from the project directory so `.recorder/` lands next to the project.
11
11
 
12
12
  Follow the phases in order. Never skip the storyboard approval. Never ingest before the human's word — for DemoBites, Approve on the in-app preview page IS the word.
13
13
 
@@ -29,6 +29,7 @@
29
29
  import fs from "node:fs";
30
30
  import path from "node:path";
31
31
  import { execFileSync, spawnSync } from "node:child_process";
32
+ import { ffmpeg as FFMPEG, ffprobe as FFPROBE } from "./media-tools.mjs";
32
33
 
33
34
  const args = process.argv.slice(2);
34
35
  const dir = args[0];
@@ -73,7 +74,7 @@ let duration = round2(Math.max(0, A * (man.duration ?? 0) + B));
73
74
  const cleanPath = path.join(dir, "clean.mp4");
74
75
  if (fs.existsSync(cleanPath)) {
75
76
  try {
76
- duration = round2(parseFloat(execFileSync("ffprobe", [
77
+ duration = round2(parseFloat(execFileSync(FFPROBE(), [
77
78
  "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", cleanPath,
78
79
  ]).toString().trim()));
79
80
  } catch {
@@ -215,7 +216,7 @@ if (fs.existsSync(cleanPath)) {
215
216
  // count exists because the bbox alone lies: two tiny unrelated changes
216
217
  // far apart (caret + spinner) span a huge, nearly-empty bbox (review
217
218
  // finding, 2026-08-09).
218
- const res = spawnSync("ffmpeg", [
219
+ const res = spawnSync(FFMPEG(), [
219
220
  "-loglevel", "info",
220
221
  "-ss", String(Math.max(0, tPre)), "-i", cleanPath,
221
222
  "-ss", String(Math.min(duration - 0.05, tPost)), "-i", cleanPath,