demobite 1.0.6 → 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 +175 -21
- package/scripts/trim.mjs +46 -4
- package/skill/SKILL.md +28 -1
- package/skill/scripts/retake.mjs +76 -0
- package/skill/scripts/upload.mjs +8 -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
|
@@ -40,15 +40,19 @@ 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.7";
|
|
44
44
|
fs.mkdirSync(DIR, { recursive: true });
|
|
45
|
-
|
|
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));
|
|
46
50
|
try {
|
|
47
51
|
const cfgPath = path.resolve(".recorder/config.json");
|
|
48
52
|
const cfg = fs.existsSync(cfgPath) ? JSON.parse(fs.readFileSync(cfgPath, "utf8")) : {};
|
|
49
53
|
// Only the public shape — NEVER the api_key or workspace.
|
|
50
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" };
|
|
51
|
-
fs.writeFileSync(path.join(DIR, "recipe.json"), JSON.stringify({ version: 1, lane: "skill", engine: ENGINE_VERSION, config }, null, 2));
|
|
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));
|
|
52
56
|
} catch (e) { console.error("recipe.json not written:", e.message); }
|
|
53
57
|
fs.mkdirSync(DIR, { recursive: true });
|
|
54
58
|
|
|
@@ -158,17 +162,54 @@ const launchOpts = {
|
|
|
158
162
|
deviceScaleFactor: 1,
|
|
159
163
|
args: ["--force-color-profile=srgb", "--disable-blink-features=AutomationControlled"],
|
|
160
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.
|
|
161
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
|
+
};
|
|
162
193
|
const wantChannel = STORYBOARD.channel ?? "chrome";
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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 }));
|
|
172
213
|
}
|
|
173
214
|
const page = ctx.pages()[0] || (await ctx.newPage());
|
|
174
215
|
|
|
@@ -329,6 +370,21 @@ const pushShot = (box, tStart, tEnd, label, extra) => {
|
|
|
329
370
|
});
|
|
330
371
|
};
|
|
331
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
|
+
|
|
332
388
|
/** LAW (visible-instance pick, dry-run lesson): sticky-header twins and
|
|
333
389
|
* offscreen duplicates shadow the real control — take the first VISIBLE match
|
|
334
390
|
* whose top clears minY, retrying while the page settles. */
|
|
@@ -337,8 +393,10 @@ async function visibleTarget(selector, minY = 0, timeout = 15000) {
|
|
|
337
393
|
const waitT0 = Date.now();
|
|
338
394
|
const all = page.locator(selector);
|
|
339
395
|
await all.first().waitFor({ state: "attached", timeout }).catch(() => {});
|
|
396
|
+
let scrollCorrections = 0;
|
|
340
397
|
for (let tries = 0; tries < 20; tries++) {
|
|
341
398
|
const n = await all.count().catch(() => 0);
|
|
399
|
+
let offVerticalEl = null; // a visible instance that's only off-frame vertically
|
|
342
400
|
for (let i = 0; i < n; i++) {
|
|
343
401
|
const el = all.nth(i);
|
|
344
402
|
if (!(await el.isVisible().catch(() => false))) continue;
|
|
@@ -350,10 +408,26 @@ async function visibleTarget(selector, minY = 0, timeout = 15000) {
|
|
|
350
408
|
// cursor 360px off the 1920 frame and the click landed off-camera.
|
|
351
409
|
// A target's CENTER must be inside the recorded frame, with margin.
|
|
352
410
|
const cx = b.x + b.width / 2, cy = b.y + b.height / 2;
|
|
353
|
-
|
|
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; }
|
|
354
415
|
// Return the ELEMENT alongside its box so callers never re-scan.
|
|
355
416
|
if (b.y >= minY * SUPERSAMPLE) { visibleTarget.lastWaitMs = Date.now() - waitT0; return { box: b, el }; }
|
|
356
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
|
+
}
|
|
357
431
|
await page.waitForTimeout(500);
|
|
358
432
|
}
|
|
359
433
|
return { box: null, el: null };
|
|
@@ -541,6 +615,49 @@ async function smoothScroll(dy, ms = 1400, within = null) {
|
|
|
541
615
|
}), [dy, ms, within]);
|
|
542
616
|
}
|
|
543
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
|
+
|
|
544
661
|
let failure = null;
|
|
545
662
|
try {
|
|
546
663
|
for (const step of STORYBOARD.steps) {
|
|
@@ -557,7 +674,10 @@ try {
|
|
|
557
674
|
process.stdout.write(`step ${rec.n} ${step.action} ${step.label ?? ""}\n`);
|
|
558
675
|
if (step.action === "goto") {
|
|
559
676
|
const navIssuedAt = t();
|
|
560
|
-
|
|
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 });
|
|
561
681
|
await ensureCursor();
|
|
562
682
|
await applyHideCss();
|
|
563
683
|
if (manifest.record_from !== undefined) {
|
|
@@ -637,15 +757,32 @@ try {
|
|
|
637
757
|
await page.waitForTimeout(settleMs);
|
|
638
758
|
if (step.focus) {
|
|
639
759
|
const box = await visibleBox(step.focus, step.minY ?? 0, 4000);
|
|
640
|
-
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 });
|
|
641
763
|
}
|
|
642
764
|
} else if (step.action === "scroll") {
|
|
643
765
|
// scrollTo distances are ALSO zoomed-space under CSS zoom (measured:
|
|
644
766
|
// scrollTo(0,600) moves 300 design px) — storyboards speak design px.
|
|
767
|
+
const shotStart = t();
|
|
645
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 });
|
|
646
770
|
} else if (step.action === "click" || step.action === "hover") {
|
|
647
|
-
const { box, el } = await visibleTarget(step.selector, step.minY ?? 0);
|
|
648
|
-
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
|
+
}
|
|
649
786
|
// SLOW-CONTENT STAMP (Reddit lesson 2026-08-09): a long target wait
|
|
650
787
|
// means the app was loading ON CAMERA — the viewer watched skeletons.
|
|
651
788
|
// The take still completes; the stamp makes the dead air visible at
|
|
@@ -698,7 +835,7 @@ try {
|
|
|
698
835
|
// that, and the same ballistic motion read stiffer for it. The shot
|
|
699
836
|
// begins just before ARRIVAL, so the previous frame holds still
|
|
700
837
|
// while the cursor sweeps across it, then the camera reframes.
|
|
701
|
-
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 } });
|
|
702
839
|
} else {
|
|
703
840
|
await page.waitForTimeout(ms(step.dwell, DEFAULT_CLICK_DWELL));
|
|
704
841
|
await page.evaluate(([a, b]) => window.__recPulse?.(a, b), [x, y]);
|
|
@@ -715,7 +852,7 @@ try {
|
|
|
715
852
|
}
|
|
716
853
|
// Shot one: the control — beginning near ARRIVAL (see the camera
|
|
717
854
|
// choreography law above), never spanning the approach glide.
|
|
718
|
-
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 } });
|
|
719
856
|
// LAW (press physics, founder 2026-08-09): a press has a down and an
|
|
720
857
|
// up — but the up only exists if the clicked surface is still there.
|
|
721
858
|
// A menu item or modal button that DESTROYS itself on click gets a
|
|
@@ -840,12 +977,24 @@ try {
|
|
|
840
977
|
await page.keyboard.type(ch);
|
|
841
978
|
await page.waitForTimeout(34 + Math.random() * 70);
|
|
842
979
|
}
|
|
843
|
-
if (step.enter) {
|
|
980
|
+
if (step.enter || step.submit) {
|
|
981
|
+
// `enter` is the skill's spelling, `submit` the cloud planner's.
|
|
844
982
|
await page.waitForTimeout(300);
|
|
983
|
+
const urlBefore = page.url();
|
|
845
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
|
+
}
|
|
846
995
|
}
|
|
847
996
|
// One shot: the field, from arrival through the typing.
|
|
848
|
-
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 } });
|
|
849
998
|
currentCursor = await cursorUnderPoint();
|
|
850
999
|
pushMouse("move", cx, cy);
|
|
851
1000
|
await page.waitForTimeout(ms(step.after, DEFAULT_CLICK_AFTER));
|
|
@@ -879,6 +1028,11 @@ if (video) {
|
|
|
879
1028
|
const vpath = await video.path();
|
|
880
1029
|
fs.renameSync(vpath, path.join(DIR, "raw.webm"));
|
|
881
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 {} }
|
|
882
1036
|
fs.writeFileSync(path.join(DIR, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
883
1037
|
if (failure) {
|
|
884
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,11 @@ 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, recipe? }
|
|
311
|
+
{ filename, sizeBytes, previewSizeBytes, manifest, recipe? }, retakeOfBiteId? }
|
|
285
312
|
// recipe = { version:1, lane:'skill', engine, storyboard, config:{app,url,frame,base} } — the RE-TAKE DNA (never the api_key)
|
|
286
313
|
-> { stagingId, uploadUrl, previewUploadUrl, videoKey, previewUrl }
|
|
287
314
|
|
|
@@ -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 {}
|
|
@@ -128,6 +134,7 @@ if (!zipped) {
|
|
|
128
134
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
129
135
|
const sizeBytes = fs.statSync(zipPath).size;
|
|
130
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.`);
|
|
131
138
|
|
|
132
139
|
// ── stage ──────────────────────────────────────────────────────────────────
|
|
133
140
|
const authHeaders = { Authorization: `Bearer ${cfg.api_key}`, "Content-Type": "application/json" };
|
|
@@ -137,7 +144,7 @@ try {
|
|
|
137
144
|
stageRes = await fetch(`${base}/api/recorder/stage`, {
|
|
138
145
|
method: "PUT",
|
|
139
146
|
headers: authHeaders,
|
|
140
|
-
body: JSON.stringify({ filename: "take.zip", sizeBytes, previewSizeBytes, manifest, ...(recipe ? { recipe } : {}) }),
|
|
147
|
+
body: JSON.stringify({ filename: "take.zip", sizeBytes, previewSizeBytes, manifest, ...(recipe ? { recipe } : {}), ...(retakeOfBiteId ? { retakeOfBiteId } : {}) }),
|
|
141
148
|
});
|
|
142
149
|
} catch (e) {
|
|
143
150
|
console.error(`Could not reach ${base}: ${e.message}`);
|