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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "demobite",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "The DemoBites agentic recorder — you prompt, it films a real browser, and DemoBites turns the take into an editable demo bite.",
5
5
  "bin": {
6
6
  "demobite": "launcher/index.mjs"
@@ -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
- try {
150
- ctx = await chromium.launchPersistentContext(
151
- profileDir,
152
- wantChannel === "chromium" ? launchOpts : { ...launchOpts, channel: wantChannel },
153
- );
154
- } catch (launchErr) {
155
- if (wantChannel === "chromium") throw launchErr;
156
- console.error(`Real Chrome (channel '${wantChannel}') failed to launch (${launchErr.message.split("\n")[0]}); falling back to bundled Chromium.`);
157
- ctx = await chromium.launchPersistentContext(profileDir, launchOpts);
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
- if (cx < 8 || cx > VIEW.width - 8 || cy < 8 || cy > VIEW.height - 8) continue;
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
- await page.goto(step.url, { waitUntil: "load" });
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) throw new Error("no visible target for " + step.selector);
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=${r0},setpts=PTS-STARTPTS,fps=30,format=yuv420p[out]`,
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);
@@ -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}`);