@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.
@@ -4,29 +4,44 @@ import path from "path";
4
4
  import { z } from "zod";
5
5
 
6
6
  // src/timing.ts
7
- var ZOOM_MS = 1200;
7
+ var ZOOM_MS = 900;
8
8
  var CAMERA_EASE = "cubic-bezier(0.65, 0, 0.35, 1)";
9
- var CARD_IN_MS = 800;
10
- var CARD_OUT_MS = 900;
11
- var CURSOR_MIN_MS = 550;
12
- var CURSOR_MAX_MS = 1100;
13
- var CURSOR_MS_PER_PX = 0.9;
14
- var SETTLE_MS = 300;
15
- var PRE_CLICK_MS = 220;
9
+ var CARD_IN_MS = 500;
10
+ var CARD_OUT_MS = 600;
11
+ var CURSOR_MIN_MS = 400;
12
+ var CURSOR_MAX_MS = 800;
13
+ var CURSOR_MS_PER_PX = 0.7;
14
+ var SETTLE_MS = 150;
15
+ var ESTABLISH_MS = 800;
16
+ var SETTLED_MS = 250;
17
+ var REACTION_MS = 1e3;
18
+ var COVERED_SETTLED_MS = 150;
19
+ var COVERED_MAX_MS = 600;
20
+ var PRE_CLICK_MS = 160;
16
21
  var PRESS_MS = 110;
17
- var POST_CLICK_MS = 650;
18
- var POST_KEY_MS = 300;
19
- var POST_ACTION_MS = 650;
20
- var LINGER_MS = 1400;
22
+ var POST_CLICK_MS = 450;
23
+ var POST_KEY_MS = 250;
24
+ var POST_ACTION_MS = 500;
25
+ var TYPE_CHAR_MS = 45;
26
+ var LINGER_MS = 900;
27
+ var TITLE_MAX_SECONDS = {
28
+ showcase: 3.5,
29
+ feature: 2.5
30
+ };
21
31
  var POLL_MS = 100;
22
32
  var wordCount = (text) => text ? text.replace(/\*/g, "").split(/\s+/).filter(Boolean).length : 0;
23
- var cardSeconds = (spec) => {
24
- const read = 1 + 0.32 * (wordCount(spec.eyebrow) + wordCount(spec.heading) + wordCount(spec.sub));
25
- return Math.max(spec.seconds, Math.round(read * 10) / 10);
33
+ var cardSeconds = (spec, maxSeconds = Number.POSITIVE_INFINITY) => {
34
+ const read = 0.5 + 0.25 * wordCount(spec.heading) + 0.2 * wordCount(spec.sub) + 0.12 * wordCount(spec.eyebrow);
35
+ return Math.max(
36
+ spec.seconds,
37
+ Math.min(maxSeconds, Math.round(read * 10) / 10)
38
+ );
26
39
  };
40
+ var titleSeconds = (spec, kind = "showcase") => cardSeconds(spec, TITLE_MAX_SECONDS[kind]);
27
41
 
28
42
  // src/stage.ts
29
43
  var STAGE_PATH = "/__demo/stage";
44
+ var STILLS_PATH = "/__demo/stills";
30
45
  var STAGE_WIDTH = 1920;
31
46
  var STAGE_HEIGHT = 1080;
32
47
  var FRAME_WIDTH = 1728;
@@ -65,9 +80,15 @@ var stageScript = (layout, look) => String.raw`
65
80
  // Nothing here waits: time is virtual (frames.ts), so each call starts a
66
81
  // move and returns at once, and the director films its duration.
67
82
 
68
- // A shot from camera.ts: the app's translation and total scale.
69
- const setShot = (shot) => {
83
+ // A shot from camera.ts: the app's translation and total scale. Instant
84
+ // behind a card, so the chapter opens on it.
85
+ const setShot = (shot, instant) => {
86
+ if (instant) { camera.classList.add("instant"); }
70
87
  camera.style.transform = "translate(" + shot.tx + "px, " + shot.ty + "px) scale(" + shot.k + ")";
88
+ if (instant) {
89
+ void camera.offsetWidth;
90
+ camera.classList.remove("instant");
91
+ }
71
92
  };
72
93
 
73
94
  // Behind an opaque card: back to wide with no move, so every chapter opens
@@ -122,14 +143,30 @@ var stageScript = (layout, look) => String.raw`
122
143
  // (payoff into end card) without the app showing in between.
123
144
  let current = null;
124
145
 
146
+ // Card stills, loaded and decoded before filming starts, so a card never
147
+ // shows one half-drawn.
148
+ const stills = new Map();
149
+ const preload = (urls) =>
150
+ Promise.all(urls.map((url) => {
151
+ const img = new Image();
152
+ img.alt = "";
153
+ img.src = url;
154
+ stills.set(url, img);
155
+ return img.decode();
156
+ })).then(() => undefined);
157
+
125
158
  const buildCard = (spec) => {
126
159
  const el = document.createElement("div");
127
- el.className = "card " + (spec.style === "brand" ? "brand" : "pale");
160
+ el.className = "card " + (spec.style === "brand" ? "brand" : "pale") + (spec.still ? " split" : "");
161
+ // The words, in one column; beside them, a still of the result.
162
+ const text = document.createElement("div");
163
+ text.className = "text";
164
+ el.appendChild(text);
128
165
  const add = (tag, cls, html) => {
129
166
  const child = document.createElement(tag);
130
- child.className = cls;
167
+ child.className = "part " + cls;
131
168
  child.innerHTML = html;
132
- el.appendChild(child);
169
+ text.appendChild(child);
133
170
  return child;
134
171
  };
135
172
  if (spec.eyebrow) { add("p", "eyebrow", escapeHtml(spec.eyebrow)); }
@@ -142,6 +179,15 @@ var stageScript = (layout, look) => String.raw`
142
179
  }
143
180
  if (spec.heading) { add("h1", "heading", emphasise(spec.heading)); }
144
181
  if (spec.sub) { add("p", "sub", escapeHtml(spec.sub)); }
182
+ if (spec.still) {
183
+ const img = stills.get(spec.still) || new Image();
184
+ img.src = spec.still;
185
+ img.className = "part still";
186
+ // A tall still (a pass, a kiosk screen) and a wide one (a dialog, a
187
+ // receipt row) are bounded differently.
188
+ img.classList.add(img.naturalHeight > img.naturalWidth ? "tall" : "broad");
189
+ el.appendChild(img);
190
+ }
145
191
  return el;
146
192
  };
147
193
 
@@ -181,7 +227,7 @@ var stageScript = (layout, look) => String.raw`
181
227
  setTimeout(() => el.remove(), CARD_OUT_MS);
182
228
  };
183
229
 
184
- window.__demo = { setShot, resetCamera, cursorTo, parkNow, showCard, hideCard };
230
+ window.__demo = { setShot, resetCamera, cursorTo, parkNow, showCard, hideCard, preload };
185
231
  cursor.style.transform = "translate(" + cursorX + "px, " + cursorY + "px)";
186
232
  })();
187
233
  `;
@@ -238,34 +284,41 @@ var stageHtml = (background, appUrl, look, options = {}) => {
238
284
  through them; the dissolve is the card fading while the app blurs. */
239
285
  .card {
240
286
  position: absolute; inset: 0; z-index: 40; isolation: isolate;
241
- display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 22px;
242
- padding: 0 240px; text-align: center;
287
+ display: flex; align-items: center; justify-content: center; gap: 104px;
288
+ padding: 0 200px; text-align: center;
243
289
  opacity: 0; transition: opacity ${CARD_OUT_MS}ms ease;
244
290
  pointer-events: none;
245
291
  }
292
+ /* Words stay inside the central 1080\xD71080, so a square crop keeps them. */
293
+ .card .text { display: flex; flex-direction: column; align-items: center; gap: 28px; max-width: 1080px; }
294
+ /* Text and UI on one card: the words on the left, the result beside them. */
295
+ .card.split { padding: 0 150px; text-align: left; }
296
+ .card.split .text { align-items: flex-start; flex: 0 1 820px; }
246
297
  .card.show { opacity: 1; transition: opacity ${CARD_IN_MS}ms ease; }
247
298
  .card.pale { background: ${background}; }
248
299
  .card.brand {
249
300
  background: radial-gradient(1200px 800px at 20% 10%, rgba(255,255,255,0.10), transparent 60%), ${look.primary};
250
301
  }
251
302
  .card.instant, .card.instant * { transition: none !important; }
252
- .card > * {
303
+ .card .part {
253
304
  margin: 0; opacity: 0; filter: blur(12px);
254
- transition: opacity 400ms ease, filter 400ms ease;
305
+ transition: opacity 300ms ease, filter 300ms ease;
255
306
  }
256
- .card.show > * { opacity: 1; filter: blur(0); transition: opacity 600ms ease, filter 600ms ease; }
257
- .card.show > .eyebrow, .card.show > .logo { transition-delay: 150ms; }
258
- .card.show > .heading { transition-delay: 260ms; }
259
- .card.show > .sub { transition-delay: 700ms; }
260
- /* Beats every .card.show rule above (same specificity, declared later). */
261
- .card.show.leaving > *, .card.show.leaving > .heading, .card.show.leaving > .sub,
262
- .card.show.leaving > .eyebrow, .card.show.leaving > .logo {
307
+ .card.show .part { opacity: 1; filter: blur(0); transition: opacity 500ms ease, filter 500ms ease; }
308
+ .card.show .eyebrow, .card.show .logo { transition-delay: 80ms; }
309
+ .card.show .heading { transition-delay: 160ms; }
310
+ .card.show .still { transition-delay: 260ms; }
311
+ .card.show .sub { transition-delay: 420ms; }
312
+ /* Beats every .card.show rule above (more specific). */
313
+ .card.show.leaving .part, .card.show.leaving .heading, .card.show.leaving .sub,
314
+ .card.show.leaving .eyebrow, .card.show.leaving .logo, .card.show.leaving .still {
263
315
  opacity: 0; filter: blur(12px);
264
- transition: opacity 280ms ease 0ms, filter 280ms ease 0ms;
316
+ transition: opacity 240ms ease 0ms, filter 240ms ease 0ms;
265
317
  }
266
- .card.show.after > .eyebrow, .card.show.after > .logo { transition-delay: 420ms; }
267
- .card.show.after > .heading { transition-delay: 520ms; }
268
- .card.show.after > .sub { transition-delay: 900ms; }
318
+ .card.show.after .eyebrow, .card.show.after .logo { transition-delay: 300ms; }
319
+ .card.show.after .heading { transition-delay: 380ms; }
320
+ .card.show.after .still { transition-delay: 480ms; }
321
+ .card.show.after .sub { transition-delay: 620ms; }
269
322
  /* Faint grain over the background and cards: it dithers their soft
270
323
  gradients, which in 8-bit video would band and block. */
271
324
  body::before, .card::before {
@@ -275,18 +328,29 @@ var stageHtml = (background, appUrl, look, options = {}) => {
275
328
  opacity: 0.045;
276
329
  }
277
330
  .card::before { z-index: -1; }
278
- .card .eyebrow { font-size: 24px; font-weight: 600; letter-spacing: 0.02em; color: #707070; }
331
+ .card .eyebrow {
332
+ font-size: 28px; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase;
333
+ color: ${look.primary};
334
+ }
279
335
  .card .logo.wide { width: 720px; height: auto; }
280
336
  .card .logo.square { height: 240px; width: auto; }
281
337
  .card .heading {
282
338
  font-family: ${font(look.headingFont)}, Georgia, serif;
283
- font-size: 76px; font-weight: 400; line-height: 1.15; letter-spacing: -0.01em;
339
+ font-size: 116px; font-weight: 400; line-height: 1.08; letter-spacing: -0.015em;
284
340
  color: ${look.ink};
285
341
  }
286
342
  .card .heading em { font-style: italic; color: ${look.primary}; }
287
- .card .sub { font-size: 28px; font-weight: 400; color: ${look.text}; opacity: 0.8; }
343
+ .card .sub { font-size: 40px; font-weight: 400; line-height: 1.3; color: ${look.text}; opacity: 0.85; }
344
+ .card.split .heading { font-size: 92px; }
345
+ .card.split .sub { font-size: 36px; }
346
+ .card .still {
347
+ flex: 0 0 auto; border-radius: 18px; background: #fff;
348
+ box-shadow: 0 28px 90px rgba(15, 23, 42, 0.18), 0 2px 8px rgba(15, 23, 42, 0.08);
349
+ }
350
+ .card .still.tall { max-height: 760px; max-width: 640px; }
351
+ .card .still.broad { max-width: 760px; max-height: 620px; }
288
352
  .card.brand .heading, .card.brand .heading em { color: #fff; }
289
- .card.brand .sub, .card.brand .eyebrow { color: rgba(255,255,255,0.82); }
353
+ .card.brand .sub, .card.brand .eyebrow { color: rgba(255,255,255,0.86); }
290
354
  .card.brand .logo { filter: brightness(0) invert(1); }
291
355
  </style>
292
356
  </head>
@@ -385,6 +449,7 @@ var target = {
385
449
  };
386
450
  var scale = z.number().min(1).max(3);
387
451
  var seconds = z.number().min(0.5).max(60);
452
+ var behindCard = { behindCard: z.boolean().optional() };
388
453
  var cardFields = {
389
454
  eyebrow: z.string().min(1).max(40).optional(),
390
455
  heading: z.string().min(1).max(80).optional(),
@@ -393,7 +458,13 @@ var cardFields = {
393
458
  logo: z.boolean().optional(),
394
459
  /** "pale" = brand-tinted white; "brand" = full-bleed primary colour. */
395
460
  style: z.enum(["pale", "brand"]).default("pale"),
396
- seconds: z.number().min(0.5).max(10).default(2.5)
461
+ seconds: z.number().min(0.5).max(10).default(1.6)
462
+ };
463
+ var show = z.union([z.string().min(1), z.object({ ...target }).strict()]).optional();
464
+ var stepCardFields = {
465
+ ...cardFields,
466
+ style: z.enum(["pale", "brand"]).optional(),
467
+ show
397
468
  };
398
469
  var hasContent = (c) => Boolean(c.heading || c.logo);
399
470
  var CARD_EMPTY = "a card needs a heading or the logo";
@@ -401,15 +472,19 @@ var coreStepSchemas = [
401
472
  z.object({
402
473
  do: z.literal("click"),
403
474
  ...target,
404
- zoom: scale.optional()
475
+ zoom: scale.optional(),
476
+ ...behindCard
405
477
  }).strict(),
406
478
  z.object({
407
479
  do: z.literal("type"),
408
480
  ...target,
409
481
  text: z.string(),
410
- perCharMs: z.number().int().min(0).max(500).optional()
482
+ perCharMs: z.number().int().min(0).max(500).optional(),
483
+ // Select what the field holds first, so the text replaces it.
484
+ replace: z.boolean().optional(),
485
+ ...behindCard
411
486
  }).strict(),
412
- z.object({ do: z.literal("press"), key: z.string().min(1) }).strict(),
487
+ z.object({ do: z.literal("press"), key: z.string().min(1), ...behindCard }).strict(),
413
488
  z.object({
414
489
  do: z.literal("zoom"),
415
490
  ...target,
@@ -427,7 +502,8 @@ var coreStepSchemas = [
427
502
  do: z.literal("waitFor"),
428
503
  ...target,
429
504
  hidden: z.boolean().optional(),
430
- timeout: seconds.default(15)
505
+ timeout: seconds.default(15),
506
+ ...behindCard
431
507
  }).strict(),
432
508
  z.object({
433
509
  do: z.literal("expectText"),
@@ -436,7 +512,7 @@ var coreStepSchemas = [
436
512
  contains: z.boolean().optional(),
437
513
  timeout: seconds.default(10)
438
514
  }).strict(),
439
- z.object({ do: z.literal("card"), ...cardFields }).strict(),
515
+ z.object({ do: z.literal("card"), ...stepCardFields }).strict(),
440
516
  z.object({
441
517
  do: z.literal("hold"),
442
518
  seconds: z.number().min(0).max(10)
@@ -445,8 +521,9 @@ var coreStepSchemas = [
445
521
  var CORE_STEP_NAMES = coreStepSchemas.map(
446
522
  (s) => s.shape.do.value
447
523
  );
448
- var RESERVED_FIELDS = ["do"];
524
+ var RESERVED_FIELDS = ["do", "behindCard"];
449
525
  var cardSchema = z.object(cardFields).strict().refine(hasContent, CARD_EMPTY);
526
+ var titleSchema = z.object({ ...cardFields, show }).strict().refine(hasContent, CARD_EMPTY);
450
527
  var isCoreStep = (step) => CORE_STEP_NAMES.includes(step.do);
451
528
  var buildStoryboardSchema = (adapter) => {
452
529
  const actionSchemas = Object.entries(adapter.actions ?? {}).map(
@@ -461,7 +538,7 @@ var buildStoryboardSchema = (adapter) => {
461
538
  );
462
539
  }
463
540
  }
464
- return z.object({ do: z.literal(name) }).merge(action.params).strict();
541
+ return z.object({ do: z.literal(name), ...behindCard }).merge(action.params).strict();
465
542
  }
466
543
  );
467
544
  const stepSchema = z.discriminatedUnion("do", [
@@ -471,8 +548,9 @@ var buildStoryboardSchema = (adapter) => {
471
548
  return z.object({
472
549
  app: z.literal(adapter.app),
473
550
  description: z.string().min(1).max(280).optional(),
551
+ kind: z.enum(["showcase", "feature"]).optional(),
474
552
  primary: z.boolean().optional(),
475
- title: cardSchema.optional(),
553
+ title: titleSchema.optional(),
476
554
  closing: cardSchema.optional(),
477
555
  steps: z.array(stepSchema).min(1)
478
556
  }).strict().superRefine((story, ctx) => {
@@ -515,6 +593,7 @@ var loadStoryboard = (file, adapter) => {
515
593
  var MIN_ZOOM = MIN_SCALE;
516
594
  var MAX_ZOOM = MAX_SCALE;
517
595
  var MAX_PUSHES_PER_CHAPTER = 2;
596
+ var MAX_SHOWCASE_CHAPTERS = 4;
518
597
  var MAX_HOLD_SECONDS = 6;
519
598
  var MIN_PER_CHAR_MS = 40;
520
599
  var MAX_WORDS = { eyebrow: 4, heading: 8, sub: 16 };
@@ -534,7 +613,8 @@ var CODE_ON_CARD = [
534
613
  ]
535
614
  ];
536
615
  var INTERACTIONS = /* @__PURE__ */ new Set(["click", "type", "press"]);
537
- var isInteraction = (step) => INTERACTIONS.has(step.do) || !isCoreStep(step);
616
+ var isBehindCard = (step) => step.behindCard === true;
617
+ var isInteraction = (step) => (INTERACTIONS.has(step.do) || !isCoreStep(step)) && !isBehindCard(step);
538
618
  var lintStoryboard = (story) => {
539
619
  const issues = [];
540
620
  const steps = story.steps;
@@ -616,10 +696,32 @@ var lintStoryboard = (story) => {
616
696
  steps.forEach((step, index) => {
617
697
  const where = `steps.${index}`;
618
698
  const s = step;
699
+ if (isBehindCard(step)) {
700
+ let k = index - 1;
701
+ while (k >= 0 && isBehindCard(steps[k])) {
702
+ k--;
703
+ }
704
+ const afterCard = k === -1 ? Boolean(story.title) : steps[k].do === "card";
705
+ if (!afterCard) {
706
+ issues.push(
707
+ `${where}: behindCard only right after a card, which covers the app while it's done`
708
+ );
709
+ }
710
+ if (step.do === "click" && s.zoom !== void 0) {
711
+ issues.push(
712
+ `${where}: a click behind a card has no shot to push in to; drop its zoom`
713
+ );
714
+ }
715
+ }
619
716
  if (step.do === "card") {
620
717
  endChapter(index);
621
718
  chapterStart = index + 1;
622
719
  lintCard(where, s);
720
+ if (s.eyebrow !== void 0) {
721
+ issues.push(
722
+ `${where}.eyebrow: cards between chapters have no eyebrow; the statement and its sub-line say it`
723
+ );
724
+ }
623
725
  const rest = steps.slice(index + 1);
624
726
  const nextCard = rest.findIndex((x) => x.do === "card");
625
727
  const chapter = nextCard === -1 ? rest : rest.slice(0, nextCard);
@@ -667,6 +769,54 @@ var lintStoryboard = (story) => {
667
769
  }
668
770
  });
669
771
  endChapter(steps.length);
772
+ const snapshots = /* @__PURE__ */ new Map();
773
+ const showOf = (where, value) => {
774
+ if (value === void 0) {
775
+ return;
776
+ }
777
+ const t = value;
778
+ const key = typeof t === "string" ? `${t}#0` : `${t.target}#${t.nth ?? 0}`;
779
+ const first = snapshots.get(key);
780
+ if (first) {
781
+ issues.push(
782
+ `${where}.show: ${first} shows the same snapshot; show each result once`
783
+ );
784
+ } else {
785
+ snapshots.set(key, where);
786
+ }
787
+ };
788
+ showOf("title", story.title?.show);
789
+ steps.forEach((step, index) => {
790
+ if (step.do === "card") {
791
+ showOf(`steps.${index}`, step.show);
792
+ }
793
+ });
794
+ const kind = story.kind ?? "showcase";
795
+ if (story.primary && kind !== "showcase") {
796
+ issues.push(
797
+ "kind: the primary story walks the app's main journey, so it is a showcase"
798
+ );
799
+ }
800
+ const chapters = steps.filter((step, i) => {
801
+ if (step.do !== "card") {
802
+ return false;
803
+ }
804
+ const rest = steps.slice(i + 1);
805
+ const next = rest.findIndex((x) => x.do === "card");
806
+ return (next === -1 ? rest : rest.slice(0, next)).some(
807
+ (x) => !isBehindCard(x)
808
+ );
809
+ }).length;
810
+ if (kind === "feature" && chapters !== 1) {
811
+ issues.push(
812
+ `kind: a feature clip shows one feature, in one chapter; this has ${chapters}. Make it one, or a showcase`
813
+ );
814
+ }
815
+ if (kind === "showcase" && chapters > MAX_SHOWCASE_CHAPTERS) {
816
+ issues.push(
817
+ `${chapters} chapters; a showcase has at most ${MAX_SHOWCASE_CHAPTERS}: make a second story`
818
+ );
819
+ }
670
820
  return issues;
671
821
  };
672
822
  var normaliseText = (text) => text.replace(/\s+/g, " ").trim();
@@ -675,16 +825,26 @@ export {
675
825
  ZOOM_MS,
676
826
  CARD_IN_MS,
677
827
  CARD_OUT_MS,
828
+ CURSOR_MIN_MS,
829
+ CURSOR_MAX_MS,
678
830
  SETTLE_MS,
831
+ ESTABLISH_MS,
832
+ SETTLED_MS,
833
+ REACTION_MS,
834
+ COVERED_SETTLED_MS,
835
+ COVERED_MAX_MS,
679
836
  PRE_CLICK_MS,
680
837
  PRESS_MS,
681
838
  POST_CLICK_MS,
682
839
  POST_KEY_MS,
683
840
  POST_ACTION_MS,
841
+ TYPE_CHAR_MS,
684
842
  LINGER_MS,
685
843
  POLL_MS,
686
844
  cardSeconds,
845
+ titleSeconds,
687
846
  STAGE_PATH,
847
+ STILLS_PATH,
688
848
  STAGE_WIDTH,
689
849
  STAGE_HEIGHT,
690
850
  DEFAULT_VIEWPORT,
@@ -703,5 +863,6 @@ export {
703
863
  StoryboardError,
704
864
  parseStoryboard,
705
865
  loadStoryboard,
866
+ isBehindCard,
706
867
  lintStoryboard
707
868
  };