demobite 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DemoBites
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # demobite
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.
8
+
9
+ ```bash
10
+ npx demobite@latest
11
+ ```
12
+
13
+ That one command checks your setup, installs the recorder skill for
14
+ [Claude Code](https://claude.com/claude-code), and connects your machine to
15
+ DemoBites through your own browser — no passwords in the terminal, ever.
16
+ Then you just ask your agent:
17
+
18
+ > "Record a demo of how search works on our app, and upload it to DemoBites."
19
+
20
+ The agent storyboards the flow, films it in a real Chrome, and stages the take
21
+ for your approval inside DemoBites. You approve in the product; the platform
22
+ does the rest.
23
+
24
+ ## What's in this repository
25
+
26
+ | Directory | What it is |
27
+ |---|---|
28
+ | `launcher/` | The `npx demobite` entry — environment checks, skill install, login |
29
+ | `skill/` | The DemoBites recorder skill for Claude Code (staging, preview, approval flow) |
30
+ | `recorder/` | The open recorder — same filming engine, no account, ends at a polished `demo.mp4` |
31
+ | `scripts/` | The shared engine: filming, clock calibration, cutting |
32
+
33
+ ## Just want the recorder, no DemoBites?
34
+
35
+ The `recorder/` directory is a standalone skill: the same real-browser filming,
36
+ hover-anchor clock calibration and camera work, delivering a finished, styled
37
+ `demo.mp4` on your disk — no account, no upload. Point Claude Code at it and
38
+ film. When you want narration, zooms, an editable timeline and hosting, the
39
+ sibling skill in `skill/` is one login away.
40
+
41
+ ## How updates reach you
42
+
43
+ Run with `@latest` and every invocation resolves the newest published version —
44
+ the skill you install always matches the DemoBites platform it talks to.
45
+ Releases are published from GitHub Actions with npm provenance: every version
46
+ is cryptographically tied to a public commit in this repository.
47
+
48
+ ## Requirements
49
+
50
+ - Node 18+
51
+ - [Claude Code](https://claude.com/claude-code) — the recorder is agent-driven
52
+ - Google Chrome (recommended; films with the real browser) — otherwise
53
+ Chromium is downloaded on first take
54
+ - ffmpeg (`brew install ffmpeg` on macOS)
55
+
56
+ ## License
57
+
58
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ // demobite — the DemoBites agentic recorder, one command away.
3
+ //
4
+ // npx demobite@latest install/update the skill + check your setup
5
+ // npx demobite@latest login connect this machine to DemoBites
6
+ // npx demobite@latest logout disconnect (revokes the key server-side)
7
+ //
8
+ // This launcher is deliberately boring: it verifies the environment, installs
9
+ // the recorder skill into your agent's skills directory, and hands off. The
10
+ // recorder itself is driven by your coding agent (Claude Code): once set up,
11
+ // you just ask it — "record a demo of how search works on our app".
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { execFileSync, spawnSync } from "node:child_process";
17
+
18
+ const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
19
+ const pkg = JSON.parse(fs.readFileSync(path.join(pkgRoot, "package.json"), "utf8"));
20
+ const arg = process.argv[2] ?? "";
21
+
22
+ const ok = (m) => console.log(` ✓ ${m}`);
23
+ const warn = (m) => console.log(` ! ${m}`);
24
+
25
+ console.log(`\ndemobite v${pkg.version} — the DemoBites agentic recorder\n`);
26
+
27
+ // ── 1. Environment checks ──────────────────────────────────────────────────
28
+ const nodeMajor = Number(process.versions.node.split(".")[0]);
29
+ if (nodeMajor >= 18) ok(`Node ${process.versions.node}`);
30
+ else { warn(`Node ${process.versions.node} — 18+ required`); process.exit(1); }
31
+
32
+ const hasBin = (bin) => {
33
+ try { execFileSync(process.platform === "win32" ? "where" : "which", [bin], { stdio: "ignore" }); return true; }
34
+ catch { return false; }
35
+ };
36
+ const chromePaths = [
37
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
38
+ "C:/Program Files/Google/Chrome/Application/chrome.exe",
39
+ "/usr/bin/google-chrome",
40
+ ];
41
+ if (chromePaths.some((p) => fs.existsSync(p)) || hasBin("google-chrome")) ok("Google Chrome (films with the real browser)");
42
+ else warn("Google Chrome not found — the recorder will download Chromium on first take");
43
+ if (hasBin("ffmpeg")) ok("ffmpeg");
44
+ else warn("ffmpeg not found — install it (macOS: brew install ffmpeg) before recording");
45
+ if (hasBin("claude")) ok("Claude Code (the agent that drives the recorder)");
46
+ else warn("Claude Code not found — install it from https://claude.com/claude-code, the recorder is agent-driven");
47
+
48
+ // ── 2. Install / update the skill ──────────────────────────────────────────
49
+ const skillsDir = path.join(os.homedir(), ".claude", "skills");
50
+ const dest = path.join(skillsDir, "agentic-recorder");
51
+ fs.mkdirSync(dest, { recursive: true });
52
+ fs.mkdirSync(path.join(dest, "scripts"), { recursive: true });
53
+ const copy = (from, to) => fs.copyFileSync(path.join(pkgRoot, from), path.join(dest, to));
54
+ copy("skill/SKILL.md", "SKILL.md");
55
+ for (const f of fs.readdirSync(path.join(pkgRoot, "skill/scripts"))) copy(`skill/scripts/${f}`, `scripts/${f}`);
56
+ for (const f of fs.readdirSync(path.join(pkgRoot, "scripts"))) copy(`scripts/${f}`, `scripts/${f}`);
57
+ ok(`Skill installed → ${dest}`);
58
+
59
+ // Playwright lives with the skill so takes can film.
60
+ if (!fs.existsSync(path.join(dest, "node_modules", "playwright"))) {
61
+ console.log("\n Installing Playwright (one-time)…");
62
+ const r = spawnSync("npm", ["install", "--prefix", dest, "--silent", "playwright"], { stdio: "inherit" });
63
+ if (r.status === 0) ok("Playwright ready");
64
+ else warn("Playwright install failed — run: npm install --prefix ~/.claude/skills/agentic-recorder playwright");
65
+ }
66
+
67
+ // ── 3. Subcommands ─────────────────────────────────────────────────────────
68
+ if (arg === "login" || arg === "logout") {
69
+ const r = spawnSync("node", [path.join(dest, "scripts", "login.mjs"), ...(arg === "logout" ? ["--logout"] : [])], {
70
+ stdio: "inherit",
71
+ cwd: process.cwd(),
72
+ });
73
+ process.exit(r.status ?? 0);
74
+ }
75
+
76
+ // ── 4. Handoff ─────────────────────────────────────────────────────────────
77
+ console.log(`
78
+ Ready. The recorder is agent-driven — open Claude Code in your project and ask:
79
+
80
+ "Record a demo of <your flow> and upload it to DemoBites"
81
+
82
+ It signs in via your browser on first use (or run: npx demobite login).
83
+ Only the recorder, no DemoBites? See the open recorder in this package's repo.
84
+ `);
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "demobite",
3
+ "version": "0.0.1",
4
+ "description": "The DemoBites agentic recorder — you prompt, it films a real browser, and DemoBites turns the take into an editable demo bite.",
5
+ "bin": {
6
+ "demobite": "launcher/index.mjs"
7
+ },
8
+ "files": [
9
+ "launcher",
10
+ "skill",
11
+ "recorder",
12
+ "scripts",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "keywords": [
20
+ "demo",
21
+ "screen-recording",
22
+ "playwright",
23
+ "claude-code",
24
+ "skill",
25
+ "demobites",
26
+ "agentic"
27
+ ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/demobites/demobite.git"
31
+ },
32
+ "homepage": "https://github.com/demobites/demobite#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/demobites/demobite/issues"
35
+ },
36
+ "license": "MIT",
37
+ "type": "module"
38
+ }
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: open-recorder
3
+ description: Film a polished product demo by driving a real browser from a storyboard, delivering a finished styled demo.mp4 — no account required. The open sibling of the DemoBites agentic recorder.
4
+ ---
5
+
6
+ # The Open Recorder
7
+
8
+ The same filming engine as the DemoBites agentic recorder — storyboard-driven
9
+ Playwright capture in a real Chrome, hover-anchor clock calibration, human-pace
10
+ cursor motion — ending at a finished, styled `demo.mp4` on your disk.
11
+
12
+ Phases (shared engine lives in `../scripts/`):
13
+
14
+ 1. **Storyboard** — write the shot list as JSON (see the schema in
15
+ `../skill/SKILL.md`, identical here), show it to the human, get approval.
16
+ 2. **Dry run** — resolve every selector headless before filming.
17
+ 3. **The take** — `node ../scripts/record.mjs <takeDir> <storyboard.json>`
18
+ 4. **Trim + calibrate** — `node ../scripts/trim.mjs <takeDir>` then
19
+ `node ../scripts/calibrate.mjs <takeDir>`
20
+ 5. **Deliver** — the standalone finishing tools in `scripts/`:
21
+ `frame.mjs` (rounded corners + shadow), `post.sh` (backdrop + trim to
22
+ `demo.mp4`), optional `tts.mjs`/`mux.mjs` voiceover with your own
23
+ ElevenLabs key, optional SRT captions.
24
+
25
+ The full standalone-ending reference, preserved verbatim from the original
26
+ skill, is in `STANDALONE-ENDING.md`.
27
+
28
+ Want narration written for you, cinematic zooms, a rendered cursor, an
29
+ editable timeline, hosting and analytics? That is the DemoBites ending — the
30
+ sibling skill in `../skill/`, one login away: `npx demobite login`.
@@ -0,0 +1,34 @@
1
+ # PARKED: the standalone ending (future community skill)
2
+
3
+ This skill is the COMMERCIAL DemoBites recorder — it does not offer a
4
+ standalone MP4. The content below is preserved verbatim for the planned
5
+ open-source community skill and is NOT part of this skill's flow.
6
+ Do not ask the human which ending they want; there is one ending.
7
+
8
+ ### Ending A: STANDALONE
9
+
10
+ Deliver a finished, styled demo.mp4.
11
+
12
+ ```bash
13
+ node scripts/frame.mjs <takeDir> # look overlays: shadow + alpha mask (radius 28)
14
+ scripts/post.sh <takeDir> <backdropHexNo#> # raw.webm -> demo.mp4, backdrop + corners + shadow + trim
15
+ ```
16
+
17
+ Look laws inside: TRUE alpha mask rounded corners (alphamerge, radius 28, never painted on), shadow strength derived from backdrop luminance, `shortest=1` so the still overlays never extend the cut.
18
+
19
+ Optional voiceover, only with the customer's OWN ElevenLabs key (env `ELEVENLABS_API_KEY` or `.recorder/elevenlabs.key`, never ask the human to paste it into chat):
20
+
21
+ ```bash
22
+ node scripts/tts.mjs <takeDir> # narration lines -> measured mp3 segments
23
+ node scripts/mux.mjs <takeDir> # demo.mp4 -> demo-voiced.mp4, anchored to real step timings
24
+ ```
25
+
26
+ Optional captions:
27
+
28
+ ```bash
29
+ node scripts/manifest.mjs <takeDir> --srt # writes captions.srt from the narration timeline
30
+ ffmpeg -i <takeDir>/demo-voiced.mp4 -vf "subtitles=<takeDir>/captions.srt" -c:a copy <takeDir>/demo-captioned.mp4
31
+ ```
32
+
33
+ Deliverables: `demo.mp4` (or `demo-voiced.mp4` / `demo-captioned.mp4`) plus `manifest.json`.
34
+
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ // Standalone ending only — generate the look overlays for post.sh:
3
+ // a shadow ring PNG and a TRUE rounded-alpha mask PNG.
4
+ //
5
+ // LAW (true alpha corners): rounded corners must be a real alpha mask fed to
6
+ // ffmpeg alphamerge — never painted-on corner squares. Radius 28.
7
+ // LAW (shadow from luminance): shadow strength derives from the backdrop's
8
+ // luminance — light backdrops get a soft ink shadow, dark backdrops a deep one.
9
+ //
10
+ // Usage: node frame.mjs <takeDir> [backdropHex]
11
+ // Backdrop resolution order: CLI arg, .recorder/config.json look.backdrop, #0f1420.
12
+ import { chromium } from "playwright";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+
16
+ const [, , outDir, argBackdrop] = process.argv;
17
+ if (!outDir) {
18
+ console.error("Usage: node frame.mjs <takeDir> [backdropHex]");
19
+ process.exit(2);
20
+ }
21
+ let backdrop = argBackdrop;
22
+ if (!backdrop) {
23
+ try {
24
+ backdrop = JSON.parse(fs.readFileSync(path.resolve(".recorder", "config.json"), "utf8")).look?.backdrop;
25
+ } catch {}
26
+ }
27
+ backdrop = backdrop || "#0f1420";
28
+ if (!/^#?[0-9a-fA-F]{6}$/.test(backdrop)) {
29
+ console.error(`Backdrop must be a 6 digit hex color, got "${backdrop}"`);
30
+ process.exit(2);
31
+ }
32
+ if (!backdrop.startsWith("#")) backdrop = "#" + backdrop;
33
+ fs.mkdirSync(path.resolve(outDir), { recursive: true });
34
+
35
+ const hx = backdrop.slice(1);
36
+ const light =
37
+ parseInt(hx.slice(0, 2), 16) * 0.299 +
38
+ parseInt(hx.slice(2, 4), 16) * 0.587 +
39
+ parseInt(hx.slice(4, 6), 16) * 0.114 > 128;
40
+ const W = 1920, H = 1080, VW = 1728, VH = 972, R = 28;
41
+ const X = (W - VW) / 2, Y = (H - VH) / 2;
42
+
43
+ const b = await chromium.launch();
44
+ const p = await b.newPage();
45
+ await p.setViewportSize({ width: W, height: H });
46
+ // Shadow ring: transparent page, rounded rect with only its box-shadow visible.
47
+ const shadow = light
48
+ ? "0 14px 60px rgba(20,22,28,.16), 0 3px 14px rgba(20,22,28,.08)"
49
+ : "0 18px 80px rgba(0,0,0,.5), 0 4px 20px rgba(0,0,0,.3)";
50
+ await p.setContent(`<body style="margin:0;background:transparent"><div style="position:absolute;left:${X}px;top:${Y}px;width:${VW}px;height:${VH}px;border-radius:${R}px;background:transparent;box-shadow:${shadow}"></div></body>`);
51
+ await p.screenshot({ path: path.join(outDir, "shadow.png"), omitBackground: true });
52
+ // Alpha mask: black canvas, white rounded rect — post.sh alphamerges this
53
+ // grayscale source onto the scaled video.
54
+ await p.setViewportSize({ width: VW, height: VH });
55
+ await p.setContent(`<body style="margin:0;background:#000"><div style="position:absolute;inset:0;border-radius:${R}px;background:#fff"></div></body>`);
56
+ await p.screenshot({ path: path.join(outDir, "mask.png") });
57
+ await b.close();
58
+ console.log("overlays ready (radius", R + "px,", light ? "light" : "dark", "shadow,", "backdrop", backdrop + ")");
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ // Standalone ending only — mux the measured TTS segments onto the composited
3
+ // cut (demo.mp4), anchored to the manifest's REAL step timings. The script is
4
+ // the metronome: each line starts when its step starts, minus the trim.
5
+ //
6
+ // Founder-proven anchor tweaks (kept, commented):
7
+ // * the OPENING line gets a +0.3s beat after the hold starts, so the voice
8
+ // never fires on the very first frame
9
+ // * a line attached to a SCROLL step starts 1.5s early — it describes the
10
+ // page that just revealed, so it lands as the page settles, before the
11
+ // scroll moves
12
+ //
13
+ // Usage: node mux.mjs <takeDir>
14
+ // Reads <takeDir>/demo.mp4 + <takeDir>/manifest.json + <takeDir>/tts/tts.json.
15
+ // Writes <takeDir>/demo-voiced.mp4.
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { execFileSync } from "node:child_process";
19
+
20
+ const dir = process.argv[2];
21
+ if (!dir) {
22
+ console.error("Usage: node mux.mjs <takeDir>");
23
+ process.exit(2);
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");
34
+
35
+ const demoPath = path.join(dir, "demo.mp4");
36
+ const manPath = path.join(dir, "manifest.json");
37
+ const ttsPath = path.join(dir, "tts", "tts.json");
38
+ if (!fs.existsSync(demoPath)) { console.error(`${demoPath} not found. Run frame.mjs + post.sh first.`); process.exit(1); }
39
+ if (!fs.existsSync(manPath)) { console.error(`${manPath} not found. Run record.mjs first.`); process.exit(1); }
40
+ if (!fs.existsSync(ttsPath)) { console.error(`${ttsPath} not found. Run tts.mjs first.`); process.exit(1); }
41
+
42
+ const man = JSON.parse(fs.readFileSync(manPath, "utf8"));
43
+ const tts = JSON.parse(fs.readFileSync(ttsPath, "utf8"));
44
+ const r0 = man.record_from ?? 0;
45
+
46
+ // Anchor each narrated step to its real start time, normalized to the cut.
47
+ const narrated = (man.steps ?? []).filter((s) => s.narration?.text);
48
+ const anchors = new Map();
49
+ let first = true;
50
+ for (const s of narrated) {
51
+ let at = (s.t_start ?? 0) - r0;
52
+ if (first) { at += 0.3; first = false; } // opening line, small beat after the hold starts
53
+ if (s.action === "scroll") at -= 1.5; // page reveal line starts as the page settles, before the scroll
54
+ anchors.set(s.n, Math.max(0.05, Math.round(at * 100) / 100));
55
+ }
56
+ console.log("narration anchors (s):", JSON.stringify(Object.fromEntries(anchors)));
57
+
58
+ const inputArgs = [];
59
+ for (const seg of tts) inputArgs.push("-i", path.join(dir, "tts", seg.file));
60
+ const delays = tts
61
+ .map((seg, i) => {
62
+ const ms = Math.round((anchors.get(seg.n) ?? 0) * 1000);
63
+ return `[${i + 1}:a]adelay=${ms}|${ms}[a${i}]`;
64
+ })
65
+ .join(";");
66
+ const mix = tts.map((_, i) => `[a${i}]`).join("") + `amix=inputs=${tts.length}:normalize=0[aout]`;
67
+
68
+ const outPath = path.join(dir, "demo-voiced.mp4");
69
+ execFileSync("ffmpeg", [
70
+ "-y", "-loglevel", "error",
71
+ "-i", demoPath,
72
+ ...inputArgs,
73
+ "-filter_complex", `${delays};${mix}`,
74
+ "-map", "0:v", "-map", "[aout]",
75
+ "-c:v", "copy", "-c:a", "aac", "-b:a", "160k",
76
+ outPath,
77
+ ], { stdio: "inherit" });
78
+ const dur = execFileSync("ffprobe", [
79
+ "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", outPath,
80
+ ]).toString().trim();
81
+ console.log(`demo-voiced.mp4 ready, ${parseFloat(dur).toFixed(2)}s`);
@@ -0,0 +1,26 @@
1
+ #!/bin/bash
2
+ # Standalone ending only — raw.webm -> demo.mp4.
3
+ # Composites the look: backdrop color, TRUE rounded-alpha corners (mask via
4
+ # alphamerge, generated by frame.mjs), shadow ring, and the record_from trim.
5
+ #
6
+ # LAW (shortest=1): the still PNG overlays are infinite inputs — shortest=1 on
7
+ # the final overlay keeps the cut exactly as long as the video, never longer.
8
+ # LAW (first-frame trim): trim=start=record_from so the published cut opens on
9
+ # the fully loaded page that record.mjs stamped.
10
+ #
11
+ # Usage: post.sh <takeDir> [backdrop-hex-no-#] (default backdrop 0f1420)
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; }
14
+ command -v node >/dev/null 2>&1 || { echo "node is required on PATH." >&2; exit 1; }
15
+ D=${1:?Usage: post.sh <takeDir> [backdrop-hex-no-#]}
16
+ # Backdrop resolution mirrors frame.mjs exactly: CLI arg, then the remembered
17
+ # .recorder/config.json look.backdrop, then #0f1420 — the two MUST agree or
18
+ # the corner mask color and the composited backdrop drift apart.
19
+ BG=${2:-$(node -p "try{(JSON.parse(require('fs').readFileSync('.recorder/config.json','utf8')).look||{}).backdrop?.replace('#','')||'0f1420'}catch{'0f1420'}")}
20
+ [ -f "$D/raw.webm" ] || { echo "$D/raw.webm not found. Run record.mjs first." >&2; exit 1; }
21
+ [ -f "$D/shadow.png" ] && [ -f "$D/mask.png" ] || { echo "Overlays missing. Run: node frame.mjs $D" >&2; exit 1; }
22
+ 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 \
24
+ "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
+ -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"
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ // Standalone ending only — voice the storyboard with the customer's OWN
3
+ // ElevenLabs key. Reads the narration lines from the take's manifest,
4
+ // synthesizes one mp3 per line, measures each with ffprobe, and writes
5
+ // <takeDir>/tts/tts.json so mux.mjs (and manifest.mjs) use MEASURED
6
+ // durations instead of estimates.
7
+ //
8
+ // Key resolution (never stored in config.json): ELEVENLABS_API_KEY env var,
9
+ // else the file .recorder/elevenlabs.key. The key never leaves this machine
10
+ // except to the ElevenLabs API.
11
+ //
12
+ // Usage: node tts.mjs <takeDir>
13
+ // Voice: .recorder/config.json tts.voice_id, else Alice (premade, free tier OK).
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { execFileSync } from "node:child_process";
17
+
18
+ const dir = process.argv[2];
19
+ if (!dir) {
20
+ console.error("Usage: node tts.mjs <takeDir>");
21
+ process.exit(2);
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");
31
+
32
+ let KEY = process.env.ELEVENLABS_API_KEY || "";
33
+ if (!KEY) {
34
+ try { KEY = fs.readFileSync(path.resolve(".recorder", "elevenlabs.key"), "utf8").trim(); } catch {}
35
+ }
36
+ if (!KEY) {
37
+ console.error("No ElevenLabs key. Set ELEVENLABS_API_KEY or put the key in .recorder/elevenlabs.key");
38
+ process.exit(1);
39
+ }
40
+
41
+ let cfg = {};
42
+ try { cfg = JSON.parse(fs.readFileSync(path.resolve(".recorder", "config.json"), "utf8")); } catch {}
43
+ const VOICE = cfg.tts?.voice_id || "Xb7hH8MSUJpSbSDYk0k2"; // Alice, clear engaging educator
44
+
45
+ const manPath = path.join(dir, "manifest.json");
46
+ if (!fs.existsSync(manPath)) { console.error(`${manPath} not found. Run record.mjs first.`); process.exit(1); }
47
+ const man = JSON.parse(fs.readFileSync(manPath, "utf8"));
48
+ const narrated = (man.steps ?? []).filter((s) => s.narration?.text);
49
+ if (narrated.length === 0) { console.error("No narration lines in the manifest."); process.exit(1); }
50
+
51
+ const outDir = path.join(dir, "tts");
52
+ fs.mkdirSync(outDir, { recursive: true });
53
+ const out = [];
54
+ for (const step of narrated) {
55
+ const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${VOICE}?output_format=mp3_44100_128`, {
56
+ method: "POST",
57
+ headers: { "xi-api-key": KEY, "Content-Type": "application/json" },
58
+ body: JSON.stringify({
59
+ text: step.narration.text,
60
+ model_id: "eleven_multilingual_v2",
61
+ voice_settings: { stability: 0.5, similarity_boost: 0.7 },
62
+ }),
63
+ });
64
+ if (!res.ok) {
65
+ console.error("TTS failed for step", step.n, res.status, await res.text());
66
+ process.exit(1);
67
+ }
68
+ const buf = Buffer.from(await res.arrayBuffer());
69
+ const file = `seg${step.n}.mp3`;
70
+ fs.writeFileSync(path.join(outDir, file), buf);
71
+ const dur = parseFloat(execFileSync("ffprobe", [
72
+ "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path.join(outDir, file),
73
+ ]).toString());
74
+ out.push({ n: step.n, text: step.narration.text, file, duration: Math.round(dur * 100) / 100 });
75
+ console.log(`step ${step.n}: ${dur.toFixed(2)}s "${step.narration.text}"`);
76
+ }
77
+ fs.writeFileSync(path.join(outDir, "tts.json"), JSON.stringify(out, null, 2));
78
+ console.log(`tts.json written (${out.length} segments) in ${outDir}`);