demobite 1.0.2 → 1.0.4

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,7 +1,7 @@
1
1
  {
2
2
  "name": "demobite",
3
- "version": "1.0.2",
4
- "description": "The DemoBites agentic recorder \u2014 you prompt, it films a real browser, and DemoBites turns the take into an editable demo bite.",
3
+ "version": "1.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
  "demobite": "launcher/index.mjs"
7
7
  },
@@ -35,4 +35,4 @@
35
35
  },
36
36
  "license": "MIT",
37
37
  "type": "module"
38
- }
38
+ }
@@ -85,6 +85,19 @@ const DEFAULT_HOVER_DWELL = 3200;
85
85
  const DEFAULT_CLICK_DWELL = 700;
86
86
  const DEFAULT_CLICK_AFTER = 2000;
87
87
  const CLOSING_BEAT_MS = 2200;
88
+ // LAW (hold your line, founder 2026-09-02): a beat is never shorter than its
89
+ // own narration. Same estimate manifest.mjs uses (words / 2.6 s + 0.4 s).
90
+ // Storyboard durations (`dwell`, `after`, settle `ms`, scroll `ms`) are
91
+ // MILLISECONDS; an author who writes 3.4 means seconds. Anything under 60 is
92
+ // read as seconds (a 59 ms dwell is never intended).
93
+ // The video track starts up to ~2.5 s AFTER the wall clock (startup lead,
94
+ // measured per take by calibrate.mjs). trim.mjs cuts by record_from, so the
95
+ // opening beat loses that lead in the published file. The FIRST narrated beat
96
+ // after the stamp carries this slack so its line always has its full beat.
97
+ const STARTUP_LEAD_SLACK_S = 2.5;
98
+ let openingBeatPending = false;
99
+ const ms = (v, fallback) => (v == null ? fallback : v < 60 ? Math.round(v * 1000) : v);
100
+ const estimateSpeech = (text) => String(text).trim().split(/\s+/).filter(Boolean).length / 2.6 + 0.4;
88
101
 
89
102
  const narrationOf = (s) => {
90
103
  if (s.narration == null) return null;
@@ -529,9 +542,34 @@ try {
529
542
  };
530
543
  process.stdout.write(`step ${rec.n} ${step.action} ${step.label ?? ""}\n`);
531
544
  if (step.action === "goto") {
545
+ const navIssuedAt = t();
532
546
  await page.goto(step.url, { waitUntil: "load" });
533
547
  await ensureCursor();
534
548
  await applyHideCss();
549
+ if (manifest.record_from !== undefined) {
550
+ // LAW (page transitions, founder 2026-09-02): a mid-take navigation
551
+ // must not show its loading blank. Wait for the new page to PAINT,
552
+ // then stamp the window so manifest.mjs cuts it out with a fade —
553
+ // the viewer sees page one, a fade, page two. No zoom or narration
554
+ // may live inside that window (manifest.mjs keeps them out).
555
+ await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {});
556
+ const paintDeadline = Date.now() + 10000;
557
+ while (Date.now() < paintDeadline) {
558
+ const painted = await page.evaluate(() => {
559
+ const textMass = (document.body?.innerText ?? "").trim().length;
560
+ let visible = 0;
561
+ for (const el of document.querySelectorAll("div, section, main, button")) {
562
+ const r = el.getBoundingClientRect();
563
+ if (r.width > 40 && r.height > 20) { visible++; if (visible > 8) break; }
564
+ }
565
+ return textMass > 80 && visible > 8;
566
+ }).catch(() => false);
567
+ if (painted) break;
568
+ await page.waitForTimeout(200);
569
+ }
570
+ await page.waitForTimeout(350);
571
+ rec.nav = { from: navIssuedAt, to: t() };
572
+ }
535
573
  if (manifest.record_from === undefined) {
536
574
  // LAW (first-frame, founder 2026-08-08, HARDENED 2026-08-13 after a
537
575
  // take opened on a white page): the published cut opens on a FULLY
@@ -575,13 +613,14 @@ try {
575
613
  }
576
614
  await page.waitForTimeout(600);
577
615
  manifest.record_from = t();
616
+ openingBeatPending = true;
578
617
  }
579
618
  await page.evaluate(([a, b]) => window.__recSetCursor?.(a, b), [cx, cy]);
580
619
  } else if (step.action === "settle") {
581
620
  // A settle can still carry the camera: `focus` names what to look at.
582
- const ms = step.ms ?? DEFAULT_SETTLE_MS;
621
+ const settleMs = ms(step.ms, DEFAULT_SETTLE_MS);
583
622
  const shotStart = t();
584
- await page.waitForTimeout(ms);
623
+ await page.waitForTimeout(settleMs);
585
624
  if (step.focus) {
586
625
  const box = await visibleBox(step.focus, step.minY ?? 0, 4000);
587
626
  pushShot(box, shotStart, t(), step.label, { n: rec.n });
@@ -589,7 +628,7 @@ try {
589
628
  } else if (step.action === "scroll") {
590
629
  // scrollTo distances are ALSO zoomed-space under CSS zoom (measured:
591
630
  // scrollTo(0,600) moves 300 design px) — storyboards speak design px.
592
- await smoothScroll(step.dy * SUPERSAMPLE, step.ms ?? 1400, step.within ?? null);
631
+ await smoothScroll(step.dy * SUPERSAMPLE, ms(step.ms, 1400), step.within ?? null);
593
632
  } else if (step.action === "click" || step.action === "hover") {
594
633
  const { box, el } = await visibleTarget(step.selector, step.minY ?? 0);
595
634
  if (!box) throw new Error("no visible target for " + step.selector);
@@ -615,6 +654,7 @@ try {
615
654
  currentCursor = "arrow";
616
655
  await glide(x, y);
617
656
  const arrivalT = t();
657
+ rec.arrival = arrivalT; // narration anchors here (manifest.mjs) — so does the beat hold
618
658
  currentCursor = targetCursor;
619
659
  rec.cursor = currentCursor;
620
660
  pushMouse("move", x, y);
@@ -636,7 +676,7 @@ try {
636
676
  };
637
677
 
638
678
  if (step.action === "hover") {
639
- await page.waitForTimeout(step.dwell ?? DEFAULT_HOVER_DWELL);
679
+ await page.waitForTimeout(ms(step.dwell, DEFAULT_HOVER_DWELL));
640
680
  // LAW (camera choreography, measured off the reference 2026-08-09):
641
681
  // the camera must NOT travel in lockstep with the cursor. When it
642
682
  // does, the cursor sits pinned near frame center while the page
@@ -646,7 +686,7 @@ try {
646
686
  // while the cursor sweeps across it, then the camera reframes.
647
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 } });
648
688
  } else {
649
- await page.waitForTimeout(step.dwell ?? DEFAULT_CLICK_DWELL);
689
+ await page.waitForTimeout(ms(step.dwell, DEFAULT_CLICK_DWELL));
650
690
  await page.evaluate(([a, b]) => window.__recPulse?.(a, b), [x, y]);
651
691
  await page.waitForTimeout(220);
652
692
  rec.click_at = t();
@@ -670,7 +710,7 @@ try {
670
710
  // pointer. The recorder does not guess: it ASKS the page whether the
671
711
  // element it just clicked is still connected, visible, and under the
672
712
  // point. The verdict rides the click event as press:'full'|'down'.
673
- const afterMs = step.after ?? DEFAULT_CLICK_AFTER;
713
+ const afterMs = ms(step.after, DEFAULT_CLICK_AFTER);
674
714
  const early = Math.min(450, afterMs);
675
715
  await page.waitForTimeout(Math.min(200, early));
676
716
  // TIMING LAW (parity forensics 2026-08-09): this check must not eat
@@ -755,6 +795,7 @@ try {
755
795
  currentCursor = "arrow";
756
796
  await glide(x, y);
757
797
  const arrivalT = t();
798
+ rec.arrival = arrivalT; // narration anchors here (manifest.mjs) — so does the beat hold
758
799
  currentCursor = targetCursor;
759
800
  rec.cursor = currentCursor;
760
801
  pushMouse("move", x, y);
@@ -766,7 +807,7 @@ try {
766
807
  w: Math.round(box.width / SUPERSAMPLE), h: Math.round(box.height / SUPERSAMPLE),
767
808
  },
768
809
  };
769
- await page.waitForTimeout(step.dwell ?? DEFAULT_CLICK_DWELL);
810
+ await page.waitForTimeout(ms(step.dwell, DEFAULT_CLICK_DWELL));
770
811
  await page.evaluate(([a, b]) => window.__recPulse?.(a, b), [x, y]);
771
812
  await page.waitForTimeout(220);
772
813
  rec.click_at = t();
@@ -793,7 +834,19 @@ try {
793
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 } });
794
835
  currentCursor = await cursorUnderPoint();
795
836
  pushMouse("move", cx, cy);
796
- await page.waitForTimeout(step.after ?? DEFAULT_CLICK_AFTER);
837
+ await page.waitForTimeout(ms(step.after, DEFAULT_CLICK_AFTER));
838
+ }
839
+ if (rec.narration && step.action !== "goto" && step.action !== "expect") {
840
+ // Hold the shot until the line would have finished, plus a breath — the
841
+ // ingestion refits words to video but cannot fit 5 s of speech in a 2 s
842
+ // beat (founder 2026-09-02: the last track ran past the end).
843
+ // Measured from ARRIVAL (where manifest.mjs anchors the line), not from
844
+ // the cursor's departure — the glide is not speaking time.
845
+ const narrText = typeof rec.narration === "string" ? rec.narration : rec.narration?.text ?? "";
846
+ let need = estimateSpeech(narrText) + 0.5;
847
+ if (openingBeatPending) { need += STARTUP_LEAD_SLACK_S; openingBeatPending = false; }
848
+ const elapsed = t() - (rec.arrival ?? rec.t_start);
849
+ if (elapsed < need) await page.waitForTimeout(Math.round((need - elapsed) * 1000));
797
850
  }
798
851
  rec.t_end = t();
799
852
  manifest.steps.push(rec);
package/skill/SKILL.md CHANGED
@@ -90,7 +90,7 @@ Holding shots to cover estimated lines is what produced a 60 second take with th
90
90
 
91
91
  **Budgets, hold yourself to them:**
92
92
 
93
- - **30 to 45 seconds total.** Over 45 is a rewrite, not a trim.
93
+ - **30 to 45 seconds total (35 to 45 when the story crosses pages).** Over 45 is a rewrite, not a trim; under 30 with two pages is rushing, see the linger law below.
94
94
  - **90 seconds is a HARD CAP, by product concept, and it is a limit, not a
95
95
  target.** A Bite over 90 seconds does not exist. Budget the beats BEFORE
96
96
  filming: if the human's scenario cannot honestly fit inside 90 seconds, do
@@ -103,6 +103,14 @@ Holding shots to cover estimated lines is what produced a 60 second take with th
103
103
 
104
104
  **LAW: a line must FIT its beat, and a beat that NAVIGATES away cannot hold two lines** (founder drift analysis, 2026-08-09). Each narration line plays while its own beat is on screen. If a beat is a click that navigates to a new page, everything you want said ABOUT the old page has to fit BEFORE that click — a ~14-word line is ~5s of speech, so one line per pre-navigation beat, not two stacked. Cramming the intro plus a second observation before a fast navigation is what makes the words drift a beat behind the picture (a list-page sentence finishing over the product page). If you need to say two things about a page, either say them AFTER you have landed on it, or give the source beat a longer `dwell` so the line finishes before the click. The ingestion fits words to the video, but it cannot make 8 seconds of speech fit into a 5 second window — that is authoring, and it is yours.
105
105
 
106
+ **LAW: every take opens with a framing intro** (founder, 2026-09-02). The first narrated beat frames the story for someone who is NOT inside the product yet: "Let's look at what happens when a visitor searches your Update Center for something you have not published yet." Never open on a UI detail ("Search sits at the top right"). The viewer is not in the realm; bring them in first.
107
+
108
+ **LAW: narrate the path, do not drive.** Before every navigation, scroll or drill-down, SAY where we are going and why, in the beat before it: "In the analytics for this Update Center, near the bottom, sits search intelligence." A viewer who only sees a cursor dive into a panel learns nothing about how to get there themselves.
109
+
110
+ **LAW: linger.** A single-page story is 30 to 45 seconds; a story that crosses pages is 35 to 45 seconds, never 23. Every beat holds at least as long as its own line plus a breath (record.mjs now enforces this: a narrated beat waits until words / 2.6 s + 0.8 s have passed), and the last beat holds its whole line so no track ever runs past the end of the video. The ingestion refits words to video; it cannot fit five seconds of speech into a two-second beat.
111
+
112
+ **LAW: page transitions are cut and faded, never watched.** When the story moves to another page, the viewer sees page one, a short fade, page two — never the loading blank. record.mjs stamps every mid-take `goto` and manifest.mjs cuts that window out with a fade (`cuts` in the wire manifest); the ingestion lays it on the bite as a timeline cut. No zoom and no narration live inside a cut (the studio forbids both), so put the line about the new page on the beat AFTER it has landed, and say goodbye to the old page BEFORE the goto.
113
+
106
114
  Storyboard schema:
107
115
 
108
116
  ```json
@@ -122,7 +130,7 @@ Storyboard schema:
122
130
  }
123
131
  ```
124
132
 
125
- Step fields: `action` is one of `goto | settle | scroll | click | hover`. `goto` needs `url`. `settle` takes `ms` and an optional `focus` selector. `scroll` needs `dy` and takes `ms`. `click`/`hover` need `selector` and take `minY` (minimum Y for the visible instance pick), `dwell`, `after`, `waitLoad`. Every step takes `label` and `narration`.
133
+ Step fields: `action` is one of `goto | settle | scroll | click | hover | type | expect`. **Durations (`dwell`, `after`, settle `ms`, scroll `ms`) are milliseconds; a value under 60 is read as seconds** (write `"dwell": 3400` or `"dwell": 3.4`, never `"dwell": 3` meaning 3 ms). `goto` needs `url`. `settle` takes `ms` and an optional `focus` selector. `scroll` needs `dy` and takes `ms`. `click`/`hover` need `selector` and take `minY` (minimum Y for the visible instance pick), `dwell`, `after`, `waitLoad`. Every step takes `label` and `narration`.
126
134
 
127
135
  Two fields carry the whole advantage of this lane, so fill them in:
128
136
 
@@ -246,7 +254,8 @@ What the human approves on that page is the **picture and the coverage**, never
246
254
  click?: { x, y, t }, // frame px + seconds
247
255
  narration?: { text, t, estimated_duration }
248
256
  }],
249
- camera: [{ t_start, t_end, x, y, w, h, label }] // focus rectangles, frame px
257
+ camera: [{ t_start, t_end, x, y, w, h, label }], // focus rectangles, frame px
258
+ cuts?: [{ t_start, t_end, transition: 'fade'|'abrupt', n }] // navigation loads, cut out of the bite
250
259
  }
251
260
  ```
252
261
 
@@ -9,7 +9,8 @@
9
9
  // on_screen?, // what the viewer sees
10
10
  // click?:{x,y,t}, // frame px + seconds
11
11
  // narration?:{text,t,estimated_duration} }],
12
- // camera:[{ t_start, t_end, x, y, w, h, label }] } // focus rectangles
12
+ // camera:[{ t_start, t_end, x, y, w, h, label }], // focus rectangles
13
+ // cuts?:[{ t_start, t_end, transition:'fade'|'abrupt', n }] } // navigation loads cut out
13
14
  //
14
15
  // EVERY time is relative to the uploaded file: record_from is subtracted and
15
16
  // the result clamped at 0.
@@ -132,6 +133,24 @@ const steps = (man.steps ?? []).map((s) => {
132
133
  return out;
133
134
  });
134
135
 
136
+ // ── LAW (page transitions, founder 2026-09-02): a mid-take navigation is CUT
137
+ // out with a FADE — the viewer sees page one, a fade, page two, never the
138
+ // loading blank. record.mjs stamps `nav {from,to}` on every goto after the
139
+ // first; each becomes a cut on the uploaded timeline. No narration may start
140
+ // inside a cut (pushed to the cut's end) and no camera shot may live in one
141
+ // (clipped/split below) — the studio forbids zoom + TTS inside cuts anyway.
142
+ const cuts = (man.steps ?? [])
143
+ .filter((s) => s.nav && Number.isFinite(s.nav.from) && Number.isFinite(s.nav.to))
144
+ .map((s) => ({ t_start: round2(norm(s.nav.from + 0.12)), t_end: round2(norm(s.nav.to)), transition: "fade", n: s.n }))
145
+ .filter((c) => c.t_end - c.t_start >= 0.3 && c.t_start >= 0 && c.t_end <= duration)
146
+ .sort((a, b) => a.t_start - b.t_start);
147
+ const insideCut = (t) => cuts.find((c) => t > c.t_start && t < c.t_end);
148
+ for (const st of steps) {
149
+ if (!st.narration) continue;
150
+ const c = insideCut(st.narration.t);
151
+ if (c) st.narration.t = round2(c.t_end + 0.1);
152
+ }
153
+
135
154
  // ── LAW (breathing room, founder 2026-08-09): narration never speaks over
136
155
  // the first or last half second. A voice at t=0 startles; a voice cut by the
137
156
  // end reads broken. First line starts at >=0.5s; the last line ENDS at
@@ -281,6 +300,27 @@ const interactions = mouseEvents.length
281
300
  }
282
301
  : null;
283
302
 
303
+ // Camera shots never overlap a cut: drop shots inside one, clip those that
304
+ // straddle an edge, split those that contain one.
305
+ if (cuts.length > 0) {
306
+ const clipped = [];
307
+ for (const sh of camera) {
308
+ let pieces = [{ ...sh }];
309
+ for (const c of cuts) {
310
+ const next = [];
311
+ for (const p of pieces) {
312
+ if (p.t_end <= c.t_start || p.t_start >= c.t_end) { next.push(p); continue; }
313
+ if (p.t_start < c.t_start) next.push({ ...p, t_end: round2(c.t_start) });
314
+ if (p.t_end > c.t_end) next.push({ ...p, t_start: round2(c.t_end) });
315
+ }
316
+ pieces = next;
317
+ }
318
+ for (const p of pieces) if (p.t_end - p.t_start >= 0.25) clipped.push(p);
319
+ }
320
+ camera.length = 0;
321
+ camera.push(...clipped.sort((a, b) => a.t_start - b.t_start));
322
+ }
323
+
284
324
  const wire = {
285
325
  version: 2,
286
326
  app: man.app ?? "App",
@@ -289,13 +329,14 @@ const wire = {
289
329
  duration,
290
330
  steps,
291
331
  camera,
332
+ ...(cuts.length > 0 ? { cuts } : {}),
292
333
  ...(interactions ? { interactions } : {}),
293
334
  };
294
335
  const outPath = path.join(dir, "manifest.demobites.json");
295
336
  fs.writeFileSync(outPath, JSON.stringify(wire, null, 2));
296
337
  console.log(
297
338
  `manifest.demobites.json written (v2: ${steps.length} steps, ${camera.length} camera shots, ` +
298
- `${mouseEvents.length} mouse events, ${duration}s)`,
339
+ `${mouseEvents.length} mouse events, ${cuts.length} cut${cuts.length === 1 ? "" : "s"}, ${duration}s)`,
299
340
  );
300
341
 
301
342
  if (wantSrt) {