@navipartner/demo-video 0.7.0 → 0.7.1

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,6 +1,8 @@
1
1
  import {
2
2
  CARD_IN_MS,
3
3
  CARD_OUT_MS,
4
+ CURSOR_MAX_MS,
5
+ CURSOR_MIN_MS,
4
6
  DEFAULT_VIEWPORT,
5
7
  LINGER_MS,
6
8
  MIN_SCALE,
@@ -15,11 +17,14 @@ import {
15
17
  STAGE_HEIGHT,
16
18
  STAGE_PATH,
17
19
  STAGE_WIDTH,
20
+ STILLS_PATH,
18
21
  StoryboardError,
22
+ TYPE_CHAR_MS,
19
23
  ZOOM_MS,
20
24
  cardSeconds,
21
25
  coverage,
22
26
  fittingScale,
27
+ isBehindCard,
23
28
  isCoreStep,
24
29
  lintStoryboard,
25
30
  loadStoryboard,
@@ -27,10 +32,11 @@ import {
27
32
  shows,
28
33
  stageHtml,
29
34
  stageLayout,
35
+ titleSeconds,
30
36
  unionRect,
31
37
  visibleRect,
32
38
  wideShot
33
- } from "../chunk-D7UYGNSF.js";
39
+ } from "../chunk-RMBVQL2J.js";
34
40
 
35
41
  // src/runner/config.ts
36
42
  import path from "path";
@@ -67,8 +73,8 @@ var createDemoConfig = (adapter, demoDir, runnerDir, mode = process.env.DEMO_MOD
67
73
  });
68
74
 
69
75
  // src/runner/register.ts
70
- import fs3 from "fs";
71
- import path4 from "path";
76
+ import fs4 from "fs";
77
+ import path5 from "path";
72
78
 
73
79
  // src/brand.ts
74
80
  import fs from "fs";
@@ -249,6 +255,10 @@ var missingNeeds = (brand, needs = {}) => {
249
255
  ];
250
256
  };
251
257
 
258
+ // src/director.ts
259
+ import fs2 from "fs";
260
+ import path3 from "path";
261
+
252
262
  // src/changes.ts
253
263
  var watchChanges = (target) => target.evaluate((el) => {
254
264
  const changed = /* @__PURE__ */ new Set();
@@ -303,6 +313,8 @@ var biggestChange = (app) => app.evaluate(() => {
303
313
  var TARGET_TIMEOUT_MS = 15e3;
304
314
  var SHOWN = 0.5;
305
315
  var FRAME_MAX_SCALE = 1.8;
316
+ var PUSH_WORTHY = 1.4;
317
+ var STILL_MAX_PX = 1600;
306
318
  var testIdSelector = (selector) => /^[\w-]+$/.test(selector) ? `[data-testid="${selector}"]` : selector;
307
319
  var normalise = (text) => text.replace(/\s+/g, " ").trim();
308
320
  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 +339,17 @@ var Director = class {
327
339
  * what the click did.
328
340
  */
329
341
  unseen;
342
+ /** Behind a card: no cursor, no camera moves, nothing for the viewer. */
343
+ covered = false;
344
+ /**
345
+ * What clicks in the current wide shot used: their controls and what they
346
+ * changed. When the wide shot ends, all of it must not have fit a push-in.
347
+ */
348
+ wideUse = {
349
+ rects: [],
350
+ names: []
351
+ };
352
+ cdp;
330
353
  get fast() {
331
354
  return this.options.fast;
332
355
  }
@@ -410,6 +433,7 @@ var Director = class {
410
433
  }
411
434
  // --- camera -----------------------------------------------------------------
412
435
  async zoomTo(t, scale) {
436
+ this.closeWide();
413
437
  const rect = await this.appRect(t);
414
438
  const next = shotFor(this.layout, rect, scale);
415
439
  this.requireSeen("zoom", next);
@@ -418,13 +442,25 @@ var Director = class {
418
442
  }
419
443
  this.shot = next;
420
444
  this.shotLabel = `the shot on "${t.target}" at ${scale}`;
445
+ await this.moveCamera(next);
446
+ }
447
+ // Films a camera move. Behind a card the shot is simply there when the
448
+ // card goes, so the chapter opens on it.
449
+ async moveCamera(shot) {
421
450
  if (this.fast) {
422
451
  return;
423
452
  }
424
- await this.page.evaluate((s) => window.__demo.setShot(s), next);
425
- await this.wait(ZOOM_MS + SETTLE_MS);
453
+ const instant = this.covered;
454
+ await this.page.evaluate(([s, i]) => window.__demo.setShot(s, i), [
455
+ shot,
456
+ instant
457
+ ]);
458
+ if (!instant) {
459
+ await this.wait(ZOOM_MS + SETTLE_MS);
460
+ }
426
461
  }
427
462
  async frame(targets, options = {}) {
463
+ this.closeWide();
428
464
  const names = targets.map((t) => `"${t.target}"`).join(" and ");
429
465
  for (const t of targets) {
430
466
  await this.appRect(t);
@@ -461,11 +497,7 @@ var Director = class {
461
497
  }
462
498
  this.shot = next;
463
499
  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);
500
+ await this.moveCamera(next);
469
501
  }
470
502
  async zoomOut(options = {}) {
471
503
  this.requireSeen("zoomOut");
@@ -492,7 +524,7 @@ var Director = class {
492
524
  // --- cursor + input -----------------------------------------------------------
493
525
  /** Rests the cursor in empty space, off the content. */
494
526
  async park() {
495
- if (this.fast) {
527
+ if (this.fast || this.covered) {
496
528
  return;
497
529
  }
498
530
  const ms = await this.page.evaluate(
@@ -513,7 +545,7 @@ var Director = class {
513
545
  `"${t.target}" is outside ${this.shotLabel}. Zoom on an area that holds everything this shot uses, or zoomOut first.`
514
546
  );
515
547
  }
516
- return this.toStagePoint(rect);
548
+ return { point: this.toStagePoint(rect), rect };
517
549
  }
518
550
  async click(t, options = {}) {
519
551
  if (options.zoom !== void 0) {
@@ -521,16 +553,20 @@ var Director = class {
521
553
  } else {
522
554
  this.requireSeen("other");
523
555
  }
524
- const point = await this.pointAt(t);
556
+ const { point, rect } = await this.pointAt(t);
525
557
  const zoomed = this.shot.k > this.layout.fit * 1.01;
526
- if (zoomed) {
558
+ const wide = !zoomed && !this.covered;
559
+ if (zoomed || wide) {
527
560
  await watchChanges(this.locator(t));
528
561
  }
529
562
  if (this.fast) {
530
563
  await this.page.mouse.click(point.x, point.y);
531
- if (zoomed) {
564
+ if (zoomed || wide) {
532
565
  await this.page.waitForTimeout(POST_CLICK_MS);
533
566
  }
567
+ } else if (this.covered) {
568
+ await this.page.mouse.click(point.x, point.y);
569
+ await this.wait(POST_CLICK_MS);
534
570
  } else {
535
571
  const ms = await this.page.evaluate(
536
572
  ([x, y]) => window.__demo.cursorTo(x, y),
@@ -546,6 +582,27 @@ var Director = class {
546
582
  }
547
583
  if (zoomed) {
548
584
  await this.assertChangeInShot(t);
585
+ } else if (wide) {
586
+ const biggest = await biggestChange(this.app);
587
+ this.wideUse.rects.push(rect, ...biggest ? [biggest.rect] : []);
588
+ this.wideUse.names.push(t.target);
589
+ }
590
+ }
591
+ // The wide shot ends (a push-in, a card, the end). Its clicks must have
592
+ // needed it: if their controls and results all fit a push-in, the wide
593
+ // shot showed them too small to read on a phone.
594
+ closeWide() {
595
+ const { rects, names } = this.wideUse;
596
+ this.wideUse = { rects: [], names: [] };
597
+ if (names.length === 0) {
598
+ return;
599
+ }
600
+ const scale = fittingScale(this.layout, unionRect(rects));
601
+ if (scale >= PUSH_WORTHY) {
602
+ const clicked = [...new Set(names)].map((n) => `"${n}"`).join(", ");
603
+ throw new Error(
604
+ `${clicked} ${names.length > 1 ? "are" : "is"} clicked in the wide shot, but the controls and what they change fit a push-in at ${Math.min(scale, FRAME_MAX_SCALE).toFixed(1)}: frame them so the shot reads on a phone, or do it behindCard if it's setup`
605
+ );
549
606
  }
550
607
  }
551
608
  // A zoomed shot is about what the click does. If the biggest thing the
@@ -579,10 +636,14 @@ var Director = class {
579
636
  }
580
637
  throw new Error(unseen.message);
581
638
  }
582
- async type(t, text, perCharMs = 70) {
639
+ async type(t, text, perCharMs = TYPE_CHAR_MS, options = {}) {
583
640
  await this.click(t);
584
- if (this.fast) {
641
+ if (options.replace) {
642
+ await this.page.keyboard.press("ControlOrMeta+A");
643
+ }
644
+ if (this.fast || this.covered) {
585
645
  await this.page.keyboard.type(text);
646
+ await this.wait(POST_KEY_MS);
586
647
  return;
587
648
  }
588
649
  for (const char of text) {
@@ -603,6 +664,7 @@ var Director = class {
603
664
  /** After the last step: a zoomed click's change must have been shown. */
604
665
  end() {
605
666
  this.requireSeen("other");
667
+ this.closeWide();
606
668
  }
607
669
  async during(work) {
608
670
  this.requireSeen("other");
@@ -651,24 +713,84 @@ var Director = class {
651
713
  // --- overlays --------------------------------------------------------------------
652
714
  async card(spec, options = {}) {
653
715
  this.requireSeen("other");
716
+ this.closeWide();
654
717
  this.shot = wideShot(this.layout);
655
718
  this.shotLabel = "the wide shot";
656
719
  if (this.fast) {
720
+ await this.behind(options.behind);
657
721
  return;
658
722
  }
659
- await this.showCard(spec);
660
- await this.wait(CARD_IN_MS);
723
+ if (!options.opening) {
724
+ await this.showCard(spec);
725
+ await this.wait(CARD_IN_MS);
726
+ }
661
727
  await this.page.evaluate(() => {
662
728
  window.__demo.resetCamera();
663
729
  window.__demo.parkNow();
664
730
  });
665
731
  await this.page.mouse.move(PARK.x, PARK.y);
666
- await this.wait(cardSeconds(spec) * 1e3);
732
+ const started = this.clockMs();
733
+ await this.behind(options.behind);
734
+ await this.page.mouse.move(PARK.x, PARK.y);
735
+ const readMs = (options.opening ? titleSeconds(spec, options.kind) : cardSeconds(spec)) * 1e3;
736
+ await this.wait(readMs - (this.clockMs() - started));
667
737
  if (!options.keepUp) {
668
738
  await this.hideCard();
669
739
  await this.wait(CARD_OUT_MS);
670
740
  }
671
741
  }
742
+ async behind(work) {
743
+ if (!work) {
744
+ return;
745
+ }
746
+ this.covered = true;
747
+ try {
748
+ await work();
749
+ } finally {
750
+ this.covered = false;
751
+ }
752
+ }
753
+ async capture(t, file) {
754
+ const locator = this.locator(t);
755
+ if (!await locator.isVisible().catch(() => false)) {
756
+ return false;
757
+ }
758
+ const r = await this.readRect(locator);
759
+ const { width, height } = this.layout.viewport;
760
+ const x0 = Math.max(0, r.x);
761
+ const y0 = Math.max(0, r.y);
762
+ const x1 = Math.min(width, r.x + r.width);
763
+ const y1 = Math.min(height, r.y + r.height);
764
+ if (x1 - x0 < 1 || y1 - y0 < 1) {
765
+ return false;
766
+ }
767
+ const shot = this.fast ? wideShot(this.layout) : this.shot;
768
+ const k = shot.k;
769
+ const left = Math.ceil(this.layout.left + shot.tx + k * x0);
770
+ const top = Math.ceil(this.layout.top + shot.ty + k * y0);
771
+ const right = Math.floor(this.layout.left + shot.tx + k * x1);
772
+ const bottom = Math.floor(this.layout.top + shot.ty + k * y1);
773
+ if (right - left < 1 || bottom - top < 1) {
774
+ return false;
775
+ }
776
+ this.cdp ??= await this.page.context().newCDPSession(this.page);
777
+ const { data } = await this.cdp.send("Page.captureScreenshot", {
778
+ format: "png",
779
+ clip: {
780
+ x: left,
781
+ y: top,
782
+ width: right - left,
783
+ height: bottom - top,
784
+ scale: Math.min(
785
+ 2 / k,
786
+ STILL_MAX_PX / Math.max(right - left, bottom - top)
787
+ )
788
+ }
789
+ });
790
+ fs2.mkdirSync(path3.dirname(file), { recursive: true });
791
+ fs2.writeFileSync(file, Buffer.from(data, "base64"));
792
+ return true;
793
+ }
672
794
  /** `instant` puts the card up with no dissolve (the video's first frame). */
673
795
  async showCard(spec, instant = false) {
674
796
  if (!this.fast) {
@@ -698,10 +820,321 @@ var Director = class {
698
820
  }
699
821
  };
700
822
 
823
+ // src/run.ts
824
+ var CAMERA_MOVE_MS = ZOOM_MS + SETTLE_MS;
825
+ var CARD_DISSOLVE_MS = CARD_IN_MS + CARD_OUT_MS;
826
+ var IDLE_MARGIN_MS = 1e4;
827
+ var INTERACTIONS = /* @__PURE__ */ new Set(["click", "type", "press"]);
828
+ var CAMERA_MOVES = /* @__PURE__ */ new Set(["zoom", "frame", "zoomOut"]);
829
+ 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}]` : ""}` : "";
830
+ var describeStep = (step) => {
831
+ if ("targets" in step && Array.isArray(step.targets)) {
832
+ return `${step.do} ${step.targets.map(describeTarget).join(" + ")}`;
833
+ }
834
+ const target = "target" in step ? describeTarget(step) : "";
835
+ return `${step.do}${target ? ` ${target}` : ""}`;
836
+ };
837
+ var StepError = class extends Error {
838
+ constructor(index, step, reason, visible) {
839
+ super(`Step ${index + 1} (${describeStep(step)}) failed: ${reason}`);
840
+ this.index = index;
841
+ this.step = step;
842
+ this.reason = reason;
843
+ this.visible = visible;
844
+ }
845
+ index;
846
+ step;
847
+ reason;
848
+ visible;
849
+ };
850
+ var reasonOf = (cause) => {
851
+ if (cause && typeof cause === "object" && "issues" in cause && Array.isArray(cause.issues)) {
852
+ return cause.issues.map((i) => `${i.path.join(".") || "(params)"}: ${i.message}`).join("; ");
853
+ }
854
+ const text = cause instanceof Error ? cause.message : String(cause);
855
+ return text.split("\n")[0].trim();
856
+ };
857
+ var runStep = async (d, step, adapter) => {
858
+ if (isCoreStep(step)) {
859
+ switch (step.do) {
860
+ case "click":
861
+ return d.click(step, { zoom: step.zoom });
862
+ case "type":
863
+ return d.type(step, step.text, step.perCharMs, {
864
+ replace: step.replace
865
+ });
866
+ case "press":
867
+ return d.press(step.key);
868
+ case "zoom":
869
+ return d.zoomTo(step, step.scale);
870
+ case "frame":
871
+ return d.frame(
872
+ step.targets.map((t) => typeof t === "string" ? { target: t } : t),
873
+ { maxScale: step.maxScale }
874
+ );
875
+ case "zoomOut":
876
+ return d.zoomOut({ park: step.park });
877
+ case "park":
878
+ return d.park();
879
+ case "waitFor":
880
+ return d.waitFor(step, {
881
+ hidden: step.hidden,
882
+ timeoutMs: step.timeout * 1e3
883
+ });
884
+ case "expectText":
885
+ return d.expectText(step, step.text, {
886
+ contains: step.contains,
887
+ timeoutMs: step.timeout * 1e3
888
+ });
889
+ case "hold":
890
+ return d.hold(step.seconds * 1e3);
891
+ case "card":
892
+ throw new Error("a card is run by runSteps, not runStep");
893
+ }
894
+ }
895
+ const action = adapter.actions?.[step.do];
896
+ if (!action) {
897
+ throw new Error(`unknown step "${step.do}"`);
898
+ }
899
+ const params = Object.fromEntries(
900
+ Object.entries(step).filter(([key]) => key !== "do")
901
+ );
902
+ const parsed = action.params.parse(params);
903
+ await d.during(() => action.run(d, parsed));
904
+ };
905
+ var needsLinger = (previous) => previous !== void 0 && previous.do !== "hold" && previous.do !== "card" && !isBehindCard(previous);
906
+ var TITLE = -1;
907
+ var planCards = (steps, hasTitle) => {
908
+ const plans = /* @__PURE__ */ new Map();
909
+ const starts = [
910
+ ...hasTitle ? [TITLE] : [],
911
+ ...steps.flatMap((s, i) => s.do === "card" ? [i] : [])
912
+ ];
913
+ for (const start of starts) {
914
+ const behind = [];
915
+ let j = start + 1;
916
+ while (j < steps.length && isBehindCard(steps[j])) {
917
+ behind.push(j++);
918
+ }
919
+ if (j < steps.length && (steps[j].do === "zoom" || steps[j].do === "frame")) {
920
+ behind.push(j++);
921
+ }
922
+ plans.set(start, {
923
+ behind,
924
+ opens: j < steps.length && steps[j].do !== "card"
925
+ });
926
+ }
927
+ [...plans.entries()].filter(([at, plan]) => at !== TITLE && plan.opens).forEach(([, plan], n) => {
928
+ plan.chapter = n + 1;
929
+ });
930
+ return plans;
931
+ };
932
+ var toTarget = (t) => typeof t === "string" ? { target: t } : t;
933
+ var cardSpecFor = (card, plan, still) => ({
934
+ eyebrow: card.eyebrow,
935
+ heading: card.heading,
936
+ sub: card.sub,
937
+ logo: card.logo,
938
+ seconds: card.seconds,
939
+ style: card.style ?? (plan?.chapter !== void 0 && plan.chapter % 2 === 0 ? "brand" : "pale"),
940
+ ...still ? { still } : {}
941
+ });
942
+ var stillCards = (steps, title) => [
943
+ ...title?.show !== void 0 ? [TITLE] : [],
944
+ ...steps.flatMap(
945
+ (s, i) => s.do === "card" && s.show !== void 0 ? [i] : []
946
+ )
947
+ ];
948
+ var WAITS = /* @__PURE__ */ new Set(["waitFor", "expectText"]);
949
+ var runSteps = async (d, steps, adapter, options = {}) => {
950
+ const idleLimitMs = adapter.idleTimeoutSeconds ? adapter.idleTimeoutSeconds * 1e3 - IDLE_MARGIN_MS : Number.POSITIVE_INFINITY;
951
+ let quietMs = options.initialQuietMs ?? 0;
952
+ const tooQuiet = () => new Error(
953
+ `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.`
954
+ );
955
+ const plans = planCards(steps, Boolean(options.title));
956
+ const covered = new Set([...plans.values()].flatMap((p) => p.behind));
957
+ let pending;
958
+ const hero = options.title?.show && options.stillFile ? { target: toTarget(options.title.show), seen: false } : void 0;
959
+ const fail = async (index, cause) => {
960
+ if (cause instanceof StepError) {
961
+ throw cause;
962
+ }
963
+ const visible = await d.visibleTestIds().catch(() => []);
964
+ throw new StepError(index, steps[index], reasonOf(cause), visible);
965
+ };
966
+ const settlePending = async () => {
967
+ const was = pending;
968
+ pending = void 0;
969
+ if (was && !was.seen) {
970
+ await fail(
971
+ was.card,
972
+ new Error(
973
+ `"${was.target.target}" (show) never appears in the chapter after this card, so there is no still to put on it`
974
+ )
975
+ );
976
+ }
977
+ };
978
+ const runCard = async (at) => {
979
+ const plan = plans.get(at);
980
+ const card = at === TITLE ? options.title : steps[at];
981
+ const show = card.show ? toTarget(card.show) : void 0;
982
+ if (show && options.stillFile && at !== TITLE) {
983
+ if (plan?.opens) {
984
+ pending = { card: at, target: show, seen: false };
985
+ } else if (!await d.capture(show, options.stillFile(at))) {
986
+ throw new Error(
987
+ `"${show.target}" (show) isn't on screen when this card comes up`
988
+ );
989
+ }
990
+ }
991
+ const behind = plan?.behind ?? [];
992
+ const last = behind.at(-1) ?? at;
993
+ const next = steps[last + 1] ?? (last === steps.length - 1 && options.keepLastCardUp ? { do: "card" } : void 0);
994
+ await d.card(
995
+ cardSpecFor(
996
+ card,
997
+ plan,
998
+ show && options.stillUrl ? options.stillUrl(at) : void 0
999
+ ),
1000
+ {
1001
+ keepUp: next?.do === "card",
1002
+ opening: at === TITLE,
1003
+ kind: options.kind,
1004
+ behind: behind.length ? async () => {
1005
+ for (const index of behind) {
1006
+ await runOne(index);
1007
+ }
1008
+ } : void 0
1009
+ }
1010
+ );
1011
+ };
1012
+ const runOne = async (index) => {
1013
+ const step = steps[index];
1014
+ if (step.do === "card") {
1015
+ await settlePending();
1016
+ }
1017
+ try {
1018
+ const linger = step.do === "card" && needsLinger(steps[index - 1]);
1019
+ if (step.do === "hold" || step.do === "card" || CAMERA_MOVES.has(step.do) && !covered.has(index)) {
1020
+ quietMs += step.do === "hold" ? Number(step.seconds) * 1e3 : step.do === "card" ? cardSeconds(step) * 1e3 + CARD_DISSOLVE_MS + (linger ? LINGER_MS : 0) : CAMERA_MOVE_MS;
1021
+ if (quietMs > idleLimitMs) {
1022
+ throw tooQuiet();
1023
+ }
1024
+ }
1025
+ if (linger) {
1026
+ await d.hold(LINGER_MS);
1027
+ }
1028
+ const started = d.clockMs();
1029
+ if (step.do === "card") {
1030
+ await runCard(index);
1031
+ } else {
1032
+ await runStep(d, step, adapter);
1033
+ }
1034
+ if (WAITS.has(step.do)) {
1035
+ quietMs += d.clockMs() - started;
1036
+ if (quietMs > idleLimitMs) {
1037
+ throw tooQuiet();
1038
+ }
1039
+ }
1040
+ if (INTERACTIONS.has(step.do) || adapter.actions?.[step.do]) {
1041
+ quietMs = 0;
1042
+ }
1043
+ } catch (cause) {
1044
+ await fail(index, cause);
1045
+ }
1046
+ if (pending && step.do !== "card" && options.stillFile) {
1047
+ const seen = await d.capture(
1048
+ pending.target,
1049
+ options.stillFile(pending.card)
1050
+ );
1051
+ pending.seen ||= seen;
1052
+ }
1053
+ if (hero && step.do !== "card" && options.stillFile) {
1054
+ hero.seen ||= await d.capture(hero.target, options.stillFile(TITLE));
1055
+ }
1056
+ };
1057
+ if (options.title) {
1058
+ quietMs += titleSeconds(options.title, options.kind) * 1e3 + CARD_OUT_MS;
1059
+ await runCard(TITLE);
1060
+ }
1061
+ for (const index of steps.keys()) {
1062
+ if (!covered.has(index)) {
1063
+ await runOne(index);
1064
+ }
1065
+ }
1066
+ await settlePending();
1067
+ if (hero && !hero.seen) {
1068
+ throw new Error(
1069
+ `title.show: "${hero.target.target}" never appears in the story, so the open card has no snapshot`
1070
+ );
1071
+ }
1072
+ };
1073
+
1074
+ // src/estimate.ts
1075
+ var CURSOR_MS = (CURSOR_MIN_MS + CURSOR_MAX_MS) / 2;
1076
+ var CLICK_MS = CURSOR_MS + PRE_CLICK_MS + PRESS_MS + POST_CLICK_MS;
1077
+ var CAMERA_MS = ZOOM_MS + SETTLE_MS;
1078
+ var stepMs = (step) => {
1079
+ const s = step;
1080
+ switch (step.do) {
1081
+ case "click":
1082
+ return CLICK_MS + (s.zoom !== void 0 ? CAMERA_MS : 0);
1083
+ case "type":
1084
+ return CLICK_MS + String(s.text).length * Number(s.perCharMs ?? TYPE_CHAR_MS) + POST_KEY_MS;
1085
+ case "press":
1086
+ return POST_KEY_MS;
1087
+ case "zoom":
1088
+ case "frame":
1089
+ case "zoomOut":
1090
+ return CAMERA_MS;
1091
+ case "park":
1092
+ return CURSOR_MS;
1093
+ case "hold":
1094
+ return Number(s.seconds) * 1e3;
1095
+ case "waitFor":
1096
+ case "expectText":
1097
+ return 0;
1098
+ default:
1099
+ return POST_ACTION_MS + CURSOR_MS;
1100
+ }
1101
+ };
1102
+ var estimateSeconds = (story) => {
1103
+ const steps = story.steps;
1104
+ const plans = planCards(steps, Boolean(story.title));
1105
+ const covered = new Set([...plans.values()].flatMap((p) => p.behind));
1106
+ const after = (at) => steps[(plans.get(at)?.behind.at(-1) ?? at) + 1];
1107
+ let ms = 0;
1108
+ if (story.title) {
1109
+ ms += titleSeconds(story.title, story.kind) * 1e3;
1110
+ ms += after(TITLE)?.do === "card" ? 0 : CARD_OUT_MS;
1111
+ }
1112
+ steps.forEach((step, i) => {
1113
+ if (covered.has(i)) {
1114
+ return;
1115
+ }
1116
+ if (step.do !== "card") {
1117
+ ms += stepMs(step);
1118
+ return;
1119
+ }
1120
+ const previous = steps[i - 1];
1121
+ const linger = previous !== void 0 && previous.do !== "hold" && previous.do !== "card" && !isBehindCard(previous);
1122
+ ms += (linger ? LINGER_MS : 0) + CARD_IN_MS;
1123
+ ms += cardSeconds(step) * 1e3;
1124
+ ms += after(i)?.do === "card" || !after(i) && story.closing ? 0 : CARD_OUT_MS;
1125
+ });
1126
+ if (story.closing) {
1127
+ const last = steps.at(-1)?.do;
1128
+ ms += last !== "card" && last !== "hold" ? LINGER_MS : 0;
1129
+ ms += CARD_IN_MS + titleSeconds(story.closing, story.kind) * 1e3;
1130
+ }
1131
+ return Math.round(ms / 1e3);
1132
+ };
1133
+
701
1134
  // src/frames.ts
702
1135
  import { spawn } from "child_process";
703
- import fs2 from "fs";
704
- import path3 from "path";
1136
+ import fs3 from "fs";
1137
+ import path4 from "path";
705
1138
  import ffmpegPath from "ffmpeg-static";
706
1139
  var FPS = 60;
707
1140
  var DRAFT_FPS = 30;
@@ -712,8 +1145,8 @@ var FrameRecorder = class {
712
1145
  this.workDir = workDir;
713
1146
  this.stem = stem;
714
1147
  this.fps = fps;
715
- this.rawDir = path3.join(workDir, "raw");
716
- this.videoPath = path3.join(this.rawDir, "stage.mkv");
1148
+ this.rawDir = path4.join(workDir, "raw");
1149
+ this.videoPath = path4.join(this.rawDir, "stage.mkv");
717
1150
  }
718
1151
  page;
719
1152
  workDir;
@@ -748,13 +1181,13 @@ var FrameRecorder = class {
748
1181
  }
749
1182
  /** Starts writing frames. Time advanced before this is not filmed. */
750
1183
  start() {
751
- if (!ffmpegPath || !fs2.existsSync(ffmpegPath)) {
1184
+ if (!ffmpegPath || !fs3.existsSync(ffmpegPath)) {
752
1185
  throw new Error(
753
1186
  "ffmpeg-static has no binary; with pnpm >= 10, allow its install script (onlyBuiltDependencies) and reinstall."
754
1187
  );
755
1188
  }
756
- fs2.rmSync(this.rawDir, { recursive: true, force: true });
757
- fs2.mkdirSync(this.rawDir, { recursive: true });
1189
+ fs3.rmSync(this.rawDir, { recursive: true, force: true });
1190
+ fs3.mkdirSync(this.rawDir, { recursive: true });
758
1191
  const ffmpeg = spawn(
759
1192
  ffmpegPath,
760
1193
  [
@@ -963,138 +1396,14 @@ var FrameRecorder = class {
963
1396
  endAt: null,
964
1397
  storyboard: this.stem
965
1398
  };
966
- fs2.writeFileSync(
967
- path3.join(this.workDir, "manifest.json"),
1399
+ fs3.writeFileSync(
1400
+ path4.join(this.workDir, "manifest.json"),
968
1401
  JSON.stringify(manifest, null, 2)
969
1402
  );
970
1403
  return manifest;
971
1404
  }
972
1405
  };
973
1406
 
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
1407
  // src/runner/env.ts
1099
1408
  var readRunEnv = (env = process.env) => {
1100
1409
  const storyboardPath = env.DEMO_STORYBOARD;
@@ -1111,7 +1420,8 @@ var readRunEnv = (env = process.env) => {
1111
1420
  workDir: env.DEMO_WORK || `${outDir}/.work`,
1112
1421
  brand: env.DEMO_BRAND || void 0,
1113
1422
  brandsDir: env.DEMO_BRANDS || void 0,
1114
- draft: env.DEMO_DRAFT === "1"
1423
+ draft: env.DEMO_DRAFT === "1",
1424
+ stillsDir: env.DEMO_STILLS || void 0
1115
1425
  };
1116
1426
  };
1117
1427
 
@@ -1156,20 +1466,20 @@ var bootFailure = (adapter, frameUrl, headers, cause) => {
1156
1466
  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
1467
  };
1158
1468
  var writeJson = (file, value) => {
1159
- fs3.mkdirSync(path4.dirname(file), { recursive: true });
1160
- fs3.writeFileSync(file, JSON.stringify(value, null, 2));
1469
+ fs4.mkdirSync(path5.dirname(file), { recursive: true });
1470
+ fs4.writeFileSync(file, JSON.stringify(value, null, 2));
1161
1471
  };
1162
1472
  var registerDemoTests = (test, adapter, demoDir) => {
1163
1473
  if (process.env.DEMO_MODE === "needs") {
1164
1474
  test("demo", async () => {
1165
- writeJson(path4.join(process.env.DEMO_WORK ?? demoDir, "needs.json"), {
1475
+ writeJson(path5.join(process.env.DEMO_WORK ?? demoDir, "needs.json"), {
1166
1476
  app: adapter.app,
1167
1477
  viewport: adapter.viewport ?? DEFAULT_VIEWPORT,
1168
1478
  video: VIDEO_NEEDS,
1169
1479
  appNeeds: describeNeeds(adapter.brandNeeds),
1170
1480
  example: JSON.parse(
1171
- fs3.readFileSync(
1172
- path4.join(sharedBrandsDir(), "navipartner.json"),
1481
+ fs4.readFileSync(
1482
+ path5.join(sharedBrandsDir(), "navipartner.json"),
1173
1483
  "utf8"
1174
1484
  )
1175
1485
  )
@@ -1180,10 +1490,15 @@ var registerDemoTests = (test, adapter, demoDir) => {
1180
1490
  test("demo", async ({ page }) => {
1181
1491
  const env = readRunEnv();
1182
1492
  if (env.mode === "check") {
1183
- const reportPath = path4.join(env.workDir, "check.json");
1493
+ const reportPath = path5.join(env.workDir, "check.json");
1184
1494
  try {
1185
- const steps = await perform(page, env);
1186
- writeJson(reportPath, { ok: true, steps });
1495
+ const story = await perform(page, env);
1496
+ writeJson(reportPath, {
1497
+ ok: true,
1498
+ steps: story.steps.length,
1499
+ kind: story.kind ?? "showcase",
1500
+ seconds: estimateSeconds(story)
1501
+ });
1187
1502
  } catch (error) {
1188
1503
  writeJson(reportPath, failureReport(error));
1189
1504
  throw error;
@@ -1198,7 +1513,7 @@ var registerDemoTests = (test, adapter, demoDir) => {
1198
1513
  if (issues.length > 0) {
1199
1514
  throw new StoryboardError(
1200
1515
  [
1201
- `${path4.basename(env.storyboardPath)} won't film well:`,
1516
+ `${path5.basename(env.storyboardPath)} won't film well:`,
1202
1517
  ...issues.map((i) => ` ${i}`)
1203
1518
  ].join("\n")
1204
1519
  );
@@ -1235,6 +1550,31 @@ var registerDemoTests = (test, adapter, demoDir) => {
1235
1550
  })
1236
1551
  );
1237
1552
  const fast = env.mode === "check";
1553
+ const stillsDir = env.stillsDir ?? path5.join(env.workDir, "stills");
1554
+ const stillName = (index) => index === TITLE ? "card-title.png" : `card-${index}.png`;
1555
+ const stillFile = (index) => path5.join(stillsDir, stillName(index));
1556
+ const stillUrl = (index) => `${STILLS_PATH}/${stillName(index)}`;
1557
+ const stills = stillCards(storyboard.steps, storyboard.title);
1558
+ if (!fast && stills.length > 0) {
1559
+ const missing2 = stills.filter((i) => !fs4.existsSync(stillFile(i)));
1560
+ if (missing2.length > 0) {
1561
+ throw new Error(
1562
+ `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`
1563
+ );
1564
+ }
1565
+ await page.route(
1566
+ `**${STILLS_PATH}/*`,
1567
+ (route) => route.fulfill({
1568
+ contentType: "image/png",
1569
+ body: fs4.readFileSync(
1570
+ path5.join(
1571
+ stillsDir,
1572
+ path5.basename(new URL(route.request().url()).pathname)
1573
+ )
1574
+ )
1575
+ })
1576
+ );
1577
+ }
1238
1578
  if (!fast) {
1239
1579
  await FrameRecorder.prepare(page);
1240
1580
  }
@@ -1262,39 +1602,44 @@ var registerDemoTests = (test, adapter, demoDir) => {
1262
1602
  cause: error
1263
1603
  });
1264
1604
  }
1605
+ const title = storyboard.title;
1265
1606
  if (fast) {
1266
- await runSteps(boot, storyboard.steps, adapter);
1607
+ await runSteps(boot, storyboard.steps, adapter, {
1608
+ title,
1609
+ kind: storyboard.kind,
1610
+ ...stills.length > 0 ? { stillFile } : {}
1611
+ });
1267
1612
  boot.end();
1268
- return storyboard.steps.length;
1613
+ return storyboard;
1269
1614
  }
1270
1615
  await page.evaluate(() => document.fonts.ready.then(() => void 0));
1271
1616
  await frame.evaluate(() => document.fonts.ready.then(() => void 0));
1617
+ await page.evaluate(
1618
+ (urls) => window.__demo.preload(urls),
1619
+ stills.map(stillUrl)
1620
+ );
1272
1621
  await page.waitForTimeout(300);
1273
1622
  const recorder = new FrameRecorder(
1274
1623
  page,
1275
1624
  env.workDir,
1276
- `${path4.basename(env.storyboardPath, ".json")}-${brand.name}${env.draft ? "-draft" : ""}`,
1625
+ `${path5.basename(env.storyboardPath, ".json")}-${brand.name}${env.draft ? "-draft" : ""}`,
1277
1626
  env.draft ? DRAFT_FPS : FPS
1278
1627
  );
1279
1628
  await recorder.freeze();
1280
1629
  const d = new Director(page, frame, { fast: false, viewport, recorder });
1281
- const title = storyboard.title;
1282
1630
  if (title) {
1283
- await d.showCard(title, true);
1631
+ await d.showCard(
1632
+ cardSpecFor(title, void 0, title.show ? stillUrl(TITLE) : void 0),
1633
+ true
1634
+ );
1284
1635
  }
1285
1636
  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
1637
  const closing = storyboard.closing;
1294
1638
  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)
1639
+ title,
1640
+ kind: storyboard.kind,
1641
+ keepLastCardUp: Boolean(closing),
1642
+ stillUrl
1298
1643
  });
1299
1644
  d.end();
1300
1645
  if (closing) {
@@ -1303,11 +1648,13 @@ var registerDemoTests = (test, adapter, demoDir) => {
1303
1648
  await recorder.advance(LINGER_MS);
1304
1649
  }
1305
1650
  await d.showCard(closing);
1306
- await recorder.advance(CARD_IN_MS + cardSeconds(closing) * 1e3);
1651
+ await recorder.advance(
1652
+ CARD_IN_MS + titleSeconds(closing, storyboard.kind) * 1e3
1653
+ );
1307
1654
  }
1308
1655
  await recorder.advance(300);
1309
1656
  await recorder.stop();
1310
- return storyboard.steps.length;
1657
+ return storyboard;
1311
1658
  };
1312
1659
  };
1313
1660
  export {