@navipartner/demo-video 0.7.0 → 0.7.2

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.
@@ -1,7 +1,12 @@
1
1
  import {
2
2
  CARD_IN_MS,
3
3
  CARD_OUT_MS,
4
+ COVERED_MAX_MS,
5
+ COVERED_SETTLED_MS,
6
+ CURSOR_MAX_MS,
7
+ CURSOR_MIN_MS,
4
8
  DEFAULT_VIEWPORT,
9
+ ESTABLISH_MS,
5
10
  LINGER_MS,
6
11
  MIN_SCALE,
7
12
  PARK,
@@ -11,15 +16,20 @@ import {
11
16
  POST_KEY_MS,
12
17
  PRESS_MS,
13
18
  PRE_CLICK_MS,
19
+ REACTION_MS,
20
+ SETTLED_MS,
14
21
  SETTLE_MS,
15
22
  STAGE_HEIGHT,
16
23
  STAGE_PATH,
17
24
  STAGE_WIDTH,
25
+ STILLS_PATH,
18
26
  StoryboardError,
27
+ TYPE_CHAR_MS,
19
28
  ZOOM_MS,
20
29
  cardSeconds,
21
30
  coverage,
22
31
  fittingScale,
32
+ isBehindCard,
23
33
  isCoreStep,
24
34
  lintStoryboard,
25
35
  loadStoryboard,
@@ -27,10 +37,11 @@ import {
27
37
  shows,
28
38
  stageHtml,
29
39
  stageLayout,
40
+ titleSeconds,
30
41
  unionRect,
31
42
  visibleRect,
32
43
  wideShot
33
- } from "../chunk-D7UYGNSF.js";
44
+ } from "../chunk-XMOGUZJU.js";
34
45
 
35
46
  // src/runner/config.ts
36
47
  import path from "path";
@@ -67,8 +78,8 @@ var createDemoConfig = (adapter, demoDir, runnerDir, mode = process.env.DEMO_MOD
67
78
  });
68
79
 
69
80
  // src/runner/register.ts
70
- import fs3 from "fs";
71
- import path4 from "path";
81
+ import fs4 from "fs";
82
+ import path5 from "path";
72
83
 
73
84
  // src/brand.ts
74
85
  import fs from "fs";
@@ -249,16 +260,36 @@ var missingNeeds = (brand, needs = {}) => {
249
260
  ];
250
261
  };
251
262
 
263
+ // src/director.ts
264
+ import fs2 from "fs";
265
+ import path3 from "path";
266
+
252
267
  // src/changes.ts
268
+ var MAX_RESTYLES = 3;
253
269
  var watchChanges = (target) => target.evaluate((el) => {
254
- const changed = /* @__PURE__ */ new Set();
270
+ const w = window;
271
+ w.__demoChanges?.observer.disconnect();
272
+ const changed = /* @__PURE__ */ new Map();
273
+ const watch = { changed, lastAt: Date.now() };
255
274
  const observer = new MutationObserver((records) => {
256
275
  for (const record of records) {
276
+ if (record.type !== "attributes") {
277
+ watch.lastAt = Date.now();
278
+ }
257
279
  const nodes = record.type === "childList" ? Array.from(record.addedNodes) : [record.target];
258
280
  for (const node of nodes) {
259
281
  const changedEl = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
260
282
  if (changedEl && !el.contains(changedEl) && !changedEl.contains(el)) {
261
- changed.add(changedEl);
283
+ const seen = changed.get(changedEl) ?? {
284
+ content: false,
285
+ restyles: 0
286
+ };
287
+ if (record.type === "attributes") {
288
+ seen.restyles++;
289
+ } else {
290
+ seen.content = true;
291
+ }
292
+ changed.set(changedEl, seen);
262
293
  }
263
294
  }
264
295
  }
@@ -266,14 +297,21 @@ var watchChanges = (target) => target.evaluate((el) => {
266
297
  observer.observe(document.body, {
267
298
  subtree: true,
268
299
  childList: true,
269
- characterData: true
300
+ characterData: true,
301
+ attributes: true,
302
+ attributeFilter: ["style", "class", "src"]
270
303
  });
271
- window.__demoChanges = {
272
- observer,
273
- changed
274
- };
304
+ watch.observer = observer;
305
+ w.__demoChanges = watch;
306
+ });
307
+ var watching = (app) => app.evaluate(
308
+ () => Boolean(window.__demoChanges)
309
+ );
310
+ var quietMs = (app) => app.evaluate(() => {
311
+ const watch = window.__demoChanges;
312
+ return watch ? Date.now() - watch.lastAt : Number.POSITIVE_INFINITY;
275
313
  });
276
- var biggestChange = (app) => app.evaluate(() => {
314
+ var biggestChange = (app) => app.evaluate((maxRestyles) => {
277
315
  const w = window;
278
316
  const watch = w.__demoChanges;
279
317
  delete w.__demoChanges;
@@ -282,9 +320,10 @@ var biggestChange = (app) => app.evaluate(() => {
282
320
  }
283
321
  watch.observer.disconnect();
284
322
  let best = null;
323
+ let bestEl = null;
285
324
  let label = "";
286
- for (const el of watch.changed) {
287
- if (!el.isConnected) {
325
+ for (const [el, seen] of watch.changed) {
326
+ if (!el.isConnected || !seen.content && seen.restyles > maxRestyles) {
288
327
  continue;
289
328
  }
290
329
  const r = el.getBoundingClientRect();
@@ -293,16 +332,36 @@ var biggestChange = (app) => app.evaluate(() => {
293
332
  }
294
333
  if (!best || r.width * r.height > best.width * best.height) {
295
334
  best = { x: r.x, y: r.y, width: r.width, height: r.height };
335
+ bestEl = el;
296
336
  label = el.getAttribute("data-testid") ?? el.tagName.toLowerCase() + (el.textContent ? ` "${el.textContent.trim().slice(0, 30)}"` : "");
297
337
  }
298
338
  }
299
- return best ? { rect: best, label } : null;
300
- });
339
+ if (!best || !bestEl) {
340
+ return null;
341
+ }
342
+ let fontPx = Number.POSITIVE_INFINITY;
343
+ const texts = [
344
+ bestEl,
345
+ ...Array.from(bestEl.querySelectorAll("*")).slice(0, 200)
346
+ ];
347
+ for (const node of texts) {
348
+ const hasText = Array.from(node.childNodes).some(
349
+ (c) => c.nodeType === Node.TEXT_NODE && c.textContent?.trim()
350
+ );
351
+ if (hasText) {
352
+ fontPx = Math.min(fontPx, parseFloat(getComputedStyle(node).fontSize));
353
+ }
354
+ }
355
+ return { rect: best, label, fontPx };
356
+ }, MAX_RESTYLES);
301
357
 
302
358
  // src/director.ts
303
359
  var TARGET_TIMEOUT_MS = 15e3;
304
360
  var SHOWN = 0.5;
305
361
  var FRAME_MAX_SCALE = 1.8;
362
+ var PUSH_WORTHY = 1.4;
363
+ var READABLE_PX = 16;
364
+ var STILL_MAX_PX = 1600;
306
365
  var testIdSelector = (selector) => /^[\w-]+$/.test(selector) ? `[data-testid="${selector}"]` : selector;
307
366
  var normalise = (text) => text.replace(/\s+/g, " ").trim();
308
367
  var sameShot = (a, b) => Math.abs(a.k - b.k) < 1e-3 && Math.abs(a.tx - b.tx) < 1 && Math.abs(a.ty - b.ty) < 1;
@@ -327,6 +386,20 @@ var Director = class {
327
386
  * what the click did.
328
387
  */
329
388
  unseen;
389
+ /** Behind a card: no cursor, no camera moves, nothing for the viewer. */
390
+ covered = false;
391
+ /**
392
+ * What clicks in the current wide shot used: their controls and what they
393
+ * changed. When the wide shot ends, all of it must not have fit a push-in.
394
+ */
395
+ wideUse = {
396
+ rects: [],
397
+ names: [],
398
+ textPx: Number.POSITIVE_INFINITY
399
+ };
400
+ /** When the whole window came into view (virtual ms), for ESTABLISH_MS. */
401
+ wideSince = 0;
402
+ cdp;
330
403
  get fast() {
331
404
  return this.options.fast;
332
405
  }
@@ -410,21 +483,39 @@ var Director = class {
410
483
  }
411
484
  // --- camera -----------------------------------------------------------------
412
485
  async zoomTo(t, scale) {
486
+ this.closeWide();
413
487
  const rect = await this.appRect(t);
414
488
  const next = shotFor(this.layout, rect, scale);
415
489
  this.requireSeen("zoom", next);
416
490
  if (sameShot(next, this.shot)) {
417
491
  return;
418
492
  }
493
+ this.wasWide = this.isWide();
419
494
  this.shot = next;
420
495
  this.shotLabel = `the shot on "${t.target}" at ${scale}`;
496
+ await this.moveCamera(next);
497
+ }
498
+ // Films a camera move. A push-in from the wide shot waits until the whole
499
+ // window has been seen for ESTABLISH_MS: a page never opens zoomed.
500
+ async moveCamera(shot) {
421
501
  if (this.fast) {
422
502
  return;
423
503
  }
424
- await this.page.evaluate((s) => window.__demo.setShot(s), next);
425
- await this.wait(ZOOM_MS + SETTLE_MS);
504
+ const fromWide = this.wasWide;
505
+ if (fromWide && !this.covered) {
506
+ await this.wait(ESTABLISH_MS - (this.clockMs() - this.wideSince));
507
+ }
508
+ const instant = this.covered;
509
+ await this.page.evaluate(([s, i]) => window.__demo.setShot(s, i), [
510
+ shot,
511
+ instant
512
+ ]);
513
+ if (!instant) {
514
+ await this.wait(ZOOM_MS + SETTLE_MS);
515
+ }
426
516
  }
427
517
  async frame(targets, options = {}) {
518
+ this.closeWide();
428
519
  const names = targets.map((t) => `"${t.target}"`).join(" and ");
429
520
  for (const t of targets) {
430
521
  await this.appRect(t);
@@ -448,7 +539,7 @@ var Director = class {
448
539
  );
449
540
  if (scale < MIN_SCALE) {
450
541
  throw new Error(
451
- `${names} only fit in the wide shot (at ${scale.toFixed(2)}); frame things that sit together, or stay wide`
542
+ `${names} only fit in the wide shot (at ${scale.toFixed(2)}): stay wide, or frame things that sit together`
452
543
  );
453
544
  }
454
545
  const next = shotFor(this.layout, all, scale);
@@ -459,13 +550,10 @@ var Director = class {
459
550
  if (sameShot(next, this.shot)) {
460
551
  return;
461
552
  }
553
+ this.wasWide = this.isWide();
462
554
  this.shot = next;
463
555
  this.shotLabel = `the shot framing ${names}`;
464
- if (this.fast) {
465
- return;
466
- }
467
- await this.page.evaluate((s) => window.__demo.setShot(s), next);
468
- await this.wait(ZOOM_MS + SETTLE_MS);
556
+ await this.moveCamera(next);
469
557
  }
470
558
  async zoomOut(options = {}) {
471
559
  this.requireSeen("zoomOut");
@@ -488,11 +576,19 @@ var Director = class {
488
576
  await this.page.evaluate((s) => window.__demo.setShot(s), wide);
489
577
  }
490
578
  await this.wait(Math.max(moving ? ZOOM_MS + SETTLE_MS : 0, parkMs));
579
+ if (moving) {
580
+ this.wideSince = this.clockMs();
581
+ }
491
582
  }
583
+ // Whether the camera shows the whole window now.
584
+ isWide() {
585
+ return this.shot.k <= this.layout.fit * 1.01;
586
+ }
587
+ wasWide = false;
492
588
  // --- cursor + input -----------------------------------------------------------
493
589
  /** Rests the cursor in empty space, off the content. */
494
590
  async park() {
495
- if (this.fast) {
591
+ if (this.fast || this.covered) {
496
592
  return;
497
593
  }
498
594
  const ms = await this.page.evaluate(
@@ -510,28 +606,41 @@ var Director = class {
510
606
  const margin = 8;
511
607
  if (cx < v.x + margin || cx > v.x + v.width - margin || cy < v.y + margin || cy > v.y + v.height - margin) {
512
608
  throw new Error(
513
- `"${t.target}" is outside ${this.shotLabel}. Zoom on an area that holds everything this shot uses, or zoomOut first.`
609
+ `"${t.target}" is outside ${this.shotLabel}: zoomOut first, or frame an area that holds everything this shot uses.`
514
610
  );
515
611
  }
516
- return this.toStagePoint(rect);
612
+ return { point: this.toStagePoint(rect), rect };
517
613
  }
518
614
  async click(t, options = {}) {
615
+ await this.interact(t, { zoom: options.zoom, verb: "clicking" });
616
+ }
617
+ // One interaction with a control, as a person makes it: the cursor glides
618
+ // there and rests, presses (a real click, or only the look of one when an
619
+ // adapter action does the work), `then` runs (typing, the action), and the
620
+ // app's reaction plays out. What it changes is watched from before the
621
+ // press to the end, so typing's live preview, a toast after a save and a
622
+ // restyle all count as the result. Behind a card none of it is filmed.
623
+ async interact(t, options) {
519
624
  if (options.zoom !== void 0) {
520
625
  await this.zoomTo(t, options.zoom);
521
626
  } else {
522
627
  this.requireSeen("other");
523
628
  }
524
- const point = await this.pointAt(t);
525
- const zoomed = this.shot.k > this.layout.fit * 1.01;
526
- if (zoomed) {
629
+ const { point, rect } = await this.pointAt(t);
630
+ const filmed = !this.fast && !this.covered;
631
+ const zoomed = !this.isWide();
632
+ const inner = await watching(this.app);
633
+ const fontPx = inner ? Number.NaN : await this.locator(t).evaluate(
634
+ (el) => parseFloat(getComputedStyle(el).fontSize),
635
+ void 0,
636
+ {
637
+ timeout: 1e3
638
+ }
639
+ ).catch(() => Number.NaN);
640
+ if (!inner) {
527
641
  await watchChanges(this.locator(t));
528
642
  }
529
- if (this.fast) {
530
- await this.page.mouse.click(point.x, point.y);
531
- if (zoomed) {
532
- await this.page.waitForTimeout(POST_CLICK_MS);
533
- }
534
- } else {
643
+ if (filmed) {
535
644
  const ms = await this.page.evaluate(
536
645
  ([x, y]) => window.__demo.cursorTo(x, y),
537
646
  [point.x, point.y]
@@ -539,20 +648,87 @@ var Director = class {
539
648
  await this.wait(ms);
540
649
  await this.page.mouse.move(point.x, point.y);
541
650
  await this.wait(PRE_CLICK_MS);
651
+ }
652
+ if (options.press === "look") {
653
+ await this.wait(PRESS_MS);
654
+ } else if (filmed) {
542
655
  await this.page.mouse.down();
543
656
  await this.wait(PRESS_MS);
544
657
  await this.page.mouse.up();
658
+ } else {
659
+ await this.page.mouse.click(point.x, point.y);
660
+ }
661
+ await options.then?.();
662
+ if (this.covered) {
663
+ await this.settle(COVERED_SETTLED_MS, COVERED_MAX_MS);
664
+ if (!inner) {
665
+ await biggestChange(this.app);
666
+ }
667
+ return;
668
+ }
669
+ if (filmed) {
545
670
  await this.wait(POST_CLICK_MS);
671
+ } else {
672
+ await this.page.waitForTimeout(POST_CLICK_MS);
673
+ }
674
+ await this.settle(SETTLED_MS, REACTION_MS);
675
+ if (inner) {
676
+ return;
546
677
  }
547
678
  if (zoomed) {
548
- await this.assertChangeInShot(t);
679
+ await this.assertChangeInShot(t, options.verb);
680
+ return;
681
+ }
682
+ const biggest = await biggestChange(this.app);
683
+ this.wideUse.rects.push(rect, ...biggest ? [biggest.rect] : []);
684
+ this.wideUse.names.push(t.target);
685
+ const smallest = Math.min(
686
+ Number.isFinite(fontPx) ? fontPx : Number.POSITIVE_INFINITY,
687
+ biggest?.fontPx ?? Number.POSITIVE_INFINITY
688
+ );
689
+ if (Number.isFinite(smallest)) {
690
+ this.wideUse.textPx = Math.min(
691
+ this.wideUse.textPx,
692
+ smallest * this.layout.fit
693
+ );
694
+ }
695
+ }
696
+ // Until nothing in the app has changed for `quiet` ms, at most `max` ms:
697
+ // filmed on the virtual clock, in real time in check.
698
+ async settle(quiet, max) {
699
+ for (let waited = 0; waited < max; waited += POLL_MS) {
700
+ if (await quietMs(this.app) >= quiet) {
701
+ return;
702
+ }
703
+ if (this.fast) {
704
+ await this.page.waitForTimeout(POLL_MS);
705
+ } else {
706
+ await this.wait(POLL_MS);
707
+ }
708
+ }
709
+ }
710
+ // The wide shot ends (a push-in, a card, the end). Its clicks must have
711
+ // needed it: if their controls and results all fit a push-in, the wide
712
+ // shot showed them too small to read on a phone.
713
+ closeWide() {
714
+ const { rects, names, textPx } = this.wideUse;
715
+ this.wideUse = { rects: [], names: [], textPx: Number.POSITIVE_INFINITY };
716
+ if (names.length === 0 || textPx >= READABLE_PX) {
717
+ return;
718
+ }
719
+ const scale = fittingScale(this.layout, unionRect(rects));
720
+ if (scale >= PUSH_WORTHY) {
721
+ const used = [...new Set(names)].map((n) => `"${n}"`).join(", ");
722
+ throw new Error(
723
+ `${used} ${names.length > 1 ? "are" : "is"} used in the wide shot, where its text is ${Math.round(textPx)} px, too small to read, and the controls with what they change fit a push-in at ${Math.min(scale, FRAME_MAX_SCALE).toFixed(1)}: frame them (the engine pushes in after a wide beat), or do it behindCard if it's setup`
724
+ );
549
725
  }
550
726
  }
551
727
  // A zoomed shot is about what the click does. If the biggest thing the
552
728
  // click changed lands outside the shot (an item added to a basket
553
729
  // off-screen), the zoom is on the wrong area: the viewer would see a
554
730
  // button pressed and nothing happen.
555
- async assertChangeInShot(t) {
731
+ async assertChangeInShot(t, verb) {
556
732
  const biggest = await biggestChange(this.app);
557
733
  if (!biggest) {
558
734
  return;
@@ -560,7 +736,7 @@ var Director = class {
560
736
  if (coverage(biggest.rect, visibleRect(this.layout, this.shot)) < SHOWN) {
561
737
  this.unseen = {
562
738
  rect: biggest.rect,
563
- message: `clicking "${t.target}" changes ${biggest.label}, which is outside ${this.shotLabel}, and the next step doesn't show it. Frame what the click changes, or pull out right after.`
739
+ message: `${verb} "${t.target}" changes ${biggest.label}, which is outside ${this.shotLabel}, and the next step doesn't show it: zoomOut right after, or frame the control with what it changes.`
564
740
  };
565
741
  }
566
742
  }
@@ -579,17 +755,34 @@ var Director = class {
579
755
  }
580
756
  throw new Error(unseen.message);
581
757
  }
582
- async type(t, text, perCharMs = 70) {
583
- await this.click(t);
584
- if (this.fast) {
585
- await this.page.keyboard.type(text);
586
- return;
587
- }
588
- for (const char of text) {
589
- await this.page.keyboard.type(char);
590
- await this.wait(perCharMs);
591
- }
592
- await this.wait(POST_KEY_MS);
758
+ async type(t, text, perCharMs = TYPE_CHAR_MS, options = {}) {
759
+ await this.interact(t, {
760
+ verb: "typing in",
761
+ then: async () => {
762
+ if (options.replace) {
763
+ await this.page.keyboard.press("ControlOrMeta+A");
764
+ } else {
765
+ await this.app.evaluate(() => {
766
+ const el = document.activeElement;
767
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
768
+ try {
769
+ el.setSelectionRange(el.value.length, el.value.length);
770
+ } catch {
771
+ }
772
+ }
773
+ });
774
+ }
775
+ if (this.fast || this.covered) {
776
+ await this.page.keyboard.type(text);
777
+ return;
778
+ }
779
+ await this.wait(POST_KEY_MS);
780
+ for (const char of text) {
781
+ await this.page.keyboard.type(char);
782
+ await this.wait(perCharMs);
783
+ }
784
+ }
785
+ });
593
786
  }
594
787
  async press(key) {
595
788
  this.requireSeen("other");
@@ -603,14 +796,24 @@ var Director = class {
603
796
  /** After the last step: a zoomed click's change must have been shown. */
604
797
  end() {
605
798
  this.requireSeen("other");
799
+ this.closeWide();
606
800
  }
607
- async during(work) {
608
- this.requireSeen("other");
801
+ async during(work, options = {}) {
609
802
  const recorder = this.options.recorder;
610
- if (this.fast || !recorder) {
611
- return work();
803
+ const run = () => this.fast || !recorder ? work() : recorder.during(work());
804
+ if (options.target) {
805
+ let result2;
806
+ await this.interact(options.target, {
807
+ verb: "using",
808
+ press: "look",
809
+ then: async () => {
810
+ result2 = await run();
811
+ }
812
+ });
813
+ return result2;
612
814
  }
613
- const result = await recorder.during(work());
815
+ this.requireSeen("other");
816
+ const result = await run();
614
817
  await this.wait(POST_ACTION_MS);
615
818
  return result;
616
819
  }
@@ -651,24 +854,85 @@ var Director = class {
651
854
  // --- overlays --------------------------------------------------------------------
652
855
  async card(spec, options = {}) {
653
856
  this.requireSeen("other");
857
+ this.closeWide();
654
858
  this.shot = wideShot(this.layout);
655
859
  this.shotLabel = "the wide shot";
656
860
  if (this.fast) {
861
+ await this.behind(options.behind);
657
862
  return;
658
863
  }
659
- await this.showCard(spec);
660
- await this.wait(CARD_IN_MS);
864
+ if (!options.opening) {
865
+ await this.showCard(spec);
866
+ await this.wait(CARD_IN_MS);
867
+ }
661
868
  await this.page.evaluate(() => {
662
869
  window.__demo.resetCamera();
663
870
  window.__demo.parkNow();
664
871
  });
665
872
  await this.page.mouse.move(PARK.x, PARK.y);
666
- await this.wait(cardSeconds(spec) * 1e3);
873
+ const started = this.clockMs();
874
+ await this.behind(options.behind);
875
+ await this.page.mouse.move(PARK.x, PARK.y);
876
+ const readMs = (options.opening ? titleSeconds(spec, options.kind) : cardSeconds(spec)) * 1e3;
877
+ await this.wait(readMs - (this.clockMs() - started));
667
878
  if (!options.keepUp) {
668
879
  await this.hideCard();
669
880
  await this.wait(CARD_OUT_MS);
881
+ this.wideSince = this.clockMs();
670
882
  }
671
883
  }
884
+ async behind(work) {
885
+ if (!work) {
886
+ return;
887
+ }
888
+ this.covered = true;
889
+ try {
890
+ await work();
891
+ } finally {
892
+ this.covered = false;
893
+ }
894
+ }
895
+ async capture(t, file) {
896
+ const locator = this.locator(t);
897
+ if (!await locator.isVisible().catch(() => false)) {
898
+ return false;
899
+ }
900
+ const r = await this.readRect(locator);
901
+ const { width, height } = this.layout.viewport;
902
+ const x0 = Math.max(0, r.x);
903
+ const y0 = Math.max(0, r.y);
904
+ const x1 = Math.min(width, r.x + r.width);
905
+ const y1 = Math.min(height, r.y + r.height);
906
+ if (x1 - x0 < 1 || y1 - y0 < 1) {
907
+ return false;
908
+ }
909
+ const shot = this.fast ? wideShot(this.layout) : this.shot;
910
+ const k = shot.k;
911
+ const left = Math.ceil(this.layout.left + shot.tx + k * x0);
912
+ const top = Math.ceil(this.layout.top + shot.ty + k * y0);
913
+ const right = Math.floor(this.layout.left + shot.tx + k * x1);
914
+ const bottom = Math.floor(this.layout.top + shot.ty + k * y1);
915
+ if (right - left < 1 || bottom - top < 1) {
916
+ return false;
917
+ }
918
+ this.cdp ??= await this.page.context().newCDPSession(this.page);
919
+ const { data } = await this.cdp.send("Page.captureScreenshot", {
920
+ format: "png",
921
+ clip: {
922
+ x: left,
923
+ y: top,
924
+ width: right - left,
925
+ height: bottom - top,
926
+ scale: Math.min(
927
+ 2 / k,
928
+ STILL_MAX_PX / Math.max(right - left, bottom - top)
929
+ )
930
+ }
931
+ });
932
+ fs2.mkdirSync(path3.dirname(file), { recursive: true });
933
+ fs2.writeFileSync(file, Buffer.from(data, "base64"));
934
+ return true;
935
+ }
672
936
  /** `instant` puts the card up with no dissolve (the video's first frame). */
673
937
  async showCard(spec, instant = false) {
674
938
  if (!this.fast) {
@@ -698,10 +962,367 @@ var Director = class {
698
962
  }
699
963
  };
700
964
 
965
+ // src/run.ts
966
+ var CAMERA_MOVE_MS = ZOOM_MS + SETTLE_MS;
967
+ var CARD_DISSOLVE_MS = CARD_IN_MS + CARD_OUT_MS;
968
+ var IDLE_MARGIN_MS = 1e4;
969
+ var INTERACTIONS = /* @__PURE__ */ new Set(["click", "type", "press"]);
970
+ var CAMERA_MOVES = /* @__PURE__ */ new Set(["zoom", "frame", "zoomOut"]);
971
+ var describeTarget = (t) => typeof t === "string" ? t : typeof t === "object" && t !== null && "target" in t ? `${String(t.target)}${"nth" in t && typeof t.nth === "number" ? `[${t.nth}]` : ""}` : "";
972
+ var describeStep = (step) => {
973
+ if ("targets" in step && Array.isArray(step.targets)) {
974
+ return `${step.do} ${step.targets.map(describeTarget).join(" + ")}`;
975
+ }
976
+ const target = "target" in step ? describeTarget(step) : "";
977
+ return `${step.do}${target ? ` ${target}` : ""}`;
978
+ };
979
+ var StepError = class extends Error {
980
+ constructor(index, step, reason, visible) {
981
+ super(`Step ${index + 1} (${describeStep(step)}) failed: ${reason}`);
982
+ this.index = index;
983
+ this.step = step;
984
+ this.reason = reason;
985
+ this.visible = visible;
986
+ }
987
+ index;
988
+ step;
989
+ reason;
990
+ visible;
991
+ };
992
+ var reasonOf = (cause) => {
993
+ if (cause && typeof cause === "object" && "issues" in cause && Array.isArray(cause.issues)) {
994
+ return cause.issues.map((i) => `${i.path.join(".") || "(params)"}: ${i.message}`).join("; ");
995
+ }
996
+ const text = cause instanceof Error ? cause.message : String(cause);
997
+ return text.split("\n")[0].trim();
998
+ };
999
+ var runStep = async (d, step, adapter) => {
1000
+ if (isCoreStep(step)) {
1001
+ switch (step.do) {
1002
+ case "click":
1003
+ return d.click(step, { zoom: step.zoom });
1004
+ case "type":
1005
+ return d.type(step, step.text, step.perCharMs, {
1006
+ replace: step.replace
1007
+ });
1008
+ case "press":
1009
+ return d.press(step.key);
1010
+ case "zoom":
1011
+ return d.zoomTo(step, step.scale);
1012
+ case "frame":
1013
+ return d.frame(
1014
+ step.targets.map((t) => typeof t === "string" ? { target: t } : t),
1015
+ { maxScale: step.maxScale }
1016
+ );
1017
+ case "zoomOut":
1018
+ return d.zoomOut({ park: step.park });
1019
+ case "park":
1020
+ return d.park();
1021
+ case "waitFor":
1022
+ return d.waitFor(step, {
1023
+ hidden: step.hidden,
1024
+ timeoutMs: step.timeout * 1e3
1025
+ });
1026
+ case "expectText":
1027
+ return d.expectText(step, step.text, {
1028
+ contains: step.contains,
1029
+ timeoutMs: step.timeout * 1e3
1030
+ });
1031
+ case "hold":
1032
+ return d.hold(step.seconds * 1e3);
1033
+ case "card":
1034
+ throw new Error("a card is run by runSteps, not runStep");
1035
+ }
1036
+ }
1037
+ const action = adapter.actions?.[step.do];
1038
+ if (!action) {
1039
+ throw new Error(`unknown step "${step.do}"`);
1040
+ }
1041
+ const params = Object.fromEntries(
1042
+ Object.entries(step).filter(
1043
+ ([key]) => key !== "do" && key !== "behindCard"
1044
+ )
1045
+ );
1046
+ const parsed = action.params.parse(params);
1047
+ const target = action.target?.(parsed);
1048
+ await d.during(
1049
+ () => action.run(d, parsed),
1050
+ target === void 0 ? void 0 : { target: toTarget(target) }
1051
+ );
1052
+ };
1053
+ var needsLinger = (previous) => previous !== void 0 && previous.do !== "hold" && previous.do !== "card" && !isBehindCard(previous);
1054
+ var TITLE = -1;
1055
+ var planCards = (steps, hasTitle) => {
1056
+ const plans = /* @__PURE__ */ new Map();
1057
+ const starts = [
1058
+ ...hasTitle ? [TITLE] : [],
1059
+ ...steps.flatMap((s, i) => s.do === "card" ? [i] : [])
1060
+ ];
1061
+ for (const start of starts) {
1062
+ const behind = [];
1063
+ let j = start + 1;
1064
+ while (j < steps.length && isBehindCard(steps[j])) {
1065
+ behind.push(j++);
1066
+ }
1067
+ plans.set(start, {
1068
+ behind,
1069
+ opens: j < steps.length && steps[j].do !== "card"
1070
+ });
1071
+ }
1072
+ [...plans.entries()].filter(([at, plan]) => at !== TITLE && plan.opens).forEach(([, plan], n) => {
1073
+ plan.chapter = n + 1;
1074
+ });
1075
+ return plans;
1076
+ };
1077
+ var toTarget = (t) => typeof t === "string" ? { target: t } : t;
1078
+ var cardSpecFor = (card, plan, still) => ({
1079
+ eyebrow: card.eyebrow,
1080
+ heading: card.heading,
1081
+ sub: card.sub,
1082
+ logo: card.logo,
1083
+ seconds: card.seconds,
1084
+ style: card.style ?? (plan?.chapter !== void 0 && plan.chapter % 2 === 0 ? "brand" : "pale"),
1085
+ ...still ? { still } : {}
1086
+ });
1087
+ var stillCards = (steps, title) => [
1088
+ ...title?.show !== void 0 ? [TITLE] : [],
1089
+ ...steps.flatMap(
1090
+ (s, i) => s.do === "card" && s.show !== void 0 ? [i] : []
1091
+ )
1092
+ ];
1093
+ var WAITS = /* @__PURE__ */ new Set(["waitFor", "expectText"]);
1094
+ var runSteps = async (d, steps, adapter, options = {}) => {
1095
+ const idleLimitMs = adapter.idleTimeoutSeconds ? adapter.idleTimeoutSeconds * 1e3 - IDLE_MARGIN_MS : Number.POSITIVE_INFINITY;
1096
+ let quietMs2 = options.initialQuietMs ?? 0;
1097
+ const tooQuiet = () => new Error(
1098
+ `the app would sit untouched for ${Math.round(quietMs2 / 1e3)} s; its idle timeout is ${adapter.idleTimeoutSeconds} s. Add an interaction or shorten the holds.`
1099
+ );
1100
+ const plans = planCards(steps, Boolean(options.title));
1101
+ const covered = new Set([...plans.values()].flatMap((p) => p.behind));
1102
+ let pending;
1103
+ const hero = options.title?.show && options.stillFile ? { target: toTarget(options.title.show), seen: false } : void 0;
1104
+ const fail = async (index, cause) => {
1105
+ if (cause instanceof StepError) {
1106
+ throw cause;
1107
+ }
1108
+ const visible = await d.visibleTestIds().catch(() => []);
1109
+ throw new StepError(index, steps[index], reasonOf(cause), visible);
1110
+ };
1111
+ const settlePending = async () => {
1112
+ const was = pending;
1113
+ pending = void 0;
1114
+ if (was && !was.seen) {
1115
+ await fail(
1116
+ was.card,
1117
+ new Error(
1118
+ `"${was.target.target}" (show) never appears in the chapter after this card, so there is no still to put on it`
1119
+ )
1120
+ );
1121
+ }
1122
+ };
1123
+ const runCard = async (at) => {
1124
+ const plan = plans.get(at);
1125
+ const card = at === TITLE ? options.title : steps[at];
1126
+ const show = card.show ? toTarget(card.show) : void 0;
1127
+ if (show && options.stillFile && at !== TITLE) {
1128
+ if (plan?.opens) {
1129
+ pending = { card: at, target: show, seen: false };
1130
+ } else if (!await d.capture(show, options.stillFile(at))) {
1131
+ throw new Error(
1132
+ `"${show.target}" (show) isn't on screen when this card comes up`
1133
+ );
1134
+ }
1135
+ }
1136
+ const behind = plan?.behind ?? [];
1137
+ const last = behind.at(-1) ?? at;
1138
+ const next = steps[last + 1] ?? (last === steps.length - 1 && options.keepLastCardUp ? { do: "card" } : void 0);
1139
+ await d.card(
1140
+ cardSpecFor(
1141
+ card,
1142
+ plan,
1143
+ show && options.stillUrl ? options.stillUrl(at) : void 0
1144
+ ),
1145
+ {
1146
+ keepUp: next?.do === "card",
1147
+ opening: at === TITLE,
1148
+ kind: options.kind,
1149
+ behind: behind.length ? async () => {
1150
+ for (const index of behind) {
1151
+ await runOne(index);
1152
+ }
1153
+ } : void 0
1154
+ }
1155
+ );
1156
+ };
1157
+ const runOne = async (index) => {
1158
+ const step = steps[index];
1159
+ if (step.do === "card") {
1160
+ await settlePending();
1161
+ }
1162
+ try {
1163
+ const linger = step.do === "card" && needsLinger(steps[index - 1]);
1164
+ if (step.do === "hold" || step.do === "card" || CAMERA_MOVES.has(step.do) && !covered.has(index)) {
1165
+ quietMs2 += step.do === "hold" ? Number(step.seconds) * 1e3 : step.do === "card" ? cardSeconds(step) * 1e3 + CARD_DISSOLVE_MS + (linger ? LINGER_MS : 0) : CAMERA_MOVE_MS;
1166
+ if (quietMs2 > idleLimitMs) {
1167
+ throw tooQuiet();
1168
+ }
1169
+ }
1170
+ if (linger) {
1171
+ await d.hold(LINGER_MS);
1172
+ }
1173
+ const started = d.clockMs();
1174
+ if (step.do === "card") {
1175
+ await runCard(index);
1176
+ } else {
1177
+ await runStep(d, step, adapter);
1178
+ }
1179
+ if (WAITS.has(step.do)) {
1180
+ quietMs2 += d.clockMs() - started;
1181
+ if (quietMs2 > idleLimitMs) {
1182
+ throw tooQuiet();
1183
+ }
1184
+ }
1185
+ if (INTERACTIONS.has(step.do) || adapter.actions?.[step.do]) {
1186
+ quietMs2 = 0;
1187
+ }
1188
+ } catch (cause) {
1189
+ await fail(index, cause);
1190
+ }
1191
+ if (pending && step.do !== "card" && options.stillFile) {
1192
+ const seen = await d.capture(
1193
+ pending.target,
1194
+ options.stillFile(pending.card)
1195
+ );
1196
+ pending.seen ||= seen;
1197
+ }
1198
+ if (hero && step.do !== "card" && options.stillFile) {
1199
+ hero.seen ||= await d.capture(hero.target, options.stillFile(TITLE));
1200
+ }
1201
+ };
1202
+ if (options.title) {
1203
+ quietMs2 += titleSeconds(options.title, options.kind) * 1e3 + CARD_OUT_MS;
1204
+ await runCard(TITLE);
1205
+ }
1206
+ for (const index of steps.keys()) {
1207
+ if (!covered.has(index)) {
1208
+ await runOne(index);
1209
+ }
1210
+ }
1211
+ await settlePending();
1212
+ if (hero && !hero.seen) {
1213
+ throw new Error(
1214
+ `title.show: "${hero.target.target}" never appears in the story, so the open card has no snapshot`
1215
+ );
1216
+ }
1217
+ };
1218
+
1219
+ // src/estimate.ts
1220
+ var CURSOR_MS = (CURSOR_MIN_MS + CURSOR_MAX_MS) / 2;
1221
+ var CLICK_MS = CURSOR_MS + PRE_CLICK_MS + PRESS_MS + POST_CLICK_MS;
1222
+ var CAMERA_MS = ZOOM_MS + SETTLE_MS;
1223
+ var stepMs = (step) => {
1224
+ const s = step;
1225
+ switch (step.do) {
1226
+ case "click":
1227
+ return CLICK_MS + (s.zoom !== void 0 ? CAMERA_MS : 0);
1228
+ case "type":
1229
+ return CLICK_MS + String(s.text).length * Number(s.perCharMs ?? TYPE_CHAR_MS) + POST_KEY_MS;
1230
+ case "press":
1231
+ return POST_KEY_MS;
1232
+ case "zoom":
1233
+ case "frame":
1234
+ case "zoomOut":
1235
+ return CAMERA_MS;
1236
+ case "park":
1237
+ return CURSOR_MS;
1238
+ case "hold":
1239
+ return Number(s.seconds) * 1e3;
1240
+ case "waitFor":
1241
+ case "expectText":
1242
+ return 0;
1243
+ default:
1244
+ return POST_ACTION_MS + CURSOR_MS;
1245
+ }
1246
+ };
1247
+ var estimateSeconds = (story) => Math.round(estimateMs(story) / 1e3);
1248
+ var estimateMs = (story) => {
1249
+ const steps = story.steps;
1250
+ const plans = planCards(steps, Boolean(story.title));
1251
+ const covered = new Set([...plans.values()].flatMap((p) => p.behind));
1252
+ const after = (at) => steps[(plans.get(at)?.behind.at(-1) ?? at) + 1];
1253
+ let ms = 0;
1254
+ let wideAt = 0;
1255
+ if (story.title) {
1256
+ ms += titleSeconds(story.title, story.kind) * 1e3;
1257
+ ms += after(TITLE)?.do === "card" ? 0 : CARD_OUT_MS;
1258
+ wideAt = ms;
1259
+ }
1260
+ steps.forEach((step, i) => {
1261
+ if (covered.has(i)) {
1262
+ return;
1263
+ }
1264
+ if (step.do !== "card") {
1265
+ const pushes = step.do === "zoom" || step.do === "frame" || step.do === "click" && step.zoom !== void 0;
1266
+ if (pushes && wideAt !== null) {
1267
+ ms += Math.max(0, ESTABLISH_MS - (ms - wideAt));
1268
+ wideAt = null;
1269
+ }
1270
+ ms += stepMs(step);
1271
+ if (step.do === "zoomOut") {
1272
+ wideAt = ms;
1273
+ }
1274
+ return;
1275
+ }
1276
+ const previous = steps[i - 1];
1277
+ const linger = previous !== void 0 && previous.do !== "hold" && previous.do !== "card" && !isBehindCard(previous);
1278
+ ms += (linger ? LINGER_MS : 0) + CARD_IN_MS;
1279
+ ms += cardSeconds(step) * 1e3;
1280
+ ms += after(i)?.do === "card" || !after(i) && story.closing ? 0 : CARD_OUT_MS;
1281
+ wideAt = ms;
1282
+ });
1283
+ if (story.closing) {
1284
+ const last = steps.at(-1)?.do;
1285
+ ms += last !== "card" && last !== "hold" ? LINGER_MS : 0;
1286
+ ms += CARD_IN_MS + titleSeconds(story.closing, story.kind) * 1e3;
1287
+ }
1288
+ return ms;
1289
+ };
1290
+ var COVERED_STEP_MS = {
1291
+ click: 300,
1292
+ type: 350,
1293
+ press: 100,
1294
+ waitFor: 200
1295
+ };
1296
+ var COVERED_ACTION_MS = 500;
1297
+ var slowCards = (story) => {
1298
+ const steps = story.steps;
1299
+ const plans = planCards(steps, Boolean(story.title));
1300
+ const slow = [];
1301
+ for (const [at, plan] of plans) {
1302
+ const card = at === TITLE ? story.title : steps[at];
1303
+ if (!card || plan.behind.length === 0) {
1304
+ continue;
1305
+ }
1306
+ const setupMs = plan.behind.reduce(
1307
+ (sum, i) => sum + (COVERED_STEP_MS[steps[i].do] ?? COVERED_ACTION_MS),
1308
+ 0
1309
+ );
1310
+ const readSeconds = at === TITLE ? titleSeconds(card, story.kind) : cardSeconds(card);
1311
+ if (setupMs / 1e3 > readSeconds + 0.3) {
1312
+ slow.push({
1313
+ where: at === TITLE ? "title" : `steps.${at}`,
1314
+ readSeconds,
1315
+ setupSeconds: Math.round(setupMs / 100) / 10
1316
+ });
1317
+ }
1318
+ }
1319
+ return slow;
1320
+ };
1321
+
701
1322
  // src/frames.ts
702
1323
  import { spawn } from "child_process";
703
- import fs2 from "fs";
704
- import path3 from "path";
1324
+ import fs3 from "fs";
1325
+ import path4 from "path";
705
1326
  import ffmpegPath from "ffmpeg-static";
706
1327
  var FPS = 60;
707
1328
  var DRAFT_FPS = 30;
@@ -712,8 +1333,8 @@ var FrameRecorder = class {
712
1333
  this.workDir = workDir;
713
1334
  this.stem = stem;
714
1335
  this.fps = fps;
715
- this.rawDir = path3.join(workDir, "raw");
716
- this.videoPath = path3.join(this.rawDir, "stage.mkv");
1336
+ this.rawDir = path4.join(workDir, "raw");
1337
+ this.videoPath = path4.join(this.rawDir, "stage.mkv");
717
1338
  }
718
1339
  page;
719
1340
  workDir;
@@ -748,13 +1369,13 @@ var FrameRecorder = class {
748
1369
  }
749
1370
  /** Starts writing frames. Time advanced before this is not filmed. */
750
1371
  start() {
751
- if (!ffmpegPath || !fs2.existsSync(ffmpegPath)) {
1372
+ if (!ffmpegPath || !fs3.existsSync(ffmpegPath)) {
752
1373
  throw new Error(
753
1374
  "ffmpeg-static has no binary; with pnpm >= 10, allow its install script (onlyBuiltDependencies) and reinstall."
754
1375
  );
755
1376
  }
756
- fs2.rmSync(this.rawDir, { recursive: true, force: true });
757
- fs2.mkdirSync(this.rawDir, { recursive: true });
1377
+ fs3.rmSync(this.rawDir, { recursive: true, force: true });
1378
+ fs3.mkdirSync(this.rawDir, { recursive: true });
758
1379
  const ffmpeg = spawn(
759
1380
  ffmpegPath,
760
1381
  [
@@ -963,138 +1584,14 @@ var FrameRecorder = class {
963
1584
  endAt: null,
964
1585
  storyboard: this.stem
965
1586
  };
966
- fs2.writeFileSync(
967
- path3.join(this.workDir, "manifest.json"),
1587
+ fs3.writeFileSync(
1588
+ path4.join(this.workDir, "manifest.json"),
968
1589
  JSON.stringify(manifest, null, 2)
969
1590
  );
970
1591
  return manifest;
971
1592
  }
972
1593
  };
973
1594
 
974
- // src/run.ts
975
- var CAMERA_MOVE_MS = ZOOM_MS + SETTLE_MS;
976
- var CARD_DISSOLVE_MS = CARD_IN_MS + CARD_OUT_MS;
977
- var IDLE_MARGIN_MS = 1e4;
978
- var INTERACTIONS = /* @__PURE__ */ new Set(["click", "type", "press"]);
979
- var CAMERA_MOVES = /* @__PURE__ */ new Set(["zoom", "frame", "zoomOut"]);
980
- var describeTarget = (t) => typeof t === "string" ? t : typeof t === "object" && t !== null && "target" in t ? `${String(t.target)}${"nth" in t && typeof t.nth === "number" ? `[${t.nth}]` : ""}` : "";
981
- var describeStep = (step) => {
982
- if ("targets" in step && Array.isArray(step.targets)) {
983
- return `${step.do} ${step.targets.map(describeTarget).join(" + ")}`;
984
- }
985
- const target = "target" in step ? describeTarget(step) : "";
986
- return `${step.do}${target ? ` ${target}` : ""}`;
987
- };
988
- var StepError = class extends Error {
989
- constructor(index, step, reason, visible) {
990
- super(`Step ${index + 1} (${describeStep(step)}) failed: ${reason}`);
991
- this.index = index;
992
- this.step = step;
993
- this.reason = reason;
994
- this.visible = visible;
995
- }
996
- index;
997
- step;
998
- reason;
999
- visible;
1000
- };
1001
- var reasonOf = (cause) => {
1002
- if (cause && typeof cause === "object" && "issues" in cause && Array.isArray(cause.issues)) {
1003
- return cause.issues.map((i) => `${i.path.join(".") || "(params)"}: ${i.message}`).join("; ");
1004
- }
1005
- const text = cause instanceof Error ? cause.message : String(cause);
1006
- return text.split("\n")[0].trim();
1007
- };
1008
- var runStep = async (d, step, adapter, next) => {
1009
- if (isCoreStep(step)) {
1010
- switch (step.do) {
1011
- case "click":
1012
- return d.click(step, { zoom: step.zoom });
1013
- case "type":
1014
- return d.type(step, step.text, step.perCharMs);
1015
- case "press":
1016
- return d.press(step.key);
1017
- case "zoom":
1018
- return d.zoomTo(step, step.scale);
1019
- case "frame":
1020
- return d.frame(
1021
- step.targets.map((t) => typeof t === "string" ? { target: t } : t),
1022
- { maxScale: step.maxScale }
1023
- );
1024
- case "zoomOut":
1025
- return d.zoomOut({ park: step.park });
1026
- case "park":
1027
- return d.park();
1028
- case "waitFor":
1029
- return d.waitFor(step, {
1030
- hidden: step.hidden,
1031
- timeoutMs: step.timeout * 1e3
1032
- });
1033
- case "expectText":
1034
- return d.expectText(step, step.text, {
1035
- contains: step.contains,
1036
- timeoutMs: step.timeout * 1e3
1037
- });
1038
- case "hold":
1039
- return d.hold(step.seconds * 1e3);
1040
- case "card":
1041
- return d.card(step, { keepUp: next?.do === "card" });
1042
- }
1043
- }
1044
- const action = adapter.actions?.[step.do];
1045
- if (!action) {
1046
- throw new Error(`unknown step "${step.do}"`);
1047
- }
1048
- const params = Object.fromEntries(
1049
- Object.entries(step).filter(([key]) => key !== "do")
1050
- );
1051
- const parsed = action.params.parse(params);
1052
- await d.during(() => action.run(d, parsed));
1053
- };
1054
- var needsLinger = (previous) => previous !== void 0 && previous.do !== "hold" && previous.do !== "card";
1055
- var WAITS = /* @__PURE__ */ new Set(["waitFor", "expectText"]);
1056
- var runSteps = async (d, steps, adapter, options = {}) => {
1057
- const idleLimitMs = adapter.idleTimeoutSeconds ? adapter.idleTimeoutSeconds * 1e3 - IDLE_MARGIN_MS : Number.POSITIVE_INFINITY;
1058
- let quietMs = options.initialQuietMs ?? 0;
1059
- const tooQuiet = () => new Error(
1060
- `the app would sit untouched for ${Math.round(quietMs / 1e3)} s; its idle timeout is ${adapter.idleTimeoutSeconds} s. Add an interaction or shorten the holds.`
1061
- );
1062
- for (const [index, step] of steps.entries()) {
1063
- try {
1064
- const linger = step.do === "card" && needsLinger(steps[index - 1]);
1065
- if (step.do === "hold" || step.do === "card" || CAMERA_MOVES.has(step.do)) {
1066
- quietMs += step.do === "hold" ? Number(step.seconds) * 1e3 : step.do === "card" ? cardSeconds(step) * 1e3 + CARD_DISSOLVE_MS + (linger ? LINGER_MS : 0) : CAMERA_MOVE_MS;
1067
- if (quietMs > idleLimitMs) {
1068
- throw tooQuiet();
1069
- }
1070
- }
1071
- if (linger) {
1072
- await d.hold(LINGER_MS);
1073
- }
1074
- const isLast = index === steps.length - 1;
1075
- const started = d.clockMs();
1076
- await runStep(
1077
- d,
1078
- step,
1079
- adapter,
1080
- steps[index + 1] ?? (isLast && options.keepLastCardUp ? { do: "card" } : void 0)
1081
- );
1082
- if (WAITS.has(step.do)) {
1083
- quietMs += d.clockMs() - started;
1084
- if (quietMs > idleLimitMs) {
1085
- throw tooQuiet();
1086
- }
1087
- }
1088
- if (INTERACTIONS.has(step.do) || adapter.actions?.[step.do]) {
1089
- quietMs = 0;
1090
- }
1091
- } catch (cause) {
1092
- const visible = await d.visibleTestIds().catch(() => []);
1093
- throw new StepError(index, step, reasonOf(cause), visible);
1094
- }
1095
- }
1096
- };
1097
-
1098
1595
  // src/runner/env.ts
1099
1596
  var readRunEnv = (env = process.env) => {
1100
1597
  const storyboardPath = env.DEMO_STORYBOARD;
@@ -1111,7 +1608,8 @@ var readRunEnv = (env = process.env) => {
1111
1608
  workDir: env.DEMO_WORK || `${outDir}/.work`,
1112
1609
  brand: env.DEMO_BRAND || void 0,
1113
1610
  brandsDir: env.DEMO_BRANDS || void 0,
1114
- draft: env.DEMO_DRAFT === "1"
1611
+ draft: env.DEMO_DRAFT === "1",
1612
+ stillsDir: env.DEMO_STILLS || void 0
1115
1613
  };
1116
1614
  };
1117
1615
 
@@ -1156,20 +1654,20 @@ var bootFailure = (adapter, frameUrl, headers, cause) => {
1156
1654
  return `The app didn't get ready: ${reason}. Check that the adapter's setup mocks what the app calls at start, and that "${adapter.ready}" is shown once it booted.`;
1157
1655
  };
1158
1656
  var writeJson = (file, value) => {
1159
- fs3.mkdirSync(path4.dirname(file), { recursive: true });
1160
- fs3.writeFileSync(file, JSON.stringify(value, null, 2));
1657
+ fs4.mkdirSync(path5.dirname(file), { recursive: true });
1658
+ fs4.writeFileSync(file, JSON.stringify(value, null, 2));
1161
1659
  };
1162
1660
  var registerDemoTests = (test, adapter, demoDir) => {
1163
1661
  if (process.env.DEMO_MODE === "needs") {
1164
1662
  test("demo", async () => {
1165
- writeJson(path4.join(process.env.DEMO_WORK ?? demoDir, "needs.json"), {
1663
+ writeJson(path5.join(process.env.DEMO_WORK ?? demoDir, "needs.json"), {
1166
1664
  app: adapter.app,
1167
1665
  viewport: adapter.viewport ?? DEFAULT_VIEWPORT,
1168
1666
  video: VIDEO_NEEDS,
1169
1667
  appNeeds: describeNeeds(adapter.brandNeeds),
1170
1668
  example: JSON.parse(
1171
- fs3.readFileSync(
1172
- path4.join(sharedBrandsDir(), "navipartner.json"),
1669
+ fs4.readFileSync(
1670
+ path5.join(sharedBrandsDir(), "navipartner.json"),
1173
1671
  "utf8"
1174
1672
  )
1175
1673
  )
@@ -1180,10 +1678,16 @@ var registerDemoTests = (test, adapter, demoDir) => {
1180
1678
  test("demo", async ({ page }) => {
1181
1679
  const env = readRunEnv();
1182
1680
  if (env.mode === "check") {
1183
- const reportPath = path4.join(env.workDir, "check.json");
1681
+ const reportPath = path5.join(env.workDir, "check.json");
1184
1682
  try {
1185
- const steps = await perform(page, env);
1186
- writeJson(reportPath, { ok: true, steps });
1683
+ const story = await perform(page, env);
1684
+ writeJson(reportPath, {
1685
+ ok: true,
1686
+ steps: story.steps.length,
1687
+ kind: story.kind ?? "showcase",
1688
+ seconds: estimateSeconds(story),
1689
+ slowCards: slowCards(story)
1690
+ });
1187
1691
  } catch (error) {
1188
1692
  writeJson(reportPath, failureReport(error));
1189
1693
  throw error;
@@ -1198,7 +1702,7 @@ var registerDemoTests = (test, adapter, demoDir) => {
1198
1702
  if (issues.length > 0) {
1199
1703
  throw new StoryboardError(
1200
1704
  [
1201
- `${path4.basename(env.storyboardPath)} won't film well:`,
1705
+ `${path5.basename(env.storyboardPath)} won't film well:`,
1202
1706
  ...issues.map((i) => ` ${i}`)
1203
1707
  ].join("\n")
1204
1708
  );
@@ -1235,6 +1739,31 @@ var registerDemoTests = (test, adapter, demoDir) => {
1235
1739
  })
1236
1740
  );
1237
1741
  const fast = env.mode === "check";
1742
+ const stillsDir = env.stillsDir ?? path5.join(env.workDir, "stills");
1743
+ const stillName = (index) => index === TITLE ? "card-title.png" : `card-${index}.png`;
1744
+ const stillFile = (index) => path5.join(stillsDir, stillName(index));
1745
+ const stillUrl = (index) => `${STILLS_PATH}/${stillName(index)}`;
1746
+ const stills = stillCards(storyboard.steps, storyboard.title);
1747
+ if (!fast && stills.length > 0) {
1748
+ const missing2 = stills.filter((i) => !fs4.existsSync(stillFile(i)));
1749
+ if (missing2.length > 0) {
1750
+ throw new Error(
1751
+ `No still for the card at ${missing2.map((i) => i === TITLE ? "title" : `steps.${i}`).join(", ")}: render the story with the demo-video CLI, which checks it first and captures them`
1752
+ );
1753
+ }
1754
+ await page.route(
1755
+ `**${STILLS_PATH}/*`,
1756
+ (route) => route.fulfill({
1757
+ contentType: "image/png",
1758
+ body: fs4.readFileSync(
1759
+ path5.join(
1760
+ stillsDir,
1761
+ path5.basename(new URL(route.request().url()).pathname)
1762
+ )
1763
+ )
1764
+ })
1765
+ );
1766
+ }
1238
1767
  if (!fast) {
1239
1768
  await FrameRecorder.prepare(page);
1240
1769
  }
@@ -1262,39 +1791,44 @@ var registerDemoTests = (test, adapter, demoDir) => {
1262
1791
  cause: error
1263
1792
  });
1264
1793
  }
1794
+ const title = storyboard.title;
1265
1795
  if (fast) {
1266
- await runSteps(boot, storyboard.steps, adapter);
1796
+ await runSteps(boot, storyboard.steps, adapter, {
1797
+ title,
1798
+ kind: storyboard.kind,
1799
+ ...stills.length > 0 ? { stillFile } : {}
1800
+ });
1267
1801
  boot.end();
1268
- return storyboard.steps.length;
1802
+ return storyboard;
1269
1803
  }
1270
1804
  await page.evaluate(() => document.fonts.ready.then(() => void 0));
1271
1805
  await frame.evaluate(() => document.fonts.ready.then(() => void 0));
1806
+ await page.evaluate(
1807
+ (urls) => window.__demo.preload(urls),
1808
+ stills.map(stillUrl)
1809
+ );
1272
1810
  await page.waitForTimeout(300);
1273
1811
  const recorder = new FrameRecorder(
1274
1812
  page,
1275
1813
  env.workDir,
1276
- `${path4.basename(env.storyboardPath, ".json")}-${brand.name}${env.draft ? "-draft" : ""}`,
1814
+ `${path5.basename(env.storyboardPath, ".json")}-${brand.name}${env.draft ? "-draft" : ""}`,
1277
1815
  env.draft ? DRAFT_FPS : FPS
1278
1816
  );
1279
1817
  await recorder.freeze();
1280
1818
  const d = new Director(page, frame, { fast: false, viewport, recorder });
1281
- const title = storyboard.title;
1282
1819
  if (title) {
1283
- await d.showCard(title, true);
1820
+ await d.showCard(
1821
+ cardSpecFor(title, void 0, title.show ? stillUrl(TITLE) : void 0),
1822
+ true
1823
+ );
1284
1824
  }
1285
1825
  recorder.start();
1286
- if (title) {
1287
- await recorder.advance(cardSeconds(title) * 1e3);
1288
- if (storyboard.steps[0]?.do !== "card") {
1289
- await d.hideCard();
1290
- await recorder.advance(CARD_OUT_MS);
1291
- }
1292
- }
1293
1826
  const closing = storyboard.closing;
1294
1827
  await runSteps(d, storyboard.steps, adapter, {
1295
- // Booted, the title card and its dissolve.
1296
- initialQuietMs: title ? cardSeconds(title) * 1e3 + CARD_OUT_MS : 0,
1297
- keepLastCardUp: Boolean(closing)
1828
+ title,
1829
+ kind: storyboard.kind,
1830
+ keepLastCardUp: Boolean(closing),
1831
+ stillUrl
1298
1832
  });
1299
1833
  d.end();
1300
1834
  if (closing) {
@@ -1303,11 +1837,13 @@ var registerDemoTests = (test, adapter, demoDir) => {
1303
1837
  await recorder.advance(LINGER_MS);
1304
1838
  }
1305
1839
  await d.showCard(closing);
1306
- await recorder.advance(CARD_IN_MS + cardSeconds(closing) * 1e3);
1840
+ await recorder.advance(
1841
+ CARD_IN_MS + titleSeconds(closing, storyboard.kind) * 1e3
1842
+ );
1307
1843
  }
1308
1844
  await recorder.advance(300);
1309
1845
  await recorder.stop();
1310
- return storyboard.steps.length;
1846
+ return storyboard;
1311
1847
  };
1312
1848
  };
1313
1849
  export {