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 +125 -79
- package/launcher/index.mjs +37 -9
- package/package.json +7 -3
- package/recorder/scripts/mux.mjs +4 -11
- package/recorder/scripts/post.sh +6 -3
- package/recorder/scripts/tts.mjs +3 -9
- package/scripts/calibrate.mjs +38 -9
- package/scripts/check-aliases.mjs +42 -0
- package/scripts/media-tools.mjs +93 -0
- package/scripts/record.mjs +20 -15
- package/scripts/trim.mjs +68 -28
- package/skill/SKILL.md +1 -1
- package/skill/scripts/manifest.mjs +3 -2
package/README.md
CHANGED
|
@@ -1,115 +1,161 @@
|
|
|
1
|
-
#
|
|
1
|
+
# DemoBites Agentic Recorder
|
|
2
2
|
|
|
3
|
-
**
|
|
4
|
-
|
|
5
|
-
and
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
21
|
+
An illustrated walkthrough of the flow, with waiting time compressed.
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
## Start with your own agent
|
|
23
24
|
|
|
24
|
-
|
|
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
|
-
|
|
31
|
-
for your approval inside DemoBites. You approve in the product; the platform
|
|
32
|
-
does the rest.
|
|
27
|
+
Before installing:
|
|
33
28
|
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
+
## One prompt, one result
|
|
54
85
|
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
96
|
+
## Your app login stays local
|
|
80
97
|
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
+
Keep `.recorder/` and take directories out of version control. To disconnect the recorder, run `npx demobite logout`.
|
|
99
111
|
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
-
|
|
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
|
-
|
|
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/).
|
package/launcher/index.mjs
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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(
|
|
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
|
|
4
|
-
"description": "The DemoBites agentic recorder
|
|
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
|
}
|
package/recorder/scripts/mux.mjs
CHANGED
|
@@ -22,15 +22,8 @@ if (!dir) {
|
|
|
22
22
|
console.error("Usage: node mux.mjs <takeDir>");
|
|
23
23
|
process.exit(2);
|
|
24
24
|
}
|
|
25
|
-
|
|
26
|
-
|
|
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(
|
|
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(
|
|
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`);
|
package/recorder/scripts/post.sh
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
#
|
|
11
11
|
# Usage: post.sh <takeDir> [backdrop-hex-no-#] (default backdrop 0f1420)
|
|
12
12
|
set -euo pipefail
|
|
13
|
-
|
|
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
|
-
|
|
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: $(
|
|
29
|
+
echo "demo.mp4 duration: $("$FFPROBE" -v error -show_entries format=duration -of csv=p=0 "$D/demo.mp4")s"
|
package/recorder/scripts/tts.mjs
CHANGED
|
@@ -20,14 +20,8 @@ if (!dir) {
|
|
|
20
20
|
console.error("Usage: node tts.mjs <takeDir>");
|
|
21
21
|
process.exit(2);
|
|
22
22
|
}
|
|
23
|
-
|
|
24
|
-
|
|
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(
|
|
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 });
|
package/scripts/calibrate.mjs
CHANGED
|
@@ -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(
|
|
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 =
|
|
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
|
-
|
|
175
|
-
|
|
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(
|
|
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 =
|
|
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
|
+
}
|
package/scripts/record.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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(
|
|
647
|
+
await page.waitForTimeout(1500); // let the page exist and the white state settle
|
|
648
648
|
const flips = [];
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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) >=
|
|
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) +
|
|
64
|
-
const res = spawnSync(
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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(
|
|
141
|
+
execFileSync(FFMPEG(), [
|
|
102
142
|
"-y", "-loglevel", "error",
|
|
103
143
|
"-i", raw,
|
|
104
|
-
"-filter_complex", `[0:v]trim
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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,
|