demobites 1.27.0 → 1.28.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.28.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).
@@ -385,14 +387,64 @@ const postFrame = async () => {
385
387
  const within = (p, ms) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`timeout ${ms} ms`)), ms))]);
386
388
  try {
387
389
  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);
390
+ // THE CAMERA'S VIEWPORT IS NEVER TOUCHED (founder's take of 2026-10-18, bite 1234: grey quarter-size frames at
391
+ // 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
392
+ // for the capture, and the video screencast recorded those frames. So: capture at scale 1, no clip, and shrink
393
+ // the JPEG OUTSIDE the page with ffmpeg. No ffmpeg → no thumbnail, never a glitch.
394
+ const { data } = await within(frameCdp.send("Page.captureScreenshot", { format: "jpeg", quality: 60 }), 4000);
395
+ const small = await within(shrinkJpeg(Buffer.from(data, "base64"), 480), 4000);
396
+ if (small) await within(postEvent("frame", { ...frameStep, jpeg: small.toString("base64") }), 8000);
391
397
  } catch (e) {
392
398
  if (!frameWarned) { frameWarned = true; console.error(`frame: dropped (${String(e?.message ?? e).split("\n")[0]})`); }
393
399
  } finally { framing = false; }
394
400
  };
395
401
  const framer = setInterval(() => void postFrame(), FRAME_EVERY_MS);
402
+ /** A JPEG scaled to `width` by ffmpeg, in memory; null when ffmpeg is not there. */
403
+ let ffmpegPath;
404
+ function shrinkJpeg(buf, width) {
405
+ if (ffmpegPath === undefined) { try { ffmpegPath = FFMPEG() || null; } catch { ffmpegPath = null; } }
406
+ if (!ffmpegPath) return Promise.resolve(null);
407
+ return new Promise((resolve) => {
408
+ 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"] });
409
+ const out = [];
410
+ ff.stdout.on("data", (d) => out.push(d));
411
+ ff.on("error", () => resolve(null));
412
+ ff.on("close", (code) => resolve(code === 0 && out.length ? Buffer.concat(out) : null));
413
+ ff.stdin.on("error", () => {});
414
+ ff.stdin.end(buf);
415
+ });
416
+ }
417
+
418
+ /**
419
+ * THE PICTURE IS STILL (founder 2026-10-18: "wait till the page loads before you're actually recording"; bite 1234
420
+ * opened on skeleton cards). Paint is not load: text and boxes exist while thumbnails and skeletons still shimmer.
421
+ * This waits until no skeleton / busy / shimmer element shows AND two stills 350 ms apart are identical, up to
422
+ * `maxMs`. Stills are full-frame captures at scale 1: they never touch the camera's viewport.
423
+ */
424
+ async function settledPicture(maxMs, label) {
425
+ const deadline = Date.now() + maxMs;
426
+ let prev = null;
427
+ while (Date.now() < deadline) {
428
+ const busy = await page.evaluate(() => {
429
+ let n = 0;
430
+ for (const el of document.querySelectorAll('.animate-pulse, [class*="skeleton" i], [class*="shimmer" i], [aria-busy="true"], [data-loading="true"]')) {
431
+ const r = el.getBoundingClientRect();
432
+ if (r.width > 8 && r.height > 8) n++;
433
+ }
434
+ return n;
435
+ }).catch(() => 0);
436
+ let shot = null;
437
+ try {
438
+ frameCdp ??= await ctx.newCDPSession(page);
439
+ shot = (await frameCdp.send("Page.captureScreenshot", { format: "jpeg", quality: 25 })).data;
440
+ } catch { frameCdp = null; }
441
+ if (!busy && shot && prev && shot === prev) return true;
442
+ prev = shot;
443
+ await page.waitForTimeout(350);
444
+ }
445
+ console.log(`STILL GATE (${label}): the picture kept changing for ${Math.round(maxMs / 1000)} s — going on anyway.`);
446
+ return false;
447
+ }
396
448
 
397
449
  /** Ask the element what cursor it shows. Takes the locator visibleBox already
398
450
  * resolved — re-scanning cost ~1s of dead video per step, which inflated a
@@ -836,6 +888,7 @@ try {
836
888
  if (painted) break;
837
889
  await page.waitForTimeout(200);
838
890
  }
891
+ await settledPicture(6000, "navigation");
839
892
  await page.waitForTimeout(350);
840
893
  rec.nav = { from: navIssuedAt, to: t() };
841
894
  }
@@ -871,6 +924,7 @@ try {
871
924
  if (!painted) {
872
925
  console.log("PAINT GATE: page never showed real content within 15s — the take would open on a blank frame. Judge the footage carefully.");
873
926
  }
927
+ await settledPicture(8000, "first frame");
874
928
  const nextStep = STORYBOARD.steps[STORYBOARD.steps.indexOf(step) + 1];
875
929
  if (nextStep?.action === "expect" && nextStep.selector) {
876
930
  const sel = nextStep.text
@@ -1073,7 +1127,7 @@ try {
1073
1127
  // the dialog opened in the middle of the screen. A step framed wide
1074
1128
  // already frames its result; a click that changed the URL leaves the
1075
1129
  // landing to the settle that follows (framed wide there).
1076
- if (page.url() !== urlBeforeClick) landingPending = true;
1130
+ if (page.url() !== urlBeforeClick) { landingPending = true; await settledPicture(5000, "landing"); }
1077
1131
  if (step.reveals !== false && !stepWide) {
1078
1132
  const opened = await revealedBox(step);
1079
1133
  if (opened) {
@@ -1169,6 +1223,7 @@ try {
1169
1223
  await page.waitForLoadState("domcontentloaded", { timeout: 30000 }).catch(() => {});
1170
1224
  if (page.url() !== urlBefore) {
1171
1225
  await page.waitForLoadState("networkidle", { timeout: 6000 }).catch(() => {});
1226
+ await settledPicture(5000, "landing");
1172
1227
  await ensureCursor();
1173
1228
  await applyHideCss();
1174
1229
  await page.evaluate(([a, b]) => window.__recSetCursor?.(a, b), [x, y]);
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();