demobites 1.27.0 → 1.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "demobites",
3
- "version": "1.27.0",
3
+ "version": "1.29.0",
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
  "demobites": "launcher/index.mjs"
@@ -12,6 +12,8 @@
12
12
  import { chromium } from "playwright";
13
13
  import fs from "node:fs";
14
14
  import path from "node:path";
15
+ import { spawn } from "node:child_process";
16
+ import { ffmpeg as FFMPEG } from "./media-tools.mjs";
15
17
  // LISTENING (2026-10-14): the app's mailbox, when `npx demobite listen` runs
16
18
  // this take; a no-op everywhere else (the terminal flow, the cloud sandbox
17
19
  // copy that may not ship listen-event.mjs).
@@ -146,7 +148,8 @@ fs.mkdirSync(profileDir, { recursive: true });
146
148
  // a customer's own uploaded voice. Holding a shot to cover an estimated line
147
149
  // is what produced a 60-second take with the cursor parked for 12 seconds.
148
150
  const DEFAULT_SETTLE_MS = 2200;
149
- const DEFAULT_HOVER_DWELL = 3200;
151
+ // 1.6 s (was 3.2 s; the planner's clock budgets 2 s for the beat — founder 2026-10-18: planned 31 s, filmed 46 s).
152
+ const DEFAULT_HOVER_DWELL = 1600;
150
153
  const DEFAULT_CLICK_DWELL = 700;
151
154
  const DEFAULT_CLICK_AFTER = 2000;
152
155
  const CLOSING_BEAT_MS = 2200;
@@ -385,14 +388,64 @@ const postFrame = async () => {
385
388
  const within = (p, ms) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`timeout ${ms} ms`)), ms))]);
386
389
  try {
387
390
  frameCdp ??= await within(ctx.newCDPSession(page), 4000);
388
- const scale = Math.min(1, 480 / VIEW.width);
389
- const { data } = await within(frameCdp.send("Page.captureScreenshot", { format: "jpeg", quality: 55, clip: { x: 0, y: 0, width: VIEW.width, height: VIEW.height, scale } }), 4000);
390
- await within(postEvent("frame", { ...frameStep, jpeg: data }), 8000);
391
+ // THE CAMERA'S VIEWPORT IS NEVER TOUCHED (founder's take of 2026-10-18, bite 1234: grey quarter-size frames at
392
+ // 0.6 s, 8.3 s, 11.1 s, 16.1 s). A captureScreenshot with a clip at scale 0.25 made Chromium emulate that scale
393
+ // for the capture, and the video screencast recorded those frames. So: capture at scale 1, no clip, and shrink
394
+ // the JPEG OUTSIDE the page with ffmpeg. No ffmpeg → no thumbnail, never a glitch.
395
+ const { data } = await within(frameCdp.send("Page.captureScreenshot", { format: "jpeg", quality: 60 }), 4000);
396
+ const small = await within(shrinkJpeg(Buffer.from(data, "base64"), 480), 4000);
397
+ if (small) await within(postEvent("frame", { ...frameStep, jpeg: small.toString("base64") }), 8000);
391
398
  } catch (e) {
392
399
  if (!frameWarned) { frameWarned = true; console.error(`frame: dropped (${String(e?.message ?? e).split("\n")[0]})`); }
393
400
  } finally { framing = false; }
394
401
  };
395
402
  const framer = setInterval(() => void postFrame(), FRAME_EVERY_MS);
403
+ /** A JPEG scaled to `width` by ffmpeg, in memory; null when ffmpeg is not there. */
404
+ let ffmpegPath;
405
+ function shrinkJpeg(buf, width) {
406
+ if (ffmpegPath === undefined) { try { ffmpegPath = FFMPEG() || null; } catch { ffmpegPath = null; } }
407
+ if (!ffmpegPath) return Promise.resolve(null);
408
+ return new Promise((resolve) => {
409
+ const ff = spawn(ffmpegPath, ["-hide_banner", "-loglevel", "error", "-f", "image2pipe", "-i", "pipe:0", "-vf", `scale=${width}:-2`, "-q:v", "7", "-f", "image2pipe", "-vcodec", "mjpeg", "pipe:1"], { stdio: ["pipe", "pipe", "ignore"] });
410
+ const out = [];
411
+ ff.stdout.on("data", (d) => out.push(d));
412
+ ff.on("error", () => resolve(null));
413
+ ff.on("close", (code) => resolve(code === 0 && out.length ? Buffer.concat(out) : null));
414
+ ff.stdin.on("error", () => {});
415
+ ff.stdin.end(buf);
416
+ });
417
+ }
418
+
419
+ /**
420
+ * THE PICTURE IS STILL (founder 2026-10-18: "wait till the page loads before you're actually recording"; bite 1234
421
+ * opened on skeleton cards). Paint is not load: text and boxes exist while thumbnails and skeletons still shimmer.
422
+ * This waits until no skeleton / busy / shimmer element shows AND two stills 350 ms apart are identical, up to
423
+ * `maxMs`. Stills are full-frame captures at scale 1: they never touch the camera's viewport.
424
+ */
425
+ async function settledPicture(maxMs, label) {
426
+ const deadline = Date.now() + maxMs;
427
+ let prev = null;
428
+ while (Date.now() < deadline) {
429
+ const busy = await page.evaluate(() => {
430
+ let n = 0;
431
+ for (const el of document.querySelectorAll('.animate-pulse, [class*="skeleton" i], [class*="shimmer" i], [aria-busy="true"], [data-loading="true"]')) {
432
+ const r = el.getBoundingClientRect();
433
+ if (r.width > 8 && r.height > 8) n++;
434
+ }
435
+ return n;
436
+ }).catch(() => 0);
437
+ let shot = null;
438
+ try {
439
+ frameCdp ??= await ctx.newCDPSession(page);
440
+ shot = (await frameCdp.send("Page.captureScreenshot", { format: "jpeg", quality: 25 })).data;
441
+ } catch { frameCdp = null; }
442
+ if (!busy && shot && prev && shot === prev) return true;
443
+ prev = shot;
444
+ await page.waitForTimeout(350);
445
+ }
446
+ console.log(`STILL GATE (${label}): the picture kept changing for ${Math.round(maxMs / 1000)} s — going on anyway.`);
447
+ return false;
448
+ }
396
449
 
397
450
  /** Ask the element what cursor it shows. Takes the locator visibleBox already
398
451
  * resolved — re-scanning cost ~1s of dead video per step, which inflated a
@@ -629,9 +682,13 @@ async function revealedBox(step) {
629
682
  // Pace matched to the reference: a 350px move lands in ~0.75s INCLUDING its
630
683
  // deceleration tail. The ballistic shape covers most distance early, so the
631
684
  // same wall duration reads far snappier than min-jerk did.
632
- const GLIDE_MIN_MS = 650;
633
- const GLIDE_MAX_MS = 1250;
634
- const GLIDE_PER_PX = 1.3;
685
+ // THE GLIDE IS PACED BY THE CLOCK (founder's take of 2026-10-18, bite 1234: four glides took 7.2 s, 3.6 s, 3.5 s and
686
+ // 4.8 s against this cap — the loop awaited a browser round trip on each of its ~60 sub-steps, so its pace was the
687
+ // protocol's latency, not the design). Now the recorded cursor advances by wall time at 60 Hz, and the real mouse
688
+ // follows with at most one move in flight, skipping points it cannot keep up with.
689
+ const GLIDE_MIN_MS = 420;
690
+ const GLIDE_MAX_MS = 900;
691
+ const GLIDE_PER_PX = 0.85;
635
692
 
636
693
  // LAW (ballistic motion, measured off the founder's reference hero-demo,
637
694
  // 2026-08-09): the life is in the VELOCITY PROFILE, not the path. Frame-by-
@@ -668,7 +725,6 @@ async function glide(x, y) {
668
725
  // not the 2x capture space, or every move doubles in duration and bow.
669
726
  const designDist = dist / SUPERSAMPLE;
670
727
  const dur = Math.min(GLIDE_MAX_MS, Math.max(GLIDE_MIN_MS, designDist * GLIDE_PER_PX));
671
- const steps = Math.max(12, Math.round(dur / 16));
672
728
  // Near-straight trace: the reference hero-demo's moves are straight to
673
729
  // within 2% (path/chord 1.02) — a big swoop reads as fake, a trace of
674
730
  // curvature reads as a hand. 4.5% capped 20px, DETERMINISTIC.
@@ -679,36 +735,30 @@ async function glide(x, y) {
679
735
  const c1y = y0 + (y - y0) * 0.3 + perpY * arcMag * sign;
680
736
  const c2x = x0 + (x - x0) * 0.75 + perpX * arcMag * 0.35 * sign;
681
737
  const c2y = y0 + (y - y0) * 0.75 + perpY * arcMag * 0.35 * sign;
682
- for (let i = 1; i <= steps; i++) {
683
- const e = ballistic(i / steps);
738
+ const started = Date.now();
739
+ let inflight = null;
740
+ for (;;) {
741
+ const f = Math.min(1, (Date.now() - started) / dur);
742
+ const e = ballistic(f);
684
743
  const u = 1 - e;
685
744
  const nx = u * u * u * x0 + 3 * u * u * e * c1x + 3 * u * e * e * c2x + e * e * e * x;
686
745
  const ny = u * u * u * y0 + 3 * u * u * e * c1y + 3 * u * e * e * c2y + e * e * e * y;
687
- // LAW (record what you animate, founder 2026-08-09): cx/cy used to be
688
- // assigned only AFTER this loop, so the background sampler recorded the
689
- // OLD position for the entire glide and then teleported. 98% of a real
690
- // take's events were duplicates and the cursor jumped 458px in 0ms. Every
691
- // reported symptom (rigid, jumping, too fast, missing where the action is,
692
- // appearing and reappearing) came from that one omission — at a 3x zoom
693
- // the visible frame is 640x360, so a cursor parked at the previous target
694
- // is off-frame entirely. Advance the live position EVERY frame and record
695
- // it, so the event track is the motion the viewer actually sees.
746
+ // LAW (record what you animate, founder 2026-08-09): advance the live position EVERY frame and record it, so
747
+ // the event track is the motion the viewer actually sees.
696
748
  cx = nx; cy = ny;
697
749
  pushMouse("move", nx, ny);
698
- // The burned-in cursor only exists in the STANDALONE ending. In the
699
- // DemoBites lane __recSetCursor is undefined, and awaiting a no-op
700
- // evaluate cost a full protocol round trip PER STEP — it doubled the
701
- // step cadence to ~33ms and made the motion chunkier than designed.
702
- if (BURN_CURSOR) {
703
- await page.evaluate(([a, b]) => window.__recSetCursor?.(a, b), [nx, ny]);
704
- }
705
- // LAW (mouse-coordinate clicks): the real mouse tracks the drawn cursor,
706
- // so hover states fire naturally and the click lands where the pulse is.
707
- await page.mouse.move(nx, ny);
708
- await page.waitForTimeout(dur / steps);
750
+ // The burned-in cursor only exists in the STANDALONE ending.
751
+ if (BURN_CURSOR) await page.evaluate(([a, b]) => window.__recSetCursor?.(a, b), [nx, ny]);
752
+ // LAW (mouse-coordinate clicks): the real mouse tracks the drawn cursor, so hover states fire naturally and the
753
+ // click lands where the pulse is. One move in flight at a time: a slow round trip skips points, never the clock.
754
+ if (!inflight) inflight = page.mouse.move(nx, ny).catch(() => {}).finally(() => { inflight = null; });
755
+ if (f >= 1) break;
756
+ await page.waitForTimeout(16);
709
757
  }
758
+ if (inflight) await inflight;
710
759
  cx = x; cy = y;
711
760
  pushMouse("move", x, y);
761
+ await page.mouse.move(x, y).catch(() => {});
712
762
  }
713
763
 
714
764
  async function smoothScroll(dy, ms = 1400, within = null) {
@@ -821,7 +871,7 @@ try {
821
871
  // then stamp the window so manifest.mjs cuts it out with a fade —
822
872
  // the viewer sees page one, a fade, page two. No zoom or narration
823
873
  // may live inside that window (manifest.mjs keeps them out).
824
- await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {});
874
+ await page.waitForLoadState("networkidle", { timeout: 2500 }).catch(() => {});
825
875
  const paintDeadline = Date.now() + 10000;
826
876
  while (Date.now() < paintDeadline) {
827
877
  const painted = await page.evaluate(() => {
@@ -836,6 +886,7 @@ try {
836
886
  if (painted) break;
837
887
  await page.waitForTimeout(200);
838
888
  }
889
+ await settledPicture(3000, "navigation");
839
890
  await page.waitForTimeout(350);
840
891
  rec.nav = { from: navIssuedAt, to: t() };
841
892
  }
@@ -871,6 +922,7 @@ try {
871
922
  if (!painted) {
872
923
  console.log("PAINT GATE: page never showed real content within 15s — the take would open on a blank frame. Judge the footage carefully.");
873
924
  }
925
+ await settledPicture(8000, "first frame");
874
926
  const nextStep = STORYBOARD.steps[STORYBOARD.steps.indexOf(step) + 1];
875
927
  if (nextStep?.action === "expect" && nextStep.selector) {
876
928
  const sel = nextStep.text
@@ -1073,7 +1125,7 @@ try {
1073
1125
  // the dialog opened in the middle of the screen. A step framed wide
1074
1126
  // already frames its result; a click that changed the URL leaves the
1075
1127
  // landing to the settle that follows (framed wide there).
1076
- if (page.url() !== urlBeforeClick) landingPending = true;
1128
+ if (page.url() !== urlBeforeClick) { landingPending = true; await settledPicture(2500, "landing"); }
1077
1129
  if (step.reveals !== false && !stepWide) {
1078
1130
  const opened = await revealedBox(step);
1079
1131
  if (opened) {
@@ -1168,7 +1220,9 @@ try {
1168
1220
  // the rest of the take.
1169
1221
  await page.waitForLoadState("domcontentloaded", { timeout: 30000 }).catch(() => {});
1170
1222
  if (page.url() !== urlBefore) {
1171
- await page.waitForLoadState("networkidle", { timeout: 6000 }).catch(() => {});
1223
+ // The still gate replaces a networkidle that long-pollers never let end (6 s of dead air, 2026-10-18).
1224
+ await page.waitForLoadState("domcontentloaded", { timeout: 6000 }).catch(() => {});
1225
+ await settledPicture(2500, "landing");
1172
1226
  await ensureCursor();
1173
1227
  await applyHideCss();
1174
1228
  await page.evaluate(([a, b]) => window.__recSetCursor?.(a, b), [x, y]);
@@ -1232,6 +1286,10 @@ try {
1232
1286
  }
1233
1287
  // Closing beat so the last action breathes before the cut ends.
1234
1288
  await page.waitForTimeout(CLOSING_BEAT_MS);
1289
+ // THE TAKE SAYS WHAT IT COST (2026-10-18): per step, so a slow take explains itself.
1290
+ const glides = manifest.camera.filter((c) => c.glide).map((c) => c.glide.t_end - c.glide.t_start);
1291
+ manifest.timing = { filmed_s: Math.round(t() * 100) / 100, steps: manifest.steps.map((st) => ({ n: st.n, action: st.action, s: Math.round(((st.t_end ?? 0) - (st.t_start ?? 0)) * 100) / 100 })), glide_s: Math.round(glides.reduce((a, b) => a + b, 0) * 100) / 100, glides: glides.length };
1292
+ console.log(`timing: filmed ${manifest.timing.filmed_s}s; steps ${manifest.timing.steps.map((st) => `${st.n}:${st.s}s`).join(" ")}; glides ${glides.length} totalling ${manifest.timing.glide_s}s`);
1235
1293
  } catch (e) {
1236
1294
  failure = e;
1237
1295
  }
package/scripts/trim.mjs CHANGED
@@ -150,6 +150,51 @@ execFileSync(FFMPEG(), [
150
150
  clean,
151
151
  ], { stdio: "inherit" });
152
152
 
153
+ // GLITCH GUARD (founder 2026-10-18, bite 1234: "extremely bad glitches"): a frame the camera should never have
154
+ // produced — the page painted small over a grey canvas (a viewport emulation caught by the screencast) — must never
155
+ // reach a demo, whatever caused it. Each such frame is replaced by the last good one (freezeframes), the count is
156
+ // logged and kept in the manifest. Detection: the bottom-right quadrant is flat mid-grey (Y≈128, almost no spread);
157
+ // real UI there is white, dark or busy. Needs signalstats + freezeframes in the ffmpeg build; without them it skips.
158
+ try {
159
+ const filters = execFileSync(FFMPEG(), ["-hide_banner", "-filters"], { stdio: ["ignore", "pipe", "ignore"] }).toString();
160
+ if (/\bfreezeframes\b/.test(filters) && /\bsignalstats\b/.test(filters)) {
161
+ const stats = spawnSync(FFMPEG(), ["-hide_banner", "-loglevel", "error", "-i", clean, "-vf", "crop=iw/2:ih/2:iw/2:ih/2,signalstats,metadata=print:file=-", "-f", "null", "-"], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
162
+ const bad = [];
163
+ let frame = -1, yavg = null, ymin = null, ymax = null;
164
+ const flush = () => { if (frame >= 0 && yavg !== null && yavg >= 118 && yavg <= 138 && ymin !== null && ymax !== null && ymax - ymin <= 24) bad.push(frame); };
165
+ for (const line of (stats.stdout || "").split("\n")) {
166
+ const f = line.match(/^frame:(\d+)/);
167
+ if (f) { flush(); frame = Number(f[1]); yavg = ymin = ymax = null; continue; }
168
+ const m = line.match(/lavfi\.signalstats\.(YAVG|YMIN|YMAX)=([\d.]+)/);
169
+ if (!m) continue;
170
+ if (m[1] === "YAVG") yavg = Number(m[2]); else if (m[1] === "YMIN") ymin = Number(m[2]); else ymax = Number(m[2]);
171
+ }
172
+ flush();
173
+ const total = frame + 1;
174
+ if (bad.length && bad.length < total * 0.5) {
175
+ const runs = [];
176
+ for (const n of bad) { const r = runs[runs.length - 1]; if (r && n === r.last + 1) r.last = n; else runs.push({ first: n, last: n }); }
177
+ // Replace each run with the frame before it; a run at the very start takes the first good frame after it.
178
+ const chain = runs.map((r) => ({ ...r, replace: r.first > 0 ? r.first - 1 : Math.min(total - 1, r.last + 1) })).filter((r) => r.replace >= 0 && r.replace < total);
179
+ const labels = chain.map((_, i) => `[r${i}]`).join("");
180
+ let graph = `[0:v]split=${chain.length + 1}[a0]${labels};`;
181
+ chain.forEach((r, i) => { graph += `[a${i}][r${i}]freezeframes=first=${r.first}:last=${r.last}:replace=${r.replace}[a${i + 1}];`; });
182
+ graph += `[a${chain.length}]format=yuv420p[out]`;
183
+ const fixed = path.join(dir, "clean-fixed.mp4");
184
+ void postEvent("finishing", { text: `Replacing ${bad.length} glitch frame${bad.length === 1 ? "" : "s"}…` });
185
+ execFileSync(FFMPEG(), ["-y", "-loglevel", "error", "-i", clean, "-filter_complex", graph, "-map", "[out]", "-c:v", "libx264", "-preset", "medium", "-crf", "19", "-movflags", "+faststart", fixed], { stdio: "inherit" });
186
+ fs.renameSync(fixed, clean);
187
+ man.glitch = { frames: bad.length, windows: chain.map((r) => ({ first: r.first, last: r.last })) };
188
+ fs.writeFileSync(manPath, JSON.stringify(man, null, 2));
189
+ console.log(`glitch guard: ${bad.length} frame${bad.length === 1 ? "" : "s"} in ${chain.length} window${chain.length === 1 ? "" : "s"} replaced by the last good frame (${chain.map((r) => `${(r.first / 30).toFixed(2)}–${((r.last + 1) / 30).toFixed(2)}s`).join(", ")})`);
190
+ } else if (bad.length) {
191
+ console.log(`glitch guard: ${bad.length} of ${total} frames look padded — too many to be a glitch, left as filmed`);
192
+ } else console.log(`glitch guard: clean (${total} frames)`);
193
+ } else console.log("glitch guard: skipped (ffmpeg build without signalstats/freezeframes)");
194
+ } catch (e) {
195
+ console.error("glitch guard: skipped (" + String(e?.message ?? e).split("\n")[0] + ")");
196
+ }
197
+
153
198
  const dur = execFileSync(FFPROBE(), [
154
199
  "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", clean,
155
200
  ]).toString().trim();
@@ -14,8 +14,10 @@ export const secondsOf = (s, firstNarrated) => {
14
14
  case "goto": return 0.1;
15
15
  case "settle": return Math.max(ms(s.ms, 1200) / 1000, say) + slack;
16
16
  case "scroll": return Math.max(ms(s.ms, 1900) / 1000, say) + slack;
17
- case "click":
18
- case "hover": return 1.0 + Math.max(ms(s.after, 2000) / 1000, say) + slack;
17
+ // A click: the glide (~0.7 s), the dwell and the press (0.9 s), then the beat; a click that opens another page
18
+ // lands first (~1 s). A hover: the glide, then the beat (1.6 s at least). Numbers of record.mjs 1.29.0.
19
+ case "click": return 0.7 + 0.9 + Math.max(ms(s.after, 2000) / 1000, say) + (/href|^a\b|url/i.test(String(s.selector ?? "")) || s.url ? 1.0 : 0.3) + slack;
20
+ case "hover": return 0.7 + Math.max(ms(s.dwell, 1600) / 1000, say) + slack;
19
21
  case "type": return 1.0 + String(s.text ?? "").length * 0.05 + Math.max(ms(s.after, 1500) / 1000, say) + slack;
20
22
  case "expect": return 0.3;
21
23
  default: return 1 + say;