@navipartner/demo-video 0.7.4 → 0.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/args.d.mts +2 -0
- package/bin/args.mjs +16 -0
- package/bin/demo-video.mjs +12 -5
- package/bin/init.d.mts +2 -0
- package/bin/init.mjs +35 -1
- package/dist/{chunk-XMOGUZJU.js → chunk-DBH2CI6K.js} +14 -1
- package/dist/index.js +1 -1
- package/dist/runner/index.js +150 -16
- package/package.json +1 -1
- package/skills/np-demo-video/SKILL.md +21 -16
- package/skills/np-demo-video/look.md +18 -10
- package/skills/np-demo-video/reference.md +20 -7
package/bin/args.d.mts
CHANGED
|
@@ -22,6 +22,8 @@ export interface DemoArgs {
|
|
|
22
22
|
draft: boolean;
|
|
23
23
|
}
|
|
24
24
|
export const COMMANDS: readonly string[];
|
|
25
|
+
/** Creates demo/out/ with a .gitignore that ignores all of it. */
|
|
26
|
+
export function makeOutDir(outDir: string): void;
|
|
25
27
|
/** The package.json that makes demo.config.ts CommonJS, or null. */
|
|
26
28
|
export function commonJsDemo(demoDir: string): string | null;
|
|
27
29
|
export function parseArgs(argv: string[], cwd: string): DemoArgs;
|
package/bin/args.mjs
CHANGED
|
@@ -14,6 +14,22 @@ const NEEDS_STORYBOARD = new Set(["check", "render", "record"]);
|
|
|
14
14
|
// Commands whose Playwright run loads demo.config.ts.
|
|
15
15
|
const LOADS_ADAPTER = new Set(["check", "render", "record", "brand-needs"]);
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Creates demo/out/ with a .gitignore of its own, so the renders, the
|
|
19
|
+
* contact sheet and the stills are never committed, whether or not init
|
|
20
|
+
* added the repo's ignore line.
|
|
21
|
+
*/
|
|
22
|
+
export const makeOutDir = (outDir) => {
|
|
23
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
24
|
+
const ignore = path.join(outDir, ".gitignore");
|
|
25
|
+
if (!fs.existsSync(ignore)) {
|
|
26
|
+
fs.writeFileSync(
|
|
27
|
+
ignore,
|
|
28
|
+
"# Written by @navipartner/demo-video: nothing here is committed.\n*\n",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
17
33
|
/**
|
|
18
34
|
* The package.json that makes demo.config.ts CommonJS, or null. Playwright
|
|
19
35
|
* compiles a .ts file as CommonJS unless the nearest package.json says
|
package/bin/demo-video.mjs
CHANGED
|
@@ -6,9 +6,9 @@ import { createRequire } from "node:module";
|
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
|
|
9
|
-
import { listStories, parseArgs } from "./args.mjs";
|
|
9
|
+
import { listStories, makeOutDir, parseArgs } from "./args.mjs";
|
|
10
10
|
import { encodeRecording, extractStillsFrom } from "./encode.mjs";
|
|
11
|
-
import { findApps, initApp, initProject } from "./init.mjs";
|
|
11
|
+
import { findApps, initApp, initProject, skillDrift } from "./init.mjs";
|
|
12
12
|
import { appMenu, menuMarkdown, pick } from "./pick.mjs";
|
|
13
13
|
import { buildPitchBrand } from "./pitch.mjs";
|
|
14
14
|
import { writeRunner } from "./runner.mjs";
|
|
@@ -38,6 +38,7 @@ const runPlaywright = (args, mode) => {
|
|
|
38
38
|
const require = createRequire(path.join(args.appDir, "package.json"));
|
|
39
39
|
const cli = require.resolve("@playwright/test/cli");
|
|
40
40
|
fs.rmSync(args.workDir, { recursive: true, force: true });
|
|
41
|
+
makeOutDir(args.outDir);
|
|
41
42
|
fs.mkdirSync(args.workDir, { recursive: true });
|
|
42
43
|
writeRunner(args);
|
|
43
44
|
const result = spawnSync(
|
|
@@ -313,7 +314,7 @@ const runPitch = async (argv) => {
|
|
|
313
314
|
finish(render, false);
|
|
314
315
|
throw new Error("Filming the pitch draft failed (see above)");
|
|
315
316
|
}
|
|
316
|
-
|
|
317
|
+
makeOutDir(render.outDir);
|
|
317
318
|
const result = encodeRecording(render.workDir, render.outDir);
|
|
318
319
|
finish(render, true);
|
|
319
320
|
// The video leaves the repo; its stills and sheet there go.
|
|
@@ -363,6 +364,12 @@ const run = () => {
|
|
|
363
364
|
console.error(error.message);
|
|
364
365
|
return 2;
|
|
365
366
|
}
|
|
367
|
+
if (["check", "render", "record"].includes(args.command)) {
|
|
368
|
+
const drift = skillDrift(args.appDir);
|
|
369
|
+
if (drift) {
|
|
370
|
+
console.error(drift);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
366
373
|
switch (args.command) {
|
|
367
374
|
case "check": {
|
|
368
375
|
const status = runPlaywright(args, "check");
|
|
@@ -391,7 +398,7 @@ const run = () => {
|
|
|
391
398
|
finish(args, false);
|
|
392
399
|
return status;
|
|
393
400
|
}
|
|
394
|
-
|
|
401
|
+
makeOutDir(args.outDir);
|
|
395
402
|
const result = encodeRecording(args.workDir, args.outDir);
|
|
396
403
|
finish(args, true);
|
|
397
404
|
if (args.json) {
|
|
@@ -404,7 +411,7 @@ const run = () => {
|
|
|
404
411
|
return 0;
|
|
405
412
|
}
|
|
406
413
|
case "encode": {
|
|
407
|
-
|
|
414
|
+
makeOutDir(args.outDir);
|
|
408
415
|
const { output, duration } = encodeRecording(args.workDir, args.outDir);
|
|
409
416
|
finish(args, true);
|
|
410
417
|
console.log(`Wrote ${output} (${duration.toFixed(1)} s)`);
|
package/bin/init.d.mts
CHANGED
|
@@ -12,6 +12,8 @@ export function initRepo(
|
|
|
12
12
|
root: string,
|
|
13
13
|
options?: { dryRun?: boolean; label?: boolean },
|
|
14
14
|
): InitResult;
|
|
15
|
+
/** A line to print when the repo's copy of the skill isn't this engine's. */
|
|
16
|
+
export function skillDrift(appDir: string): string | null;
|
|
15
17
|
/** The repo's apps (folders with a playwright.config), "." for the root. */
|
|
16
18
|
export function findApps(root: string): string[];
|
|
17
19
|
export function initProject(
|
package/bin/init.mjs
CHANGED
|
@@ -87,6 +87,11 @@ const withKey = (object, key, value) => {
|
|
|
87
87
|
return Object.fromEntries(entries);
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
+
// Line endings aside: git may check the repo's copy out with CRLF.
|
|
91
|
+
const sameText = (x, y) =>
|
|
92
|
+
fs.readFileSync(x, "utf8").replace(/\r\n/g, "\n") ===
|
|
93
|
+
fs.readFileSync(y, "utf8").replace(/\r\n/g, "\n");
|
|
94
|
+
|
|
90
95
|
const sameTree = (a, b) => {
|
|
91
96
|
if (!fs.existsSync(b)) {
|
|
92
97
|
return false;
|
|
@@ -95,8 +100,37 @@ const sameTree = (a, b) => {
|
|
|
95
100
|
const [x, y] = [path.join(a, entry.name), path.join(b, entry.name)];
|
|
96
101
|
return entry.isDirectory()
|
|
97
102
|
? sameTree(x, y)
|
|
98
|
-
: fs.existsSync(y) &&
|
|
103
|
+
: fs.existsSync(y) && sameText(x, y);
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* What to tell the developer when the repo's copy of the skill (init puts
|
|
109
|
+
* it in .claude/skills/) isn't this engine's: Claude would follow an older
|
|
110
|
+
* workflow and look than the engine checks. Null when it is, or when the
|
|
111
|
+
* repo has no copy.
|
|
112
|
+
*/
|
|
113
|
+
export const skillDrift = (appDir) => {
|
|
114
|
+
const top = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
115
|
+
cwd: appDir,
|
|
116
|
+
encoding: "utf8",
|
|
99
117
|
});
|
|
118
|
+
if (top.error || top.status !== 0) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
const copy = path.join(
|
|
122
|
+
top.stdout.trim(),
|
|
123
|
+
".claude",
|
|
124
|
+
"skills",
|
|
125
|
+
"np-demo-video",
|
|
126
|
+
);
|
|
127
|
+
if (
|
|
128
|
+
!fs.existsSync(copy) ||
|
|
129
|
+
sameTree(path.join(engineDir, "skills", "np-demo-video"), copy)
|
|
130
|
+
) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return `.claude/skills/np-demo-video isn't the skill of ${PKG} ${engineVersion()}: run "demo-video init" at the repo root to update it.`;
|
|
100
134
|
};
|
|
101
135
|
|
|
102
136
|
const gitIgnores = (root, file) =>
|
|
@@ -338,9 +338,11 @@ var stageHtml = (background, appUrl, look, options = {}) => {
|
|
|
338
338
|
font-family: ${font(look.headingFont)}, Georgia, serif;
|
|
339
339
|
font-size: 116px; font-weight: 400; line-height: 1.08; letter-spacing: -0.015em;
|
|
340
340
|
color: ${look.ink};
|
|
341
|
+
/* Lines of even length: no word left alone on the last one. */
|
|
342
|
+
text-wrap: balance;
|
|
341
343
|
}
|
|
342
344
|
.card .heading em { font-style: italic; color: ${look.primary}; }
|
|
343
|
-
.card .sub { font-size: 40px; font-weight: 400; line-height: 1.3; color: ${look.text}; opacity: 0.85; }
|
|
345
|
+
.card .sub { font-size: 40px; font-weight: 400; line-height: 1.3; color: ${look.text}; opacity: 0.85; text-wrap: balance; }
|
|
344
346
|
.card.split .heading { font-size: 92px; }
|
|
345
347
|
.card.split .sub { font-size: 36px; }
|
|
346
348
|
.card .still {
|
|
@@ -685,6 +687,7 @@ var lintStoryboard = (story) => {
|
|
|
685
687
|
}
|
|
686
688
|
let chapterStart = 0;
|
|
687
689
|
let pushes = [];
|
|
690
|
+
let pushedIn = false;
|
|
688
691
|
const endChapter = (end) => {
|
|
689
692
|
if (pushes.length > MAX_PUSHES_PER_CHAPTER) {
|
|
690
693
|
issues.push(
|
|
@@ -716,6 +719,7 @@ var lintStoryboard = (story) => {
|
|
|
716
719
|
if (step.do === "card") {
|
|
717
720
|
endChapter(index);
|
|
718
721
|
chapterStart = index + 1;
|
|
722
|
+
pushedIn = false;
|
|
719
723
|
lintCard(where, s);
|
|
720
724
|
if (s.eyebrow !== void 0) {
|
|
721
725
|
issues.push(
|
|
@@ -742,6 +746,15 @@ var lintStoryboard = (story) => {
|
|
|
742
746
|
if (shot && pushes.at(-1) !== shot) {
|
|
743
747
|
pushes.push(shot);
|
|
744
748
|
}
|
|
749
|
+
if (shot && !isBehindCard(step)) {
|
|
750
|
+
pushedIn = true;
|
|
751
|
+
}
|
|
752
|
+
if (step.do === "zoomOut" && pushedIn) {
|
|
753
|
+
issues.push(
|
|
754
|
+
`${where}: a pull-out. A push-in stays in until the next card, which takes the camera back to wide: push in on what is read (a dialog, the basket, the confirmation) and stay in, and use what opens something new from the wide shot, before the push-in`
|
|
755
|
+
);
|
|
756
|
+
pushedIn = false;
|
|
757
|
+
}
|
|
745
758
|
if (step.do === "zoom" || step.do === "frame") {
|
|
746
759
|
const rest = steps.slice(index + 1);
|
|
747
760
|
const end = rest.findIndex(
|
package/dist/index.js
CHANGED
package/dist/runner/index.js
CHANGED
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
unionRect,
|
|
42
42
|
visibleRect,
|
|
43
43
|
wideShot
|
|
44
|
-
} from "../chunk-
|
|
44
|
+
} from "../chunk-DBH2CI6K.js";
|
|
45
45
|
|
|
46
46
|
// src/runner/config.ts
|
|
47
47
|
import path from "path";
|
|
@@ -266,6 +266,43 @@ import path3 from "path";
|
|
|
266
266
|
|
|
267
267
|
// src/changes.ts
|
|
268
268
|
var MAX_RESTYLES = 3;
|
|
269
|
+
var NEW_SCREEN_GONE = 0.5;
|
|
270
|
+
var markScreen = (app) => app.evaluate(() => {
|
|
271
|
+
const w = window;
|
|
272
|
+
const read = () => {
|
|
273
|
+
const texts = [];
|
|
274
|
+
for (const node of Array.from(document.body.querySelectorAll("*"))) {
|
|
275
|
+
const own = Array.from(node.childNodes).filter((c) => c.nodeType === Node.TEXT_NODE).map((c) => c.textContent ?? "").join(" ").replace(/\s+/g, " ").trim();
|
|
276
|
+
if (!own) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const r = node.getBoundingClientRect();
|
|
280
|
+
const x = r.x + r.width / 2;
|
|
281
|
+
const y = r.y + r.height / 2;
|
|
282
|
+
if (r.width === 0 || r.height === 0 || x < 0 || y < 0 || x > innerWidth || y > innerHeight || !node.checkVisibility({
|
|
283
|
+
opacityProperty: true,
|
|
284
|
+
visibilityProperty: true
|
|
285
|
+
})) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const top = document.elementFromPoint(x, y);
|
|
289
|
+
if (top && (node.contains(top) || top.contains(node))) {
|
|
290
|
+
texts.push({
|
|
291
|
+
text: own,
|
|
292
|
+
x: r.x,
|
|
293
|
+
y: r.y,
|
|
294
|
+
width: r.width,
|
|
295
|
+
height: r.height
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
if (texts.length >= 300) {
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return texts;
|
|
303
|
+
};
|
|
304
|
+
w.__demoScreen = { read, before: read() };
|
|
305
|
+
});
|
|
269
306
|
var watchChanges = (target) => target.evaluate((el) => {
|
|
270
307
|
const w = window;
|
|
271
308
|
w.__demoChanges?.observer.disconnect();
|
|
@@ -304,6 +341,28 @@ var watchChanges = (target) => target.evaluate((el) => {
|
|
|
304
341
|
watch.observer = observer;
|
|
305
342
|
w.__demoChanges = watch;
|
|
306
343
|
});
|
|
344
|
+
var newScreen = (app) => app.evaluate((share) => {
|
|
345
|
+
const screen = window.__demoScreen;
|
|
346
|
+
if (!screen) {
|
|
347
|
+
return { x: 0, y: 0, width: innerWidth, height: innerHeight };
|
|
348
|
+
}
|
|
349
|
+
if (screen.before.length === 0) {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
const now = screen.read();
|
|
353
|
+
const nowTexts = new Set(now.map((r) => r.text));
|
|
354
|
+
const beforeTexts = new Set(screen.before.map((r) => r.text));
|
|
355
|
+
const gone = screen.before.filter((r) => !nowTexts.has(r.text));
|
|
356
|
+
if (gone.length / screen.before.length < share) {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
const moved = [...gone, ...now.filter((r) => !beforeTexts.has(r.text))];
|
|
360
|
+
const x0 = Math.min(...moved.map((r) => r.x));
|
|
361
|
+
const y0 = Math.min(...moved.map((r) => r.y));
|
|
362
|
+
const x1 = Math.max(...moved.map((r) => r.x + r.width));
|
|
363
|
+
const y1 = Math.max(...moved.map((r) => r.y + r.height));
|
|
364
|
+
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
|
365
|
+
}, NEW_SCREEN_GONE);
|
|
307
366
|
var watching = (app) => app.evaluate(
|
|
308
367
|
() => Boolean(window.__demoChanges)
|
|
309
368
|
);
|
|
@@ -348,7 +407,12 @@ var biggestChange = (app) => app.evaluate((maxRestyles) => {
|
|
|
348
407
|
const hasText = Array.from(node.childNodes).some(
|
|
349
408
|
(c) => c.nodeType === Node.TEXT_NODE && c.textContent?.trim()
|
|
350
409
|
);
|
|
351
|
-
|
|
410
|
+
const r = node.getBoundingClientRect();
|
|
411
|
+
const shown = r.width > 0 && r.height > 0 && r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth && node.checkVisibility({
|
|
412
|
+
opacityProperty: true,
|
|
413
|
+
visibilityProperty: true
|
|
414
|
+
});
|
|
415
|
+
if (hasText && shown) {
|
|
352
416
|
fontPx = Math.min(fontPx, parseFloat(getComputedStyle(node).fontSize));
|
|
353
417
|
}
|
|
354
418
|
}
|
|
@@ -361,6 +425,7 @@ var SHOWN = 0.5;
|
|
|
361
425
|
var FRAME_MAX_SCALE = 1.8;
|
|
362
426
|
var PUSH_WORTHY = 1.4;
|
|
363
427
|
var READABLE_PX = 16;
|
|
428
|
+
var MOVES_ON_MS = POST_CLICK_MS + REACTION_MS + ZOOM_MS + SETTLE_MS;
|
|
364
429
|
var STILL_MAX_PX = 1600;
|
|
365
430
|
var testIdSelector = (selector) => /^[\w-]+$/.test(selector) ? `[data-testid="${selector}"]` : selector;
|
|
366
431
|
var normalise = (text) => text.replace(/\s+/g, " ").trim();
|
|
@@ -397,6 +462,11 @@ var Director = class {
|
|
|
397
462
|
names: [],
|
|
398
463
|
textPx: Number.POSITIVE_INFINITY
|
|
399
464
|
};
|
|
465
|
+
/**
|
|
466
|
+
* The chapter so far: whether it pushed in, and the smallest text its
|
|
467
|
+
* clicks showed wide. A chapter that stays wide must read wide.
|
|
468
|
+
*/
|
|
469
|
+
chapter = { pushed: false, textPx: Number.POSITIVE_INFINITY };
|
|
400
470
|
/** When the whole window came into view (virtual ms), for ESTABLISH_MS. */
|
|
401
471
|
wideSince = 0;
|
|
402
472
|
cdp;
|
|
@@ -493,8 +563,14 @@ var Director = class {
|
|
|
493
563
|
this.wasWide = this.isWide();
|
|
494
564
|
this.shot = next;
|
|
495
565
|
this.shotLabel = `the shot on "${t.target}" at ${scale}`;
|
|
566
|
+
await this.pushedIn();
|
|
496
567
|
await this.moveCamera(next);
|
|
497
568
|
}
|
|
569
|
+
// The screen a push-in is on is the one it stays on, until the card.
|
|
570
|
+
async pushedIn() {
|
|
571
|
+
this.chapter.pushed = true;
|
|
572
|
+
await markScreen(this.app);
|
|
573
|
+
}
|
|
498
574
|
// Films a camera move. A push-in from the wide shot waits until the whole
|
|
499
575
|
// window has been seen for ESTABLISH_MS: a page never opens zoomed.
|
|
500
576
|
async moveCamera(shot) {
|
|
@@ -553,6 +629,7 @@ var Director = class {
|
|
|
553
629
|
this.wasWide = this.isWide();
|
|
554
630
|
this.shot = next;
|
|
555
631
|
this.shotLabel = `the shot framing ${names}`;
|
|
632
|
+
await this.pushedIn();
|
|
556
633
|
await this.moveCamera(next);
|
|
557
634
|
}
|
|
558
635
|
async zoomOut(options = {}) {
|
|
@@ -606,7 +683,7 @@ var Director = class {
|
|
|
606
683
|
const margin = 8;
|
|
607
684
|
if (cx < v.x + margin || cx > v.x + v.width - margin || cy < v.y + margin || cy > v.y + v.height - margin) {
|
|
608
685
|
throw new Error(
|
|
609
|
-
`"${t.target}" is outside ${this.shotLabel}:
|
|
686
|
+
`"${t.target}" is outside ${this.shotLabel}: frame an area that holds everything this shot uses, or use it from the wide shot, before the push-in.`
|
|
610
687
|
);
|
|
611
688
|
}
|
|
612
689
|
return { point: this.toStagePoint(rect), rect };
|
|
@@ -638,6 +715,7 @@ var Director = class {
|
|
|
638
715
|
}
|
|
639
716
|
).catch(() => Number.NaN);
|
|
640
717
|
if (!inner) {
|
|
718
|
+
await markScreen(this.app);
|
|
641
719
|
await watchChanges(this.locator(t));
|
|
642
720
|
}
|
|
643
721
|
if (filmed) {
|
|
@@ -659,6 +737,7 @@ var Director = class {
|
|
|
659
737
|
await this.page.mouse.click(point.x, point.y);
|
|
660
738
|
}
|
|
661
739
|
await options.then?.();
|
|
740
|
+
const pressedAt = Date.now();
|
|
662
741
|
if (this.covered) {
|
|
663
742
|
await this.settle(COVERED_SETTLED_MS, COVERED_MAX_MS);
|
|
664
743
|
if (!inner) {
|
|
@@ -675,23 +754,60 @@ var Director = class {
|
|
|
675
754
|
if (inner) {
|
|
676
755
|
return;
|
|
677
756
|
}
|
|
757
|
+
const biggest = await biggestChange(this.app);
|
|
758
|
+
const opened = await newScreen(this.app);
|
|
678
759
|
if (zoomed) {
|
|
679
|
-
|
|
760
|
+
if (this.cropped(opened) || await this.movesOn(pressedAt)) {
|
|
761
|
+
throw new Error(
|
|
762
|
+
`${options.verb} "${t.target}" opens a new screen or dialog under the push-in, so it would show cropped: use it from the wide shot (no push-in before it), and push in on the new screen once it has been seen whole.`
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
this.assertChangeInShot(t, options.verb, biggest);
|
|
680
766
|
return;
|
|
681
767
|
}
|
|
682
|
-
const biggest = await biggestChange(this.app);
|
|
683
|
-
this.wideUse.rects.push(rect, ...biggest ? [biggest.rect] : []);
|
|
684
|
-
this.wideUse.names.push(t.target);
|
|
685
768
|
const smallest = Math.min(
|
|
686
769
|
Number.isFinite(fontPx) ? fontPx : Number.POSITIVE_INFINITY,
|
|
687
770
|
biggest?.fontPx ?? Number.POSITIVE_INFINITY
|
|
688
|
-
);
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
771
|
+
) * this.layout.fit;
|
|
772
|
+
this.chapter.textPx = Math.min(this.chapter.textPx, smallest);
|
|
773
|
+
if (opened) {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
this.wideUse.rects.push(rect, ...biggest ? [biggest.rect] : []);
|
|
777
|
+
this.wideUse.names.push(t.target);
|
|
778
|
+
this.wideUse.textPx = Math.min(this.wideUse.textPx, smallest);
|
|
779
|
+
}
|
|
780
|
+
// After a press under a push-in, in check: the time render's beat and a
|
|
781
|
+
// camera move would take, watching for a screen that moves on by itself.
|
|
782
|
+
// The screen as the reaction left it is what the push-in stays on.
|
|
783
|
+
async movesOn(pressedAt) {
|
|
784
|
+
await markScreen(this.app);
|
|
785
|
+
if (!this.fast) {
|
|
786
|
+
return false;
|
|
694
787
|
}
|
|
788
|
+
while (Date.now() - pressedAt < MOVES_ON_MS) {
|
|
789
|
+
await this.page.waitForTimeout(POLL_MS);
|
|
790
|
+
if (this.cropped(await newScreen(this.app))) {
|
|
791
|
+
return true;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return false;
|
|
795
|
+
}
|
|
796
|
+
// A new screen (where its texts left and arrived) that the shot doesn't
|
|
797
|
+
// hold whole. A list filtered inside the shot is not one.
|
|
798
|
+
cropped(screen) {
|
|
799
|
+
return screen !== null && !shows(this.layout, this.shot, screen);
|
|
800
|
+
}
|
|
801
|
+
// While pushed in, the screen stays the one the push-in is on: a screen
|
|
802
|
+
// the app moves on to (after a wait, a hold, by an action) would show
|
|
803
|
+
// cropped. Check skips holds, so there only render catches a timer.
|
|
804
|
+
async assertSameScreen() {
|
|
805
|
+
if (this.isWide() || this.covered || !this.cropped(await newScreen(this.app))) {
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
throw new Error(
|
|
809
|
+
"the app moves on to a new screen while the camera is pushed in, so it would show cropped: push in only on a screen the chapter then stays on, and do what leads to the next one from the wide shot."
|
|
810
|
+
);
|
|
695
811
|
}
|
|
696
812
|
// Until nothing in the app has changed for `quiet` ms, at most `max` ms:
|
|
697
813
|
// filmed on the virtual clock, in real time in check.
|
|
@@ -728,15 +844,14 @@ var Director = class {
|
|
|
728
844
|
// click changed lands outside the shot (an item added to a basket
|
|
729
845
|
// off-screen), the zoom is on the wrong area: the viewer would see a
|
|
730
846
|
// button pressed and nothing happen.
|
|
731
|
-
|
|
732
|
-
const biggest = await biggestChange(this.app);
|
|
847
|
+
assertChangeInShot(t, verb, biggest) {
|
|
733
848
|
if (!biggest) {
|
|
734
849
|
return;
|
|
735
850
|
}
|
|
736
851
|
if (coverage(biggest.rect, visibleRect(this.layout, this.shot)) < SHOWN) {
|
|
737
852
|
this.unseen = {
|
|
738
853
|
rect: biggest.rect,
|
|
739
|
-
message: `${verb} "${t.target}" changes ${biggest.label}, which is outside ${this.shotLabel}, and the next step doesn't show it:
|
|
854
|
+
message: `${verb} "${t.target}" changes ${biggest.label}, which is outside ${this.shotLabel}, and the next step doesn't show it: frame the control with what it changes, or use it from the wide shot.`
|
|
740
855
|
};
|
|
741
856
|
}
|
|
742
857
|
}
|
|
@@ -797,6 +912,19 @@ var Director = class {
|
|
|
797
912
|
end() {
|
|
798
913
|
this.requireSeen("other");
|
|
799
914
|
this.closeWide();
|
|
915
|
+
this.closeChapter();
|
|
916
|
+
}
|
|
917
|
+
// A chapter ends (a card, the end). One that never pushed in must have
|
|
918
|
+
// read wide: a portrait kiosk scaled into the frame, or small text, gives
|
|
919
|
+
// the viewer nothing to read on a phone.
|
|
920
|
+
closeChapter() {
|
|
921
|
+
const { pushed, textPx } = this.chapter;
|
|
922
|
+
this.chapter = { pushed: false, textPx: Number.POSITIVE_INFINITY };
|
|
923
|
+
if (!pushed && textPx < READABLE_PX) {
|
|
924
|
+
throw new Error(
|
|
925
|
+
`the chapter before this stays wide, where its text is ${Math.round(textPx)} px on the frame, too small to read: push in on what the viewer should read (the result it ends on), and stay in until the card.`
|
|
926
|
+
);
|
|
927
|
+
}
|
|
800
928
|
}
|
|
801
929
|
async during(work, options = {}) {
|
|
802
930
|
const recorder = this.options.recorder;
|
|
@@ -815,6 +943,7 @@ var Director = class {
|
|
|
815
943
|
this.requireSeen("other");
|
|
816
944
|
const result = await run();
|
|
817
945
|
await this.wait(POST_ACTION_MS);
|
|
946
|
+
await this.assertSameScreen();
|
|
818
947
|
return result;
|
|
819
948
|
}
|
|
820
949
|
// --- waits and checks ------------------------------------------------------------
|
|
@@ -826,6 +955,7 @@ var Director = class {
|
|
|
826
955
|
options.timeoutMs,
|
|
827
956
|
() => `"${t.target}" did not ${options.hidden ? "disappear" : "appear"} within ${options.timeoutMs / 1e3} s`
|
|
828
957
|
);
|
|
958
|
+
await this.assertSameScreen();
|
|
829
959
|
}
|
|
830
960
|
async expectText(t, text, options) {
|
|
831
961
|
this.requireSeen("other");
|
|
@@ -846,15 +976,19 @@ var Director = class {
|
|
|
846
976
|
options.timeoutMs,
|
|
847
977
|
() => `expected "${want}"${options.contains ? " (contains)" : ""}, saw "${last}"`
|
|
848
978
|
);
|
|
979
|
+
await this.assertSameScreen();
|
|
849
980
|
}
|
|
850
981
|
async hold(ms) {
|
|
851
982
|
this.requireSeen("other");
|
|
852
983
|
await this.wait(ms);
|
|
984
|
+
await this.assertSameScreen();
|
|
853
985
|
}
|
|
854
986
|
// --- overlays --------------------------------------------------------------------
|
|
855
987
|
async card(spec, options = {}) {
|
|
856
988
|
this.requireSeen("other");
|
|
989
|
+
await this.assertSameScreen();
|
|
857
990
|
this.closeWide();
|
|
991
|
+
this.closeChapter();
|
|
858
992
|
this.shot = wideShot(this.layout);
|
|
859
993
|
this.shotLabel = "the wide shot";
|
|
860
994
|
if (this.fast) {
|
package/package.json
CHANGED
|
@@ -89,9 +89,11 @@ Before any JSON, write the plan. For each chapter, in this order:
|
|
|
89
89
|
3. **Actions** (2–4), each with **what it changes and where** (a row at the
|
|
90
90
|
top, the total at the bottom, a new screen).
|
|
91
91
|
4. **Shots:** the chapter opens wide, on the whole window. Push in only
|
|
92
|
-
where the text is too small to read wide, framing
|
|
93
|
-
it changes (a live preview included)
|
|
94
|
-
|
|
92
|
+
where the text is too small to read wide, framing what is read: the
|
|
93
|
+
control *and* what it changes (a live preview included), or the result
|
|
94
|
+
(a dialog, the basket). The engine shows the whole window first, and
|
|
95
|
+
the camera stays in until the card, so taps that open a new screen come
|
|
96
|
+
before the push-in. End on the result, framed if it needs reading.
|
|
95
97
|
5. **The card:** statement + benefit, written last (below), and the
|
|
96
98
|
snapshot (`show`) of the result it ends on, if that result is visual.
|
|
97
99
|
6. **Proof:** the value the app shows, checked with `expectText`.
|
|
@@ -112,8 +114,8 @@ ch1 sees: a guest scans two waters and they land in the basket
|
|
|
112
114
|
show: basket
|
|
113
115
|
ch2 sees: the guest pays by card and the payment goes through
|
|
114
116
|
behind: —
|
|
115
|
-
shots:
|
|
116
|
-
end framing the receipt
|
|
117
|
+
shots: wide: "Go to payment" opens a new screen; pay wide too;
|
|
118
|
+
end framing the receipt, held until the card
|
|
117
119
|
card: "Pay *by card*." / "Tap, insert or phone: the terminal takes it."
|
|
118
120
|
show: receipt
|
|
119
121
|
payoff "Paid *in seconds*." · sub "64,50 kr. by card." (expectText on the total)
|
|
@@ -184,21 +186,23 @@ A chapter in steps:
|
|
|
184
186
|
{ "do": "card", "heading": "Pay *by card*.",
|
|
185
187
|
"sub": "Tap, insert or phone: the terminal takes it.", "show": "receipt" },
|
|
186
188
|
{ "do": "click", "target": "accept-terms", "behindCard": true },
|
|
187
|
-
{ "do": "frame", "targets": ["pay-button", "order-total"] },
|
|
188
189
|
{ "do": "expectText", "target": "order-total", "text": "64,50 kr." },
|
|
189
190
|
{ "do": "click", "target": "pay-button" },
|
|
190
|
-
{ "do": "zoomOut" },
|
|
191
191
|
{ "do": "waitFor", "target": "receipt" },
|
|
192
192
|
{ "do": "frame", "targets": ["receipt", "receipt-total"] },
|
|
193
193
|
{ "do": "hold", "seconds": 2 }
|
|
194
194
|
|
|
195
|
-
- **Shots:** stay wide while the shot reads. When
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
195
|
+
- **Shots:** stay wide while the shot reads. When it doesn't, push in on
|
|
196
|
+
what the viewer reads (a dialog, the basket, the receipt) and stay in until the next
|
|
197
|
+
card, which takes the camera back to wide; never pull out (`check`
|
|
198
|
+
refuses a `zoomOut` after a push-in). A tap that opens a new screen or
|
|
199
|
+
dialog happens wide, so what it opens is seen whole first: `check`
|
|
200
|
+
refuses it under a push-in, also when the screen moves on by itself a
|
|
201
|
+
moment later (a payment going on to "processing"). Prefer `frame` (2–4
|
|
202
|
+
targets: the control and what it changes) to guessing one `zoom` target,
|
|
203
|
+
and keep the live preview in it; use `maxScale` to stay looser. When
|
|
204
|
+
`check` asks for a shot, question the shot before adding a frame:
|
|
205
|
+
staying wide, or framing the result, is often the better fix.
|
|
202
206
|
- **Typing:** `"replace": true` clears what the field holds first. Type
|
|
203
207
|
short, believable values that change something on screen.
|
|
204
208
|
- **Leave the beats to the engine.** It pauses around every click, settles
|
|
@@ -266,8 +270,9 @@ report. Then film a draft:
|
|
|
266
270
|
|
|
267
271
|
pnpm --dir <app> exec demo-video render --draft [--story <name>]
|
|
268
272
|
|
|
269
|
-
A draft films at 30 fps, in about half the time of the final (which
|
|
270
|
-
|
|
273
|
+
A draft films at 30 fps, in about half the time of the final (which takes
|
|
274
|
+
6–15× the video's length, depending on the machine; a developer laptop is
|
|
275
|
+
at the low end). It runs a check first when cards show
|
|
271
276
|
snapshots, to capture them. Playwright starts the app's dev server from the
|
|
272
277
|
adapter's `webServer`; if it can't on this machine, start it yourself with
|
|
273
278
|
the same command and env, and it is reused.
|
|
@@ -15,7 +15,8 @@ importance:
|
|
|
15
15
|
something. Never rushed: results and cards stay up long enough to read.
|
|
16
16
|
3. **Purposeful camera.** Every page opens wide, on the whole window. The
|
|
17
17
|
camera pushes in only where the action would be too small to read, and
|
|
18
|
-
then only after the viewer has seen where they are
|
|
18
|
+
then only after the viewer has seen where they are; once in, it stays
|
|
19
|
+
in until the next card. It moves between
|
|
19
20
|
shots, never during the action, never chases the cursor, and there are no
|
|
20
21
|
hard cuts inside a chapter.
|
|
21
22
|
4. **Soft, light, branded.** A pale brand-tinted background, a floating app
|
|
@@ -128,22 +129,29 @@ text only, centred.
|
|
|
128
129
|
chrome. A wide shot whose text reads (16 px or more on the 1080p frame)
|
|
129
130
|
needs no push-in.
|
|
130
131
|
- **A page never opens zoomed.** Every chapter opens wide when its card
|
|
131
|
-
dissolves, and so does every new screen
|
|
132
|
+
dissolves, and so does every new screen or dialog, the first time it is
|
|
133
|
+
seen: a tap that opens one is made from the wide shot, also when the
|
|
134
|
+
screen moves on by itself a moment later. When a push-in is needed, the
|
|
132
135
|
whole window is seen first (the engine holds it ~0.8 s), then the camera
|
|
133
136
|
moves in.
|
|
134
|
-
- **Push in only
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
- **Push in only to read:** where the text is too small to read wide
|
|
138
|
+
(`check` asks for it), and then on what the viewer reads (a dialog, the
|
|
139
|
+
basket, the confirmation), not on the control that leads there. A
|
|
140
|
+
portrait app scaled into the frame almost always needs it. Frame the
|
|
141
|
+
control *and* what it changes, and no tighter than it takes to read
|
|
142
|
+
(scale 1.3–1.8, never past 2: tighter loses the context and softens the
|
|
143
|
+
picture). A live preview that the action changes belongs in the shot.
|
|
144
|
+
- **Never pull out.** A push-in stays in until the next card, which takes
|
|
145
|
+
the camera back to wide behind it. In and out on the same screen is
|
|
146
|
+
wasted motion: do the taps that open something new wide, before the
|
|
147
|
+
push-in.
|
|
139
148
|
- **Hold steady through the action.** The clicks happen inside the shot; the
|
|
140
149
|
camera doesn't move with them. At most two push-ins a chapter.
|
|
141
|
-
- **Show the result.**
|
|
142
|
-
row added at the top, a new screen), pull out right after it. A push-in
|
|
150
|
+
- **Show the result.** What an action changes is in its shot. A push-in
|
|
143
151
|
that shows a button pressed and nothing happening is the worst shot.
|
|
144
152
|
- **End on the result.** The last shot of a chapter frames what it achieved
|
|
145
153
|
(the pass with its wallet badge, the receipt), held long enough to read,
|
|
146
|
-
then the card covers it.
|
|
154
|
+
then the card covers it, still pushed in.
|
|
147
155
|
- **Each move eases in and out** (engine: 900 ms,
|
|
148
156
|
`cubic-bezier(0.65, 0, 0.35, 1)`), then settles 150 ms before anything
|
|
149
157
|
happens.
|
|
@@ -58,7 +58,7 @@ export default defineDemo({
|
|
|
58
58
|
| `press` | `key`, `behindCard` |
|
|
59
59
|
| `zoom` | `target`, `nth`, `scale` (1.3–2) |
|
|
60
60
|
| `frame` | `targets` (2–4 testids or `{ target, nth }`), `maxScale` (1.3–2, default 1.8) |
|
|
61
|
-
| `zoomOut` | `park` (rest the cursor too) |
|
|
61
|
+
| `zoomOut` | `park` (rest the cursor too). Never after a push-in: a card takes the camera back to wide |
|
|
62
62
|
| `park` | rests the cursor in empty space |
|
|
63
63
|
| `waitFor` | `target`, `nth`, `hidden`, `timeout` (s), `behindCard` |
|
|
64
64
|
| `expectText` | `target`, `nth`, `text`, `contains`, `timeout` (s) |
|
|
@@ -91,10 +91,19 @@ push-in from the wide shot comes after the whole window has been seen. At
|
|
|
91
91
|
most two push-ins a chapter, and a push-in needs something done or read in
|
|
92
92
|
its shot. A control a step uses must be inside the current shot. What it
|
|
93
93
|
changes (watched from the press to the app settling: typing's preview, a
|
|
94
|
-
toast, a restyle) must be in the shot or
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
toast, a restyle) must be in the shot or framed by the next step. A
|
|
95
|
+
push-in stays in until the next card: no `zoomOut` after one. A tap that
|
|
96
|
+
opens a new screen or dialog (most of what was read is gone or covered,
|
|
97
|
+
and the shot doesn't hold all that changed), or one after which the app
|
|
98
|
+
moves on by itself within about 2.5 s, happens wide; so does whatever the
|
|
99
|
+
app moves on to while pushed in (a wait, a hold, an action; check skips
|
|
100
|
+
holds, so a timer there only fails render). A list filtered inside the
|
|
101
|
+
shot is not a new screen. A wide shot
|
|
102
|
+
whose text is under 16 px on the frame, where the controls and what they
|
|
103
|
+
change would fit a push-in of 1.4 or more, needs one (taps that open a new
|
|
104
|
+
screen don't count: they stay wide). A chapter that never pushes in needs
|
|
105
|
+
text of 16 px or more on the frame throughout; a portrait kiosk scaled
|
|
106
|
+
into the frame rarely has it.
|
|
98
107
|
|
|
99
108
|
**Adapter actions** may name the control they use (`target(params)`); the
|
|
100
109
|
cursor then goes there and rests, and the camera rules apply as for a
|
|
@@ -110,8 +119,12 @@ length against its kind's range (`seconds` and `kind` in `--json`).
|
|
|
110
119
|
|
|
111
120
|
| It says | Fix |
|
|
112
121
|
|---|---|
|
|
113
|
-
| `"x" is outside the shot …` |
|
|
114
|
-
| `clicking / typing in / using "x" changes y, which is outside the shot …` |
|
|
122
|
+
| `"x" is outside the shot …` | Frame an area that holds everything the shot uses, or use `x` wide, before the push-in |
|
|
123
|
+
| `clicking / typing in / using "x" changes y, which is outside the shot …` | Frame the control with what it changes, or use it wide |
|
|
124
|
+
| `clicking "x" opens a new screen or dialog under the push-in …` | Drop the push-in before it: tap wide, then push in on the new screen if it needs reading |
|
|
125
|
+
| `the app moves on to a new screen while the camera is pushed in …` | Don't push in on a screen that moves on by itself; push in on the one it moves to |
|
|
126
|
+
| `a pull-out` | Remove the `zoomOut`: end the chapter pushed in on its result, or do the wide taps before the push-in |
|
|
127
|
+
| `the chapter before this stays wide, where its text is N px …` | Push in on the result the chapter ends on (`frame` it) and hold until the card |
|
|
115
128
|
| `"x" is used in the wide shot, where its text is N px, too small to read …` | `frame` the controls and what they change (the engine pushes in after a wide beat); or, if it's setup, `behindCard` |
|
|
116
129
|
| `N camera push-ins in one chapter` | Push in once on the chapter's area and hold steady |
|
|
117
130
|
| `… only fit in the wide shot` | Stay wide, or frame things that sit together |
|