demobites 1.28.0 → 1.30.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.28.0",
3
+ "version": "1.30.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"
@@ -45,8 +45,10 @@ const tb = man.timebase;
45
45
  // of it compounded two shifts (-0.71 then -1.67 on one take) and produced a
46
46
  // clock that matched nothing. Re-base first, then anchor once.
47
47
  if (tb && typeof man.record_from === "number") {
48
- tb.a = 1; tb.k = 1; tb.b = -man.record_from; tb.videoRecordFrom = man.record_from;
49
- tb.method = "identity (raw is wall-rate; see 2026-08-09 four-lane verification)";
48
+ // The base keeps the take's speed (trim.mjs 1.30.0: one uniform compression to fit the plan): video = (wall - record_from) / speed.
49
+ const speed = typeof man.speed === "number" && man.speed > 0 ? man.speed : 1;
50
+ tb.a = 1 / speed; tb.k = 1; tb.speed = speed; tb.b = -man.record_from / speed; tb.videoRecordFrom = man.record_from;
51
+ tb.method = speed === 1 ? "identity (raw is wall-rate; see 2026-08-09 four-lane verification)" : `identity, then one uniform ${speed}x compression to fit the plan`;
50
52
  }
51
53
  if (!tb || typeof tb.a !== "number" || typeof tb.b !== "number") {
52
54
  console.error("manifest.json has no timebase stamp. Run trim.mjs first.");
@@ -148,7 +148,8 @@ fs.mkdirSync(profileDir, { recursive: true });
148
148
  // a customer's own uploaded voice. Holding a shot to cover an estimated line
149
149
  // is what produced a 60-second take with the cursor parked for 12 seconds.
150
150
  const DEFAULT_SETTLE_MS = 2200;
151
- 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;
152
153
  const DEFAULT_CLICK_DWELL = 700;
153
154
  const DEFAULT_CLICK_AFTER = 2000;
154
155
  const CLOSING_BEAT_MS = 2200;
@@ -681,9 +682,13 @@ async function revealedBox(step) {
681
682
  // Pace matched to the reference: a 350px move lands in ~0.75s INCLUDING its
682
683
  // deceleration tail. The ballistic shape covers most distance early, so the
683
684
  // same wall duration reads far snappier than min-jerk did.
684
- const GLIDE_MIN_MS = 650;
685
- const GLIDE_MAX_MS = 1250;
686
- 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;
687
692
 
688
693
  // LAW (ballistic motion, measured off the founder's reference hero-demo,
689
694
  // 2026-08-09): the life is in the VELOCITY PROFILE, not the path. Frame-by-
@@ -720,7 +725,6 @@ async function glide(x, y) {
720
725
  // not the 2x capture space, or every move doubles in duration and bow.
721
726
  const designDist = dist / SUPERSAMPLE;
722
727
  const dur = Math.min(GLIDE_MAX_MS, Math.max(GLIDE_MIN_MS, designDist * GLIDE_PER_PX));
723
- const steps = Math.max(12, Math.round(dur / 16));
724
728
  // Near-straight trace: the reference hero-demo's moves are straight to
725
729
  // within 2% (path/chord 1.02) — a big swoop reads as fake, a trace of
726
730
  // curvature reads as a hand. 4.5% capped 20px, DETERMINISTIC.
@@ -731,36 +735,30 @@ async function glide(x, y) {
731
735
  const c1y = y0 + (y - y0) * 0.3 + perpY * arcMag * sign;
732
736
  const c2x = x0 + (x - x0) * 0.75 + perpX * arcMag * 0.35 * sign;
733
737
  const c2y = y0 + (y - y0) * 0.75 + perpY * arcMag * 0.35 * sign;
734
- for (let i = 1; i <= steps; i++) {
735
- 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);
736
743
  const u = 1 - e;
737
744
  const nx = u * u * u * x0 + 3 * u * u * e * c1x + 3 * u * e * e * c2x + e * e * e * x;
738
745
  const ny = u * u * u * y0 + 3 * u * u * e * c1y + 3 * u * e * e * c2y + e * e * e * y;
739
- // LAW (record what you animate, founder 2026-08-09): cx/cy used to be
740
- // assigned only AFTER this loop, so the background sampler recorded the
741
- // OLD position for the entire glide and then teleported. 98% of a real
742
- // take's events were duplicates and the cursor jumped 458px in 0ms. Every
743
- // reported symptom (rigid, jumping, too fast, missing where the action is,
744
- // appearing and reappearing) came from that one omission — at a 3x zoom
745
- // the visible frame is 640x360, so a cursor parked at the previous target
746
- // is off-frame entirely. Advance the live position EVERY frame and record
747
- // 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.
748
748
  cx = nx; cy = ny;
749
749
  pushMouse("move", nx, ny);
750
- // The burned-in cursor only exists in the STANDALONE ending. In the
751
- // DemoBites lane __recSetCursor is undefined, and awaiting a no-op
752
- // evaluate cost a full protocol round trip PER STEP — it doubled the
753
- // step cadence to ~33ms and made the motion chunkier than designed.
754
- if (BURN_CURSOR) {
755
- await page.evaluate(([a, b]) => window.__recSetCursor?.(a, b), [nx, ny]);
756
- }
757
- // LAW (mouse-coordinate clicks): the real mouse tracks the drawn cursor,
758
- // so hover states fire naturally and the click lands where the pulse is.
759
- await page.mouse.move(nx, ny);
760
- 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);
761
757
  }
758
+ if (inflight) await inflight;
762
759
  cx = x; cy = y;
763
760
  pushMouse("move", x, y);
761
+ await page.mouse.move(x, y).catch(() => {});
764
762
  }
765
763
 
766
764
  async function smoothScroll(dy, ms = 1400, within = null) {
@@ -873,7 +871,7 @@ try {
873
871
  // then stamp the window so manifest.mjs cuts it out with a fade —
874
872
  // the viewer sees page one, a fade, page two. No zoom or narration
875
873
  // may live inside that window (manifest.mjs keeps them out).
876
- await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {});
874
+ await page.waitForLoadState("networkidle", { timeout: 2500 }).catch(() => {});
877
875
  const paintDeadline = Date.now() + 10000;
878
876
  while (Date.now() < paintDeadline) {
879
877
  const painted = await page.evaluate(() => {
@@ -888,7 +886,7 @@ try {
888
886
  if (painted) break;
889
887
  await page.waitForTimeout(200);
890
888
  }
891
- await settledPicture(6000, "navigation");
889
+ await settledPicture(3000, "navigation");
892
890
  await page.waitForTimeout(350);
893
891
  rec.nav = { from: navIssuedAt, to: t() };
894
892
  }
@@ -1127,7 +1125,7 @@ try {
1127
1125
  // the dialog opened in the middle of the screen. A step framed wide
1128
1126
  // already frames its result; a click that changed the URL leaves the
1129
1127
  // landing to the settle that follows (framed wide there).
1130
- if (page.url() !== urlBeforeClick) { landingPending = true; await settledPicture(5000, "landing"); }
1128
+ if (page.url() !== urlBeforeClick) { landingPending = true; await settledPicture(2500, "landing"); }
1131
1129
  if (step.reveals !== false && !stepWide) {
1132
1130
  const opened = await revealedBox(step);
1133
1131
  if (opened) {
@@ -1222,8 +1220,9 @@ try {
1222
1220
  // the rest of the take.
1223
1221
  await page.waitForLoadState("domcontentloaded", { timeout: 30000 }).catch(() => {});
1224
1222
  if (page.url() !== urlBefore) {
1225
- await page.waitForLoadState("networkidle", { timeout: 6000 }).catch(() => {});
1226
- await settledPicture(5000, "landing");
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");
1227
1226
  await ensureCursor();
1228
1227
  await applyHideCss();
1229
1228
  await page.evaluate(([a, b]) => window.__recSetCursor?.(a, b), [x, y]);
@@ -1287,6 +1286,10 @@ try {
1287
1286
  }
1288
1287
  // Closing beat so the last action breathes before the cut ends.
1289
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`);
1290
1293
  } catch (e) {
1291
1294
  failure = e;
1292
1295
  }
package/scripts/trim.mjs CHANGED
@@ -129,21 +129,44 @@ if (lastStepEnd > r0) {
129
129
  }
130
130
  }
131
131
  if (cutAt !== r0 - beaconDelta) console.log("beacon: corrected cut clamped at 0 — head shorter than the anchor error");
132
+
133
+ // THE TAKE FITS ITS PLAN (founder 2026-10-18: "it's no shame to run it via ffmpeg and make it 1.4 times faster… planned
134
+ // 30, took 46 — make it 33 or 34"). When the filmed story runs past the storyboard's own clock, the whole take is
135
+ // sped up by ONE factor (1.5 at most) so it lands near the plan, and the timebase carries that factor: every stamped
136
+ // moment (steps, cursor, shots) maps through video = (wall - record_from) / speed, so nothing drifts. Not a rate
137
+ // fit (the identity law stands): a known, deliberate, uniform compression, recorded in the manifest.
138
+ let speed = 1;
139
+ try {
140
+ const { estimateTake } = await import("../skill/scripts/clock.mjs");
141
+ const sb = JSON.parse(fs.readFileSync(path.join(dir, "storyboard.json"), "utf8"));
142
+ const planned = estimateTake(sb.steps);
143
+ const filmed = lastStepEnd > r0 ? lastStepEnd - r0 + TAIL_BEAT_S : 0;
144
+ const target = planned * 1.1;
145
+ if (planned > 5 && filmed > target * 1.03) {
146
+ speed = Math.min(1.5, Math.round((filmed / target) * 100) / 100);
147
+ console.log(`speed: planned ${planned.toFixed(1)}s, filmed ${filmed.toFixed(1)}s — the take is sped up ${speed}x to ${(filmed / speed).toFixed(1)}s`);
148
+ void postEvent("finishing", { text: `Fitting the take to its plan (${speed}x)…` });
149
+ } else console.log(`speed: planned ${planned.toFixed(1)}s, filmed ${filmed.toFixed(1)}s — as filmed`);
150
+ } catch (e) {
151
+ console.log("speed: as filmed (" + String(e?.message ?? e).split("\n")[0] + ")");
152
+ }
153
+ man.speed = speed;
132
154
  man.timebase = {
133
- a: 1,
134
- b: -r0,
135
- method: "identity (raw is wall-rate; see 2026-08-09 four-lane verification)" + beaconMethod,
155
+ a: 1 / speed,
156
+ b: -r0 / speed,
157
+ method: (speed === 1 ? "identity (raw is wall-rate; see 2026-08-09 four-lane verification)" : `identity, then one uniform ${speed}x compression to fit the plan`) + beaconMethod,
136
158
  k: 1,
159
+ speed,
137
160
  videoRecordFrom: r0,
138
161
  ...(beaconMethod ? { beaconDelta } : {}),
139
162
  };
140
163
  fs.writeFileSync(manPath, JSON.stringify(man, null, 2));
141
- console.log(`timebase: identity, video = wall - ${r0.toFixed(3)}${beaconMethod ? ` (cut at raw ${cutAt.toFixed(3)}s)` : ""}`);
164
+ console.log(`timebase: video = (wall - ${r0.toFixed(3)})${speed === 1 ? "" : ` / ${speed}`}${beaconMethod ? ` (cut at raw ${cutAt.toFixed(3)}s)` : ""}`);
142
165
 
143
166
  execFileSync(FFMPEG(), [
144
167
  "-y", "-loglevel", "error",
145
168
  "-i", raw,
146
- "-filter_complex", `[0:v]trim=${tailCut ? `start=${cutAt}:end=${tailCut}` : `start=${cutAt}`},setpts=PTS-STARTPTS,fps=30,format=yuv420p[out]`,
169
+ "-filter_complex", `[0:v]trim=${tailCut ? `start=${cutAt}:end=${tailCut}` : `start=${cutAt}`},setpts=(PTS-STARTPTS)/${speed},fps=30,format=yuv420p[out]`,
147
170
  "-map", "[out]",
148
171
  "-c:v", "libx264", "-preset", "medium", "-crf", "19",
149
172
  "-movflags", "+faststart",
@@ -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;