demobite 1.0.5 → 1.0.7
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/launcher/index.mjs +15 -0
- package/package.json +1 -1
- package/scripts/record.mjs +186 -18
- package/scripts/trim.mjs +46 -4
- package/skill/SKILL.md +29 -1
- package/skill/scripts/retake.mjs +76 -0
- package/skill/scripts/upload.mjs +21 -1
package/launcher/index.mjs
CHANGED
|
@@ -113,6 +113,21 @@ Other MCP clients: any Streamable HTTP client works with the same URL + header.
|
|
|
113
113
|
return false;
|
|
114
114
|
};
|
|
115
115
|
|
|
116
|
+
if (arg === "retake") {
|
|
117
|
+
const biteId = process.argv[3];
|
|
118
|
+
if (!biteId || !/^\d+$/.test(biteId)) { warn('Usage: npx demobite retake <biteId> [--note "what changed"]'); process.exit(2); }
|
|
119
|
+
let cfg = readCfg();
|
|
120
|
+
if (!cfg?.api_key) {
|
|
121
|
+
console.log("\n Not connected yet — linking this machine to DemoBites first…\n");
|
|
122
|
+
const r = spawnSync("node", [path.join(dest, "scripts", "login.mjs")], { stdio: "inherit", cwd: process.cwd() });
|
|
123
|
+
if (r.status !== 0) process.exit(r.status ?? 1);
|
|
124
|
+
cfg = readCfg();
|
|
125
|
+
}
|
|
126
|
+
if (!cfg?.api_key) { warn("Login did not complete — run: npx demobite login"); process.exit(1); }
|
|
127
|
+
const r = spawnSync("node", [path.join(dest, "scripts", "retake.mjs"), ...process.argv.slice(3)], { stdio: "inherit", cwd: process.cwd() });
|
|
128
|
+
process.exit(r.status ?? 1);
|
|
129
|
+
}
|
|
130
|
+
|
|
116
131
|
if (arg === "mcp") {
|
|
117
132
|
let cfg = readCfg();
|
|
118
133
|
if (!cfg?.api_key) {
|
package/package.json
CHANGED
package/scripts/record.mjs
CHANGED
|
@@ -36,6 +36,24 @@ for (const [i, s] of STORYBOARD.steps.entries()) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
const DIR = path.resolve(outArg);
|
|
39
|
+
|
|
40
|
+
// RE-TAKE (founder 2026-09-02): the storyboard IS the bite's DNA. Keep a verbatim
|
|
41
|
+
// copy in the take dir so upload.mjs can stage it as the recording recipe —
|
|
42
|
+
// selectors, urls, typed text, hideCss — the wire manifest alone cannot re-film.
|
|
43
|
+
const ENGINE_VERSION = "1.0.7";
|
|
44
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
45
|
+
// The copy is the RECIPE, not the transport: cdpWsUrl / storageStatePath are
|
|
46
|
+
// per-take plumbing stamped by the cloud runner (a CDP url carries a session
|
|
47
|
+
// token) — never persisted, never staged.
|
|
48
|
+
const { cdpWsUrl: _cdpStamp, storageStatePath: _ssStamp, ...recipeStoryboard } = STORYBOARD;
|
|
49
|
+
fs.writeFileSync(path.join(DIR, "storyboard.json"), JSON.stringify(recipeStoryboard, null, 2));
|
|
50
|
+
try {
|
|
51
|
+
const cfgPath = path.resolve(".recorder/config.json");
|
|
52
|
+
const cfg = fs.existsSync(cfgPath) ? JSON.parse(fs.readFileSync(cfgPath, "utf8")) : {};
|
|
53
|
+
// Only the public shape — NEVER the api_key or workspace.
|
|
54
|
+
const config = { app: STORYBOARD.app ?? cfg.app ?? null, url: STORYBOARD.url ?? cfg.url ?? null, frame: cfg.frame ?? { width: 1920, height: 1080 }, base: cfg.base ?? "https://app.demobites.com" };
|
|
55
|
+
fs.writeFileSync(path.join(DIR, "recipe.json"), JSON.stringify({ version: 1, lane: (process.env.CDP_WS_URL || STORYBOARD.cdpWsUrl) ? "cloud" : "skill", engine: ENGINE_VERSION, config }, null, 2));
|
|
56
|
+
} catch (e) { console.error("recipe.json not written:", e.message); }
|
|
39
57
|
fs.mkdirSync(DIR, { recursive: true });
|
|
40
58
|
|
|
41
59
|
// LAW (supersampled capture, measured 2026-08-09): deviceScaleFactor is a
|
|
@@ -144,17 +162,54 @@ const launchOpts = {
|
|
|
144
162
|
deviceScaleFactor: 1,
|
|
145
163
|
args: ["--force-color-profile=srgb", "--disable-blink-features=AutomationControlled"],
|
|
146
164
|
};
|
|
165
|
+
|
|
166
|
+
// THREE ways to get a browser. ONE script serves both lanes — the cloud
|
|
167
|
+
// sandbox runs THIS file. (2026-09-02: a sync that carried only mode C into
|
|
168
|
+
// the sandbox copy crashed every cloud film leg ~85 s after bootstrap.)
|
|
169
|
+
// A. CDP mode (CDP_WS_URL env or storyboard.cdpWsUrl): connect to a browser
|
|
170
|
+
// someone else PROVISIONED (Surfsky / Browserbase / sandbox endpoint) and
|
|
171
|
+
// film in a fresh recording context on it. Never creates or tears down
|
|
172
|
+
// the remote browser — the provisioner owns it. A connected browser has
|
|
173
|
+
// no persistent-context video, so newContext({recordVideo}) is the way.
|
|
174
|
+
// B. Storage-state mode (STORAGE_STATE env or storyboard.storageStatePath,
|
|
175
|
+
// no CDP): a signed-in take. The caller decrypts a vaulted Playwright
|
|
176
|
+
// storageState ({cookies, origins:[{origin, localStorage}]}) to a file
|
|
177
|
+
// and hands us the path; we launch our OWN browser and load that session
|
|
178
|
+
// into a fresh recording context. The file is opaque here — loaded,
|
|
179
|
+
// never logged or copied. storageState is per context, so it also rides
|
|
180
|
+
// into mode A's context when both are set.
|
|
181
|
+
// C. Launch mode (default): the local persistent Chrome for public urls.
|
|
147
182
|
let ctx;
|
|
183
|
+
let connectedBrowser = null;
|
|
184
|
+
let ownedBrowser = null;
|
|
185
|
+
const cdpWsUrl = process.env.CDP_WS_URL || STORYBOARD.cdpWsUrl || null;
|
|
186
|
+
const storageStatePath = process.env.STORAGE_STATE || STORYBOARD.storageStatePath || null;
|
|
187
|
+
const contextOpts = {
|
|
188
|
+
viewport: VIEW,
|
|
189
|
+
recordVideo: { dir: DIR, size: VIEW },
|
|
190
|
+
deviceScaleFactor: 1,
|
|
191
|
+
...(storageStatePath ? { storageState: storageStatePath } : {}),
|
|
192
|
+
};
|
|
148
193
|
const wantChannel = STORYBOARD.channel ?? "chrome";
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
194
|
+
const launchWithFallback = async (launch) => {
|
|
195
|
+
try {
|
|
196
|
+
return await launch(wantChannel === "chromium" ? {} : { channel: wantChannel });
|
|
197
|
+
} catch (launchErr) {
|
|
198
|
+
if (wantChannel === "chromium") throw launchErr;
|
|
199
|
+
console.error(`Real Chrome (channel '${wantChannel}') failed to launch (${launchErr.message.split("\n")[0]}); falling back to bundled Chromium.`);
|
|
200
|
+
return await launch({});
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
if (cdpWsUrl) {
|
|
204
|
+
connectedBrowser = await chromium.connectOverCDP(cdpWsUrl);
|
|
205
|
+
ctx = await connectedBrowser.newContext(contextOpts);
|
|
206
|
+
console.error(`CDP mode: filming in a provided context (${connectedBrowser.contexts().length - 1} pre-existing)${storageStatePath ? ", signed-in session loaded" : ""}.`);
|
|
207
|
+
} else if (storageStatePath) {
|
|
208
|
+
ownedBrowser = await launchWithFallback((ch) => chromium.launch({ headless: launchOpts.headless, args: launchOpts.args, ...ch }));
|
|
209
|
+
ctx = await ownedBrowser.newContext(contextOpts);
|
|
210
|
+
console.error("Storage-state mode: own browser, signed-in session loaded into a fresh recording context.");
|
|
211
|
+
} else {
|
|
212
|
+
ctx = await launchWithFallback((ch) => chromium.launchPersistentContext(profileDir, { ...launchOpts, ...ch }));
|
|
158
213
|
}
|
|
159
214
|
const page = ctx.pages()[0] || (await ctx.newPage());
|
|
160
215
|
|
|
@@ -315,6 +370,21 @@ const pushShot = (box, tStart, tEnd, label, extra) => {
|
|
|
315
370
|
});
|
|
316
371
|
};
|
|
317
372
|
|
|
373
|
+
// User-approved zoom (storyboard flow 2026-08-20, cloud planner + storyboard
|
|
374
|
+
// page): a step may carry zoom {x,y,w,h} in PERCENT of the frame, adjusted by
|
|
375
|
+
// the customer before filming. It overrides the auto shot box. zoom:null means
|
|
376
|
+
// the customer removed the zoom — NO shot for that step. Absent = classic auto.
|
|
377
|
+
const userShotBox = (step, fallback) => {
|
|
378
|
+
if (step.zoom === null) return null;
|
|
379
|
+
if (!step.zoom) return fallback ?? null;
|
|
380
|
+
return {
|
|
381
|
+
x: (step.zoom.x / 100) * VIEW.width,
|
|
382
|
+
y: (step.zoom.y / 100) * VIEW.height,
|
|
383
|
+
width: (step.zoom.w / 100) * VIEW.width,
|
|
384
|
+
height: (step.zoom.h / 100) * VIEW.height,
|
|
385
|
+
};
|
|
386
|
+
};
|
|
387
|
+
|
|
318
388
|
/** LAW (visible-instance pick, dry-run lesson): sticky-header twins and
|
|
319
389
|
* offscreen duplicates shadow the real control — take the first VISIBLE match
|
|
320
390
|
* whose top clears minY, retrying while the page settles. */
|
|
@@ -323,8 +393,10 @@ async function visibleTarget(selector, minY = 0, timeout = 15000) {
|
|
|
323
393
|
const waitT0 = Date.now();
|
|
324
394
|
const all = page.locator(selector);
|
|
325
395
|
await all.first().waitFor({ state: "attached", timeout }).catch(() => {});
|
|
396
|
+
let scrollCorrections = 0;
|
|
326
397
|
for (let tries = 0; tries < 20; tries++) {
|
|
327
398
|
const n = await all.count().catch(() => 0);
|
|
399
|
+
let offVerticalEl = null; // a visible instance that's only off-frame vertically
|
|
328
400
|
for (let i = 0; i < n; i++) {
|
|
329
401
|
const el = all.nth(i);
|
|
330
402
|
if (!(await el.isVisible().catch(() => false))) continue;
|
|
@@ -336,10 +408,26 @@ async function visibleTarget(selector, minY = 0, timeout = 15000) {
|
|
|
336
408
|
// cursor 360px off the 1920 frame and the click landed off-camera.
|
|
337
409
|
// A target's CENTER must be inside the recorded frame, with margin.
|
|
338
410
|
const cx = b.x + b.width / 2, cy = b.y + b.height / 2;
|
|
339
|
-
|
|
411
|
+
const offHoriz = cx < 8 || cx > VIEW.width - 8;
|
|
412
|
+
const offVert = cy < 8 || cy > VIEW.height - 8;
|
|
413
|
+
if (offHoriz) continue; // a horizontal strip — vertical scroll won't fix it
|
|
414
|
+
if (offVert) { if (!offVerticalEl) offVerticalEl = el; continue; }
|
|
340
415
|
// Return the ELEMENT alongside its box so callers never re-scan.
|
|
341
416
|
if (b.y >= minY * SUPERSAMPLE) { visibleTarget.lastWaitMs = Date.now() - waitT0; return { box: b, el }; }
|
|
342
417
|
}
|
|
418
|
+
// ROBUSTNESS (authored-tour scroll drift): a hover/click target the author
|
|
419
|
+
// named but the authored scroll amounts did not land in the frame is a
|
|
420
|
+
// below/above-fold instance. Travel to it — scroll it toward the upper
|
|
421
|
+
// third and re-scan — instead of failing the whole take. Bounded so a
|
|
422
|
+
// genuinely-unreachable target still ends the loop. This is the camera
|
|
423
|
+
// following the subject, not a camera-path change.
|
|
424
|
+
if (offVerticalEl && scrollCorrections < 3) {
|
|
425
|
+
scrollCorrections++;
|
|
426
|
+
await offVerticalEl.scrollIntoViewIfNeeded({ timeout: 2000 }).catch(() => {});
|
|
427
|
+
await page.evaluate(() => window.scrollBy(0, -Math.round(window.innerHeight * 0.28))).catch(() => {});
|
|
428
|
+
await page.waitForTimeout(450);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
343
431
|
await page.waitForTimeout(500);
|
|
344
432
|
}
|
|
345
433
|
return { box: null, el: null };
|
|
@@ -527,6 +615,49 @@ async function smoothScroll(dy, ms = 1400, within = null) {
|
|
|
527
615
|
}), [dy, ms, within]);
|
|
528
616
|
}
|
|
529
617
|
|
|
618
|
+
// ── Head beacon ────────────────────────────────────────────────────────────
|
|
619
|
+
// The identity law (video = wall - record_from) assumes raw.webm's t=0 lines
|
|
620
|
+
// up with T0. That anchoring is otherwise UNMEASURED: on a cold remote-CDP
|
|
621
|
+
// browser it drifts by seconds (the 2026-08-21 Product Hunt class) and even
|
|
622
|
+
// locally the startup lead swings 0–2.5 s between takes (2026-09-02). So
|
|
623
|
+
// MEASURE it: flash the still-blank page full-frame at stamped wall times
|
|
624
|
+
// before any content loads. trim.mjs finds the flashes in raw.webm by scene
|
|
625
|
+
// detection and corrects the cut point; both flashes sit before record_from,
|
|
626
|
+
// so the published cut never contains them. Hover-anchor calibration then
|
|
627
|
+
// measures only the residual.
|
|
628
|
+
try {
|
|
629
|
+
const flip = async (color) => {
|
|
630
|
+
const before = t();
|
|
631
|
+
await page.evaluate((c) => { document.documentElement.style.background = c; }, color);
|
|
632
|
+
const after = t();
|
|
633
|
+
// The paint happened between call and return; the midpoint bounds the
|
|
634
|
+
// stamp error at half a CDP round trip — inside one frame co-located.
|
|
635
|
+
return { color, wall: (before + after) / 2 };
|
|
636
|
+
};
|
|
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
|
+
}
|
|
646
|
+
await page.evaluate(() => { document.documentElement.style.background = "#ffffff"; });
|
|
647
|
+
await page.waitForTimeout(600); // let the white state reach the recording
|
|
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);
|
|
653
|
+
manifest.beacon = { flips };
|
|
654
|
+
process.stdout.write(`beacon: flips at ${flips.map((f) => f.wall.toFixed(3)).join("s, ")}s (wall)\n`);
|
|
655
|
+
} catch (e) {
|
|
656
|
+
// A failed beacon must never kill a take — trim just falls back to the
|
|
657
|
+
// stamped anchor, exactly the pre-beacon behavior.
|
|
658
|
+
console.error("beacon: skipped (" + e.message.split("\n")[0] + ")");
|
|
659
|
+
}
|
|
660
|
+
|
|
530
661
|
let failure = null;
|
|
531
662
|
try {
|
|
532
663
|
for (const step of STORYBOARD.steps) {
|
|
@@ -543,7 +674,10 @@ try {
|
|
|
543
674
|
process.stdout.write(`step ${rec.n} ${step.action} ${step.label ?? ""}\n`);
|
|
544
675
|
if (step.action === "goto") {
|
|
545
676
|
const navIssuedAt = t();
|
|
546
|
-
|
|
677
|
+
// domcontentloaded, not load: a heavy site behind a proxy (the cloud
|
|
678
|
+
// browser) can stall `load` 45 s+ on third-party subresources.
|
|
679
|
+
// record_from / nav.to are still stamped after the paint gates below.
|
|
680
|
+
await page.goto(step.url, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
547
681
|
await ensureCursor();
|
|
548
682
|
await applyHideCss();
|
|
549
683
|
if (manifest.record_from !== undefined) {
|
|
@@ -623,15 +757,32 @@ try {
|
|
|
623
757
|
await page.waitForTimeout(settleMs);
|
|
624
758
|
if (step.focus) {
|
|
625
759
|
const box = await visibleBox(step.focus, step.minY ?? 0, 4000);
|
|
626
|
-
pushShot(box, shotStart, t(), step.label, { n: rec.n });
|
|
760
|
+
pushShot(userShotBox(step, box), shotStart, t(), step.label, { n: rec.n });
|
|
761
|
+
} else if (step.zoom) {
|
|
762
|
+
pushShot(userShotBox(step, null), shotStart, t(), step.label, { n: rec.n });
|
|
627
763
|
}
|
|
628
764
|
} else if (step.action === "scroll") {
|
|
629
765
|
// scrollTo distances are ALSO zoomed-space under CSS zoom (measured:
|
|
630
766
|
// scrollTo(0,600) moves 300 design px) — storyboards speak design px.
|
|
767
|
+
const shotStart = t();
|
|
631
768
|
await smoothScroll(step.dy * SUPERSAMPLE, ms(step.ms, 1400), step.within ?? null);
|
|
769
|
+
if (step.zoom) pushShot(userShotBox(step, null), shotStart, t(), step.label, { n: rec.n });
|
|
632
770
|
} else if (step.action === "click" || step.action === "hover") {
|
|
633
|
-
const { box, el } = await visibleTarget(step.selector, step.minY ?? 0);
|
|
634
|
-
if (!box)
|
|
771
|
+
const { box, el } = await visibleTarget(step.selector, step.minY ?? 0, step.action === "hover" ? 8000 : 15000);
|
|
772
|
+
if (!box) {
|
|
773
|
+
if (step.action === "hover") {
|
|
774
|
+
// Storyboard resilience (2026-08-20): a showcase hover whose target
|
|
775
|
+
// vanished between plan time and film time must not kill the take —
|
|
776
|
+
// the customer approved a tour, not a selector. Skip the beat and
|
|
777
|
+
// keep filming; the shot simply never happens.
|
|
778
|
+
console.log(`MISSING TARGET (hover, skipped): step ${rec.n} ${step.selector}`);
|
|
779
|
+
rec.skipped = "missing_target";
|
|
780
|
+
rec.t_end = t();
|
|
781
|
+
manifest.steps.push(rec);
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
throw new Error("no visible target for " + step.selector);
|
|
785
|
+
}
|
|
635
786
|
// SLOW-CONTENT STAMP (Reddit lesson 2026-08-09): a long target wait
|
|
636
787
|
// means the app was loading ON CAMERA — the viewer watched skeletons.
|
|
637
788
|
// The take still completes; the stamp makes the dead air visible at
|
|
@@ -684,7 +835,7 @@ try {
|
|
|
684
835
|
// that, and the same ballistic motion read stiffer for it. The shot
|
|
685
836
|
// begins just before ARRIVAL, so the previous frame holds still
|
|
686
837
|
// while the cursor sweeps across it, then the camera reframes.
|
|
687
|
-
pushShot(step.focus ? await visibleBox(step.focus, 0, 3000) : box, Math.max(shotStart, arrivalT - 0.3), t(), step.label, { n: rec.n, glide: { t_start: Math.round(shotStart * 100) / 100, t_end: Math.round(arrivalT * 100) / 100 } });
|
|
838
|
+
pushShot(userShotBox(step, step.focus ? await visibleBox(step.focus, 0, 3000) : box), Math.max(shotStart, arrivalT - 0.3), t(), step.label, { n: rec.n, glide: { t_start: Math.round(shotStart * 100) / 100, t_end: Math.round(arrivalT * 100) / 100 } });
|
|
688
839
|
} else {
|
|
689
840
|
await page.waitForTimeout(ms(step.dwell, DEFAULT_CLICK_DWELL));
|
|
690
841
|
await page.evaluate(([a, b]) => window.__recPulse?.(a, b), [x, y]);
|
|
@@ -701,7 +852,7 @@ try {
|
|
|
701
852
|
}
|
|
702
853
|
// Shot one: the control — beginning near ARRIVAL (see the camera
|
|
703
854
|
// choreography law above), never spanning the approach glide.
|
|
704
|
-
pushShot(box, Math.max(shotStart, arrivalT - 0.3), t() + 0.3, step.label, { n: rec.n, glide: { t_start: Math.round(shotStart * 100) / 100, t_end: Math.round(arrivalT * 100) / 100 } });
|
|
855
|
+
pushShot(userShotBox(step, box), Math.max(shotStart, arrivalT - 0.3), t() + 0.3, step.label, { n: rec.n, glide: { t_start: Math.round(shotStart * 100) / 100, t_end: Math.round(arrivalT * 100) / 100 } });
|
|
705
856
|
// LAW (press physics, founder 2026-08-09): a press has a down and an
|
|
706
857
|
// up — but the up only exists if the clicked surface is still there.
|
|
707
858
|
// A menu item or modal button that DESTROYS itself on click gets a
|
|
@@ -826,12 +977,24 @@ try {
|
|
|
826
977
|
await page.keyboard.type(ch);
|
|
827
978
|
await page.waitForTimeout(34 + Math.random() * 70);
|
|
828
979
|
}
|
|
829
|
-
if (step.enter) {
|
|
980
|
+
if (step.enter || step.submit) {
|
|
981
|
+
// `enter` is the skill's spelling, `submit` the cloud planner's.
|
|
830
982
|
await page.waitForTimeout(300);
|
|
983
|
+
const urlBefore = page.url();
|
|
831
984
|
await page.keyboard.press("Enter");
|
|
985
|
+
// A submit usually navigates: re-arm the presenter layer on the new
|
|
986
|
+
// document, exactly like a goto — otherwise the cursor vanishes for
|
|
987
|
+
// the rest of the take.
|
|
988
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 30000 }).catch(() => {});
|
|
989
|
+
if (page.url() !== urlBefore) {
|
|
990
|
+
await page.waitForLoadState("networkidle", { timeout: 6000 }).catch(() => {});
|
|
991
|
+
await ensureCursor();
|
|
992
|
+
await applyHideCss();
|
|
993
|
+
await page.evaluate(([a, b]) => window.__recSetCursor?.(a, b), [x, y]);
|
|
994
|
+
}
|
|
832
995
|
}
|
|
833
996
|
// One shot: the field, from arrival through the typing.
|
|
834
|
-
pushShot(box, Math.max(shotStart, arrivalT - 0.3), t() + 0.3, step.label ?? "type", { n: rec.n, glide: { t_start: Math.round(shotStart * 100) / 100, t_end: Math.round(arrivalT * 100) / 100 } });
|
|
997
|
+
pushShot(userShotBox(step, box), Math.max(shotStart, arrivalT - 0.3), t() + 0.3, step.label ?? "type", { n: rec.n, glide: { t_start: Math.round(shotStart * 100) / 100, t_end: Math.round(arrivalT * 100) / 100 } });
|
|
835
998
|
currentCursor = await cursorUnderPoint();
|
|
836
999
|
pushMouse("move", cx, cy);
|
|
837
1000
|
await page.waitForTimeout(ms(step.after, DEFAULT_CLICK_AFTER));
|
|
@@ -865,6 +1028,11 @@ if (video) {
|
|
|
865
1028
|
const vpath = await video.path();
|
|
866
1029
|
fs.renameSync(vpath, path.join(DIR, "raw.webm"));
|
|
867
1030
|
}
|
|
1031
|
+
// CDP mode: disconnect from the provided browser WITHOUT killing it — the
|
|
1032
|
+
// provisioner owns the remote browser's lifecycle, not us. Mode B's browser
|
|
1033
|
+
// is ours to close.
|
|
1034
|
+
if (connectedBrowser) { try { await connectedBrowser.close(); } catch {} }
|
|
1035
|
+
if (ownedBrowser) { try { await ownedBrowser.close(); } catch {} }
|
|
868
1036
|
fs.writeFileSync(path.join(DIR, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
869
1037
|
if (failure) {
|
|
870
1038
|
console.error(`TAKE FAILED at step ${manifest.steps.length + 1}: ${failure.message}`);
|
package/scripts/trim.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// Usage: node trim.mjs <takeDir>
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
|
-
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
12
12
|
|
|
13
13
|
const dir = process.argv[2];
|
|
14
14
|
if (!dir) {
|
|
@@ -46,20 +46,62 @@ const clean = path.join(dir, "clean.mp4");
|
|
|
46
46
|
// took 0.49s to load tilted 0.897x. Both produced ramps of error that read as
|
|
47
47
|
// a broken cursor.
|
|
48
48
|
const r0 = man.record_from ?? 0;
|
|
49
|
+
|
|
50
|
+
// Head-beacon phase 0: the identity law above fixes the RATE; the beacon
|
|
51
|
+
// fixes the ANCHOR. record.mjs flashed the blank page full-frame at stamped
|
|
52
|
+
// wall times before any content loaded; find those flashes in raw.webm and
|
|
53
|
+
// the difference (stamped wall - observed video time) is the exact anchoring
|
|
54
|
+
// error between t()-space and the video timeline — near zero on a warm local
|
|
55
|
+
// browser, whole seconds on a cold remote-CDP one (2026-08-21 PH class).
|
|
56
|
+
// Shift the cut by it so clean.mp4 truly begins at the record_from moment.
|
|
57
|
+
let beaconDelta = 0;
|
|
58
|
+
let beaconMethod = "";
|
|
59
|
+
if ((man.beacon?.flips?.length ?? 0) >= 2) {
|
|
60
|
+
const flips = man.beacon.flips;
|
|
61
|
+
// The flashes are the first big whole-frame changes in the head. Search a
|
|
62
|
+
// 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", [
|
|
65
|
+
"-loglevel", "info", "-t", String(searchEnd), "-i", raw,
|
|
66
|
+
"-vf", "select='gt(scene,0.3)',showinfo", "-f", "null", "-",
|
|
67
|
+
], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
68
|
+
const onsets = [...`${res.stderr ?? ""}`.matchAll(/pts_time:([0-9.]+)/g)]
|
|
69
|
+
.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`);
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
console.log("beacon: flashes not found in the raw head — cut stays as stamped");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const cutAt = Math.max(0, r0 - beaconDelta);
|
|
89
|
+
if (cutAt !== r0 - beaconDelta) console.log("beacon: corrected cut clamped at 0 — head shorter than the anchor error");
|
|
49
90
|
man.timebase = {
|
|
50
91
|
a: 1,
|
|
51
92
|
b: -r0,
|
|
52
|
-
method: "identity (raw is wall-rate; see 2026-08-09 four-lane verification)",
|
|
93
|
+
method: "identity (raw is wall-rate; see 2026-08-09 four-lane verification)" + beaconMethod,
|
|
53
94
|
k: 1,
|
|
54
95
|
videoRecordFrom: r0,
|
|
96
|
+
...(beaconMethod ? { beaconDelta } : {}),
|
|
55
97
|
};
|
|
56
98
|
fs.writeFileSync(manPath, JSON.stringify(man, null, 2));
|
|
57
|
-
console.log(`timebase: identity, video = wall - ${r0.toFixed(3)}`);
|
|
99
|
+
console.log(`timebase: identity, video = wall - ${r0.toFixed(3)}${beaconMethod ? ` (cut at raw ${cutAt.toFixed(3)}s)` : ""}`);
|
|
58
100
|
|
|
59
101
|
execFileSync("ffmpeg", [
|
|
60
102
|
"-y", "-loglevel", "error",
|
|
61
103
|
"-i", raw,
|
|
62
|
-
"-filter_complex", `[0:v]trim=start=${
|
|
104
|
+
"-filter_complex", `[0:v]trim=start=${cutAt},setpts=PTS-STARTPTS,fps=30,format=yuv420p[out]`,
|
|
63
105
|
"-map", "[out]",
|
|
64
106
|
"-c:v", "libx264", "-preset", "medium", "-crf", "19",
|
|
65
107
|
"-movflags", "+faststart",
|
package/skill/SKILL.md
CHANGED
|
@@ -236,6 +236,30 @@ What the human approves on that page is the **picture and the coverage**, never
|
|
|
236
236
|
|
|
237
237
|
`upload.mjs` now polls `/api/recorder/status` until the bite reaches `completed` and prints what actually landed. **Read that line before you say anything to the human.** It reports `narrationReady/narrationTotal` segments with real audio behind them, and the camera shot count. If narration is 0, or ready is below total, or shots are 0, say so plainly and investigate. Do not pass on a link with a warning above it as though it were a success.
|
|
238
238
|
|
|
239
|
+
## Phase 7: Re-take (Launch plan and up)
|
|
240
|
+
|
|
241
|
+
A bite filmed by this recorder carries its recipe (storyboard, config, manifest) inside DemoBites. When the
|
|
242
|
+
human's app changes, they do not re-record: they ask for a **re-take**, and the same story is filmed again
|
|
243
|
+
against today's app and landed **inside the same bite**. Links, analytics, the bite's current narration text
|
|
244
|
+
(their edits win over the original intent), voice, intro/outro and look are preserved; camera, cuts and audio
|
|
245
|
+
are refitted by the ingestion.
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
node scripts/retake.mjs <biteId> [--note "what changed"] # or: npx demobite retake <biteId> --note "..."
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Laws for a re-take:
|
|
252
|
+
- **Read the note first.** "We moved Export to the header" tells you which step will break before you film.
|
|
253
|
+
- **A step that no longer resolves stops the take at that step.** Look at the live page. If the control moved,
|
|
254
|
+
fix the selector in the take's `storyboard.json` and run again with `--take <dir>`. If the feature is truly
|
|
255
|
+
gone, DROP that beat AND its narration line, and tell the human plainly: "This capability no longer exists,
|
|
256
|
+
we removed it from the video." Nothing stages until every step resolves.
|
|
257
|
+
- **The narration in the recipe is the ORIGINAL intent.** Do not rewrite it to taste: the server replaces it
|
|
258
|
+
with the bite's current text per step. Only remove lines whose beats you dropped.
|
|
259
|
+
- **Same pacing laws apply** (intro, narrate the path, linger, cut and fade on page transitions).
|
|
260
|
+
- **The human approves in-app.** The preview page says "Re-take of <bite>". Approve replaces the recording in
|
|
261
|
+
that bite; the previous recording is kept for rollback, never overwritten.
|
|
262
|
+
|
|
239
263
|
## The wire manifest (fixed contract, version 2)
|
|
240
264
|
|
|
241
265
|
`manifest.mjs` produces exactly this shape. All times are relative to the UPLOADED file (record_from already subtracted, clamped at 0). `duration` is the duration of the uploaded clean.mp4.
|
|
@@ -280,8 +304,12 @@ PUT <base>/api/recorder/device { device_code } (poll every `interval`
|
|
|
280
304
|
DELETE <base>/api/recorder/key (Authorization: Bearer <api_key>)
|
|
281
305
|
-> { revoked: true } (logout)
|
|
282
306
|
|
|
307
|
+
GET <base>/api/recorder/recipe?biteId=<id> (Authorization: Bearer <api_key>) // RE-TAKE: the bite's recipe
|
|
308
|
+
-> { storyboard, config:{app,url,frame}, manifest, engine } (404 no recipe; 402/403 plan gate)
|
|
309
|
+
|
|
283
310
|
PUT <base>/api/recorder/stage (Authorization: Bearer <api_key>)
|
|
284
|
-
{ filename, sizeBytes, previewSizeBytes, manifest }
|
|
311
|
+
{ filename, sizeBytes, previewSizeBytes, manifest, recipe? }, retakeOfBiteId? }
|
|
312
|
+
// recipe = { version:1, lane:'skill', engine, storyboard, config:{app,url,frame,base} } — the RE-TAKE DNA (never the api_key)
|
|
285
313
|
-> { stagingId, uploadUrl, previewUploadUrl, videoKey, previewUrl }
|
|
286
314
|
|
|
287
315
|
GET <base>/api/recorder/stage?id=<stagingId> (Authorization: Bearer <api_key>)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// RE-TAKE (local lane, Phase 1 — founder 2026-09-02): re-film a bite that was
|
|
3
|
+
// recorded by the agentic recorder, against the app as it is TODAY, and land
|
|
4
|
+
// the new footage INSIDE THE SAME BITE. The bite's recipe (storyboard + config)
|
|
5
|
+
// is fetched from DemoBites; the server keeps the bite's CURRENT narration
|
|
6
|
+
// text, voice, intro/outro and look, and refits camera, cuts and audio.
|
|
7
|
+
//
|
|
8
|
+
// Usage: node retake.mjs <biteId> [--note "what changed"] [--take <dir>]
|
|
9
|
+
//
|
|
10
|
+
// Flow: recipe -> storyboard.json -> record -> trim -> calibrate (gate) ->
|
|
11
|
+
// manifest -> upload --retake-of <biteId> (stages; the human approves in-app,
|
|
12
|
+
// where the preview says "Re-take of <bite>").
|
|
13
|
+
//
|
|
14
|
+
// A step that no longer resolves makes record.mjs FAIL LOUDLY at that step.
|
|
15
|
+
// That is the moment for the agent to look at the live page, fix the
|
|
16
|
+
// storyboard (or drop the beat AND its line, and tell the human what is gone),
|
|
17
|
+
// and run again. Nothing is staged until every step resolves.
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
22
|
+
|
|
23
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const args = process.argv.slice(2);
|
|
25
|
+
const biteId = Number(args[0]);
|
|
26
|
+
if (!Number.isInteger(biteId) || biteId <= 0) {
|
|
27
|
+
console.error('Usage: node retake.mjs <biteId> [--note "what changed"] [--take <dir>]');
|
|
28
|
+
process.exit(2);
|
|
29
|
+
}
|
|
30
|
+
const opt = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
|
|
31
|
+
const note = opt("--note") ?? "";
|
|
32
|
+
const cfgPath = path.resolve(".recorder", "config.json");
|
|
33
|
+
let cfg = {};
|
|
34
|
+
try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
|
|
35
|
+
if (!cfg.api_key || !cfg.base) { console.error("No recorder key. Run: node scripts/login.mjs"); process.exit(1); }
|
|
36
|
+
const base = cfg.base.replace(/\/+$/, "");
|
|
37
|
+
|
|
38
|
+
// 1. The recipe — the bite's DNA.
|
|
39
|
+
const res = await fetch(`${base}/api/recorder/recipe?biteId=${biteId}`, { headers: { Authorization: `Bearer ${cfg.api_key}` } });
|
|
40
|
+
if (res.status === 404) {
|
|
41
|
+
console.error(`Bite ${biteId} has no recording recipe. Only bites filmed by the agentic recorder (engine 1.0.6 or later) can be re-taken.`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
if (res.status === 402 || res.status === 403) {
|
|
45
|
+
const body = await res.json().catch(() => ({}));
|
|
46
|
+
console.error(body?.message || "Re-take is available from the Launch plan. Upgrade in DemoBites to use it.");
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
if (!res.ok) { console.error(`Could not fetch the recipe (${res.status}).`); process.exit(1); }
|
|
50
|
+
const recipe = await res.json();
|
|
51
|
+
const storyboard = recipe.storyboard;
|
|
52
|
+
if (!storyboard?.steps?.length) { console.error("The recipe has no storyboard steps."); process.exit(1); }
|
|
53
|
+
|
|
54
|
+
// 2. Take dir + files.
|
|
55
|
+
let takeDir = opt("--take");
|
|
56
|
+
if (!takeDir) { let n = 1; while (fs.existsSync(`take-retake-${biteId}${n > 1 ? n : ""}`)) n++; takeDir = `take-retake-${biteId}${n > 1 ? n : ""}`; }
|
|
57
|
+
fs.mkdirSync(takeDir, { recursive: true });
|
|
58
|
+
const sbPath = path.join(takeDir, "storyboard.json");
|
|
59
|
+
fs.writeFileSync(sbPath, JSON.stringify(storyboard, null, 2));
|
|
60
|
+
fs.writeFileSync(path.join(takeDir, "retake.json"), JSON.stringify({ biteId, note, engine: recipe.engine ?? null, fetchedAt: new Date().toISOString() }, null, 2));
|
|
61
|
+
console.log(`Re-take of bite ${biteId} — ${storyboard.steps.length} steps from the recipe${recipe.engine ? ` (filmed by ${recipe.engine})` : ""}.`);
|
|
62
|
+
if (note) console.log(`Note from the human: ${note}`);
|
|
63
|
+
|
|
64
|
+
// 3. Film -> trim -> calibrate (gate) -> manifest -> stage.
|
|
65
|
+
const run = (script, extra) => spawnSync("node", [path.join(here, script), ...extra], { stdio: "inherit", cwd: process.cwd() });
|
|
66
|
+
let r = run("record.mjs", [takeDir, sbPath]);
|
|
67
|
+
if (r.status !== 0) {
|
|
68
|
+
console.error(`\nThe take stopped at a step that no longer resolves. Look at the live page, fix the storyboard in ${sbPath}\n(or drop the beat and its line, and tell the human what is gone), then run:\n node scripts/retake.mjs ${biteId} --take ${takeDir}${note ? ` --note ${JSON.stringify(note)}` : ""}`);
|
|
69
|
+
process.exit(r.status ?? 1);
|
|
70
|
+
}
|
|
71
|
+
r = run("trim.mjs", [takeDir]); if (r.status !== 0) process.exit(r.status ?? 1);
|
|
72
|
+
r = run("calibrate.mjs", [takeDir]);
|
|
73
|
+
if (r.status !== 0) { console.error("Calibration failed — do not stage this take. Investigate record_from / anchors and film again."); process.exit(3); }
|
|
74
|
+
r = run("manifest.mjs", [takeDir]); if (r.status !== 0) process.exit(r.status ?? 1);
|
|
75
|
+
r = run("upload.mjs", [takeDir, "--retake-of", String(biteId)]);
|
|
76
|
+
process.exit(r.status ?? 0);
|
package/skill/scripts/upload.mjs
CHANGED
|
@@ -29,6 +29,12 @@ if (!dir) {
|
|
|
29
29
|
console.error("Usage: node upload.mjs <takeDir>");
|
|
30
30
|
process.exit(2);
|
|
31
31
|
}
|
|
32
|
+
// RE-TAKE (2026-09-02): `--retake-of <biteId>` stages this take as a NEW RECORDING
|
|
33
|
+
// of an existing bite instead of a new bite. The server keeps the bite's current
|
|
34
|
+
// narration text, voice, intro/outro and look; the human approves in-app.
|
|
35
|
+
const retakeIdx = process.argv.indexOf("--retake-of");
|
|
36
|
+
const retakeOfBiteId = retakeIdx >= 0 ? Number(process.argv[retakeIdx + 1]) : null;
|
|
37
|
+
if (retakeIdx >= 0 && !(Number.isInteger(retakeOfBiteId) && retakeOfBiteId > 0)) { console.error("--retake-of needs a bite id"); process.exit(2); }
|
|
32
38
|
const cfgPath = path.resolve(".recorder", "config.json");
|
|
33
39
|
let cfg = {};
|
|
34
40
|
try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
|
|
@@ -40,6 +46,19 @@ const base = cfg.base.replace(/\/+$/, "");
|
|
|
40
46
|
|
|
41
47
|
const cleanPath = path.join(dir, "clean.mp4");
|
|
42
48
|
const wirePath = path.join(dir, "manifest.demobites.json");
|
|
49
|
+
// Recording recipe (RE-TAKE, 2026-09-02): storyboard + public config, written by
|
|
50
|
+
// record.mjs. Staged next to the manifest so the bite can be re-filmed later.
|
|
51
|
+
// Absent on takes filmed by older engines — staging still works without it.
|
|
52
|
+
let recipe = null;
|
|
53
|
+
try {
|
|
54
|
+
const sbPath = path.join(dir, "storyboard.json");
|
|
55
|
+
const rcPath = path.join(dir, "recipe.json");
|
|
56
|
+
if (fs.existsSync(sbPath)) {
|
|
57
|
+
const rc = fs.existsSync(rcPath) ? JSON.parse(fs.readFileSync(rcPath, "utf8")) : {};
|
|
58
|
+
recipe = { version: 1, lane: rc.lane ?? "skill", engine: rc.engine ?? null, storyboard: JSON.parse(fs.readFileSync(sbPath, "utf8")), config: rc.config ?? {} };
|
|
59
|
+
if (recipe.config && "api_key" in recipe.config) delete recipe.config.api_key;
|
|
60
|
+
}
|
|
61
|
+
} catch (e) { console.error("recipe skipped:", e.message); }
|
|
43
62
|
if (!fs.existsSync(cleanPath)) { console.error(`${cleanPath} not found. Run: node scripts/trim.mjs ${dir}`); process.exit(1); }
|
|
44
63
|
if (!fs.existsSync(wirePath)) { console.error(`${wirePath} not found. Run: node scripts/manifest.mjs ${dir}`); process.exit(1); }
|
|
45
64
|
const manifest = JSON.parse(fs.readFileSync(wirePath, "utf8"));
|
|
@@ -115,6 +134,7 @@ if (!zipped) {
|
|
|
115
134
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
116
135
|
const sizeBytes = fs.statSync(zipPath).size;
|
|
117
136
|
console.log(`take.zip ready (${(sizeBytes / 1024 / 1024).toFixed(1)} MB, ${zipped ? "system zip" : "store method"})`);
|
|
137
|
+
if (retakeOfBiteId) console.log(`Staging as a RE-TAKE of bite ${retakeOfBiteId} — the new recording replaces the current one inside that bite once approved.`);
|
|
118
138
|
|
|
119
139
|
// ── stage ──────────────────────────────────────────────────────────────────
|
|
120
140
|
const authHeaders = { Authorization: `Bearer ${cfg.api_key}`, "Content-Type": "application/json" };
|
|
@@ -124,7 +144,7 @@ try {
|
|
|
124
144
|
stageRes = await fetch(`${base}/api/recorder/stage`, {
|
|
125
145
|
method: "PUT",
|
|
126
146
|
headers: authHeaders,
|
|
127
|
-
body: JSON.stringify({ filename: "take.zip", sizeBytes, previewSizeBytes, manifest }),
|
|
147
|
+
body: JSON.stringify({ filename: "take.zip", sizeBytes, previewSizeBytes, manifest, ...(recipe ? { recipe } : {}), ...(retakeOfBiteId ? { retakeOfBiteId } : {}) }),
|
|
128
148
|
});
|
|
129
149
|
} catch (e) {
|
|
130
150
|
console.error(`Could not reach ${base}: ${e.message}`);
|