@navipartner/demo-video 0.7.1 → 0.7.3
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/README.md +6 -5
- package/bin/demo-video.mjs +6 -0
- package/bin/runner.mjs +8 -2
- package/dist/{adapter-bOi1ZBRa.d.ts → adapter-DxZmb-QU.d.ts} +13 -3
- package/dist/{chunk-RMBVQL2J.js → chunk-XMOGUZJU.js} +14 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/runner/index.d.ts +1 -1
- package/dist/runner/index.js +266 -77
- package/package.json +6 -4
- package/skills/np-demo-video/SKILL.md +49 -21
- package/skills/np-demo-video/look.md +31 -21
- package/skills/np-demo-video/reference.md +23 -15
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ video of the app: it writes the adapter and the primary story with you.
|
|
|
33
33
|
| Linear | @Linear "… for Northwind (northwind.example)" | the same, in a customer's researched brand |
|
|
34
34
|
| A PR | the `demo video` label | reviewing the stories the PR adds or changes |
|
|
35
35
|
| A PR | `/demo-video checkout` (`help` for the menu) | filming any story from the PR's branch |
|
|
36
|
-
| Your machine |
|
|
36
|
+
| Your machine | Claude with the skill | writing and tuning a story: 30 fps drafts, then the 60 fps video once you say the whole flow is right |
|
|
37
37
|
|
|
38
38
|
Requests only pick existing stories; new stories come through a developer's
|
|
39
39
|
PR. In a PR comment, the first line is the request, a translation is found
|
|
@@ -44,8 +44,9 @@ the menu.
|
|
|
44
44
|
|
|
45
45
|
Ask @Linear in plain words, naming the repo, the story and the customer. In
|
|
46
46
|
a coding session it follows the skill's `linear.md`, attaches the MP4 and
|
|
47
|
-
its contact sheet to the issue, and commits nothing. It
|
|
48
|
-
credits; its sandbox is slow, about
|
|
47
|
+
its contact sheet to the issue, and commits nothing. It films the agreed
|
|
48
|
+
story at 60 fps. It runs on Linear's AI credits; its sandbox is slow, about
|
|
49
|
+
15× the video's length at 60 fps.
|
|
49
50
|
|
|
50
51
|
For a customer, the video is a **pitch draft**. @Linear researches their
|
|
51
52
|
brand from their own website, the engine checks it and films at 30 fps, and
|
|
@@ -92,8 +93,8 @@ output is kept in `demo/out/failed/`.
|
|
|
92
93
|
pnpm test && pnpm typecheck && pnpm lint && pnpm build
|
|
93
94
|
|
|
94
95
|
CI runs the tests, actionlint and a smoke render of the fixture app. To
|
|
95
|
-
release,
|
|
96
|
-
|
|
96
|
+
release, tag main: `git tag vX.Y.Z && git push origin vX.Y.Z`. The tag
|
|
97
|
+
sets the version; `package.json` isn't bumped by hand. CI publishes to npm as `latest`
|
|
97
98
|
through trusted publishing (no npm token) and moves the `v0` tag. Running
|
|
98
99
|
`release.yml` by hand publishes a `next` prerelease.
|
|
99
100
|
|
package/bin/demo-video.mjs
CHANGED
|
@@ -135,6 +135,12 @@ const printReport = (report) => {
|
|
|
135
135
|
` about ${report.seconds} s; a ${report.kind === "feature" ? "feature clip" : "showcase"} runs ${low}–${high} s${fits ? `, so this is ${fits}` : ""}`,
|
|
136
136
|
);
|
|
137
137
|
}
|
|
138
|
+
// A card held up by its setup lingers: worth moving some of it.
|
|
139
|
+
for (const card of report.slowCards ?? []) {
|
|
140
|
+
console.log(
|
|
141
|
+
` ${card.where}: the setup behind it takes about ${card.setupSeconds} s, so it stays up longer than its ${card.readSeconds} s to read; move some setup behind an earlier card`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
138
144
|
return;
|
|
139
145
|
}
|
|
140
146
|
const where = report.step
|
package/bin/runner.mjs
CHANGED
|
@@ -14,6 +14,12 @@ const importPath = (from, to) => {
|
|
|
14
14
|
const HEADER =
|
|
15
15
|
"// Generated by @navipartner/demo-video on every run; do not edit.\n";
|
|
16
16
|
|
|
17
|
+
// In an app that isn't ESM, Playwright compiles demo.config.ts as CommonJS,
|
|
18
|
+
// and its `export default` arrives wrapped: { __esModule, default }.
|
|
19
|
+
const importAdapter = (from) => `import loaded from ${from};
|
|
20
|
+
|
|
21
|
+
const adapter = loaded?.__esModule ? loaded.default : loaded;`;
|
|
22
|
+
|
|
17
23
|
/** Writes playwright.config.mjs and demo.spec.mjs; returns the config path. */
|
|
18
24
|
export const writeRunner = ({
|
|
19
25
|
runnerDir,
|
|
@@ -28,7 +34,7 @@ export const writeRunner = ({
|
|
|
28
34
|
configPath,
|
|
29
35
|
`${HEADER}import { createDemoConfig } from "@navipartner/demo-video/runner";
|
|
30
36
|
|
|
31
|
-
|
|
37
|
+
${importAdapter(adapter)}
|
|
32
38
|
|
|
33
39
|
export default createDemoConfig(adapter, ${dirs});
|
|
34
40
|
`,
|
|
@@ -38,7 +44,7 @@ export default createDemoConfig(adapter, ${dirs});
|
|
|
38
44
|
`${HEADER}import { registerDemoTests } from "@navipartner/demo-video/runner";
|
|
39
45
|
import { test } from "@playwright/test";
|
|
40
46
|
|
|
41
|
-
|
|
47
|
+
${importAdapter(adapter)}
|
|
42
48
|
|
|
43
49
|
registerDemoTests(test, adapter, ${JSON.stringify(demoDir)});
|
|
44
50
|
`,
|
|
@@ -71,8 +71,8 @@ interface CardOptions {
|
|
|
71
71
|
/** The story's kind: how long the open card may stay up. */
|
|
72
72
|
kind?: Kind;
|
|
73
73
|
/**
|
|
74
|
-
* Done while the card covers the app (setup
|
|
75
|
-
*
|
|
74
|
+
* Done while the card covers the app (setup): no cursor, no camera
|
|
75
|
+
* moves, and the card stays up until it's done.
|
|
76
76
|
*/
|
|
77
77
|
behind?: () => Promise<void>;
|
|
78
78
|
}
|
|
@@ -116,7 +116,10 @@ interface DirectorApi {
|
|
|
116
116
|
* Runs an adapter action. While filming, time is stopped, so an action that
|
|
117
117
|
* waits on the app (a scan that fetches) needs frames filmed meanwhile.
|
|
118
118
|
*/
|
|
119
|
-
during<T>(work: () => Promise<T
|
|
119
|
+
during<T>(work: () => Promise<T>, options?: {
|
|
120
|
+
/** The control the work uses: the cursor goes there, as for a click. */
|
|
121
|
+
target?: Target;
|
|
122
|
+
}): Promise<T>;
|
|
120
123
|
/**
|
|
121
124
|
* The app's clock, in ms: real time in check mode, the filmed (virtual)
|
|
122
125
|
* time while filming. The idle accounting measures waits with it.
|
|
@@ -140,6 +143,13 @@ interface DirectorApi {
|
|
|
140
143
|
interface DemoAction<S extends z.ZodRawShape = z.ZodRawShape> {
|
|
141
144
|
params: z.ZodObject<S>;
|
|
142
145
|
run(d: DirectorApi, params: z.infer<z.ZodObject<S>>): Promise<void>;
|
|
146
|
+
/**
|
|
147
|
+
* The control the action uses (a colour field, an upload button), as a
|
|
148
|
+
* testid, a selector or `{ target, nth }`. With one, the cursor glides to
|
|
149
|
+
* it and rests before the action, the camera rules apply as for a click,
|
|
150
|
+
* and the app's reaction plays out after it.
|
|
151
|
+
*/
|
|
152
|
+
target?(params: z.infer<z.ZodObject<S>>): string | Target | undefined;
|
|
143
153
|
}
|
|
144
154
|
/** One thing a brand gives the app: what it is and where it shows. */
|
|
145
155
|
type BrandNeed = string | {
|
|
@@ -12,6 +12,11 @@ var CURSOR_MIN_MS = 400;
|
|
|
12
12
|
var CURSOR_MAX_MS = 800;
|
|
13
13
|
var CURSOR_MS_PER_PX = 0.7;
|
|
14
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;
|
|
15
20
|
var PRE_CLICK_MS = 160;
|
|
16
21
|
var PRESS_MS = 110;
|
|
17
22
|
var POST_CLICK_MS = 450;
|
|
@@ -26,7 +31,7 @@ var TITLE_MAX_SECONDS = {
|
|
|
26
31
|
var POLL_MS = 100;
|
|
27
32
|
var wordCount = (text) => text ? text.replace(/\*/g, "").split(/\s+/).filter(Boolean).length : 0;
|
|
28
33
|
var cardSeconds = (spec, maxSeconds = Number.POSITIVE_INFINITY) => {
|
|
29
|
-
const read = 0.
|
|
34
|
+
const read = 0.5 + 0.25 * wordCount(spec.heading) + 0.2 * wordCount(spec.sub) + 0.12 * wordCount(spec.eyebrow);
|
|
30
35
|
return Math.max(
|
|
31
36
|
spec.seconds,
|
|
32
37
|
Math.min(maxSeconds, Math.round(read * 10) / 10)
|
|
@@ -453,7 +458,7 @@ var cardFields = {
|
|
|
453
458
|
logo: z.boolean().optional(),
|
|
454
459
|
/** "pale" = brand-tinted white; "brand" = full-bleed primary colour. */
|
|
455
460
|
style: z.enum(["pale", "brand"]).default("pale"),
|
|
456
|
-
seconds: z.number().min(0.5).max(10).default(1.
|
|
461
|
+
seconds: z.number().min(0.5).max(10).default(1.6)
|
|
457
462
|
};
|
|
458
463
|
var show = z.union([z.string().min(1), z.object({ ...target }).strict()]).optional();
|
|
459
464
|
var stepCardFields = {
|
|
@@ -516,7 +521,7 @@ var coreStepSchemas = [
|
|
|
516
521
|
var CORE_STEP_NAMES = coreStepSchemas.map(
|
|
517
522
|
(s) => s.shape.do.value
|
|
518
523
|
);
|
|
519
|
-
var RESERVED_FIELDS = ["do"];
|
|
524
|
+
var RESERVED_FIELDS = ["do", "behindCard"];
|
|
520
525
|
var cardSchema = z.object(cardFields).strict().refine(hasContent, CARD_EMPTY);
|
|
521
526
|
var titleSchema = z.object({ ...cardFields, show }).strict().refine(hasContent, CARD_EMPTY);
|
|
522
527
|
var isCoreStep = (step) => CORE_STEP_NAMES.includes(step.do);
|
|
@@ -533,7 +538,7 @@ var buildStoryboardSchema = (adapter) => {
|
|
|
533
538
|
);
|
|
534
539
|
}
|
|
535
540
|
}
|
|
536
|
-
return z.object({ do: z.literal(name) }).merge(action.params).strict();
|
|
541
|
+
return z.object({ do: z.literal(name), ...behindCard }).merge(action.params).strict();
|
|
537
542
|
}
|
|
538
543
|
);
|
|
539
544
|
const stepSchema = z.discriminatedUnion("do", [
|
|
@@ -823,6 +828,11 @@ export {
|
|
|
823
828
|
CURSOR_MIN_MS,
|
|
824
829
|
CURSOR_MAX_MS,
|
|
825
830
|
SETTLE_MS,
|
|
831
|
+
ESTABLISH_MS,
|
|
832
|
+
SETTLED_MS,
|
|
833
|
+
REACTION_MS,
|
|
834
|
+
COVERED_SETTLED_MS,
|
|
835
|
+
COVERED_MAX_MS,
|
|
826
836
|
PRE_CLICK_MS,
|
|
827
837
|
PRESS_MS,
|
|
828
838
|
POST_CLICK_MS,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { K as Kind, D as DemoAdapter } from './adapter-
|
|
2
|
-
export { B as Brand, C as CardSpec, a as DemoAction, b as DirectorApi, T as Target, V as VideoBrand, d as defineAction, c as defineDemo } from './adapter-
|
|
1
|
+
import { K as Kind, D as DemoAdapter } from './adapter-DxZmb-QU.js';
|
|
2
|
+
export { B as Brand, C as CardSpec, a as DemoAction, b as DirectorApi, T as Target, V as VideoBrand, d as defineAction, c as defineDemo } from './adapter-DxZmb-QU.js';
|
|
3
3
|
import { Page } from '@playwright/test';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
export { z } from 'zod';
|
package/dist/index.js
CHANGED
package/dist/runner/index.d.ts
CHANGED
package/dist/runner/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CARD_IN_MS,
|
|
3
3
|
CARD_OUT_MS,
|
|
4
|
+
COVERED_MAX_MS,
|
|
5
|
+
COVERED_SETTLED_MS,
|
|
4
6
|
CURSOR_MAX_MS,
|
|
5
7
|
CURSOR_MIN_MS,
|
|
6
8
|
DEFAULT_VIEWPORT,
|
|
9
|
+
ESTABLISH_MS,
|
|
7
10
|
LINGER_MS,
|
|
8
11
|
MIN_SCALE,
|
|
9
12
|
PARK,
|
|
@@ -13,6 +16,8 @@ import {
|
|
|
13
16
|
POST_KEY_MS,
|
|
14
17
|
PRESS_MS,
|
|
15
18
|
PRE_CLICK_MS,
|
|
19
|
+
REACTION_MS,
|
|
20
|
+
SETTLED_MS,
|
|
16
21
|
SETTLE_MS,
|
|
17
22
|
STAGE_HEIGHT,
|
|
18
23
|
STAGE_PATH,
|
|
@@ -36,7 +41,7 @@ import {
|
|
|
36
41
|
unionRect,
|
|
37
42
|
visibleRect,
|
|
38
43
|
wideShot
|
|
39
|
-
} from "../chunk-
|
|
44
|
+
} from "../chunk-XMOGUZJU.js";
|
|
40
45
|
|
|
41
46
|
// src/runner/config.ts
|
|
42
47
|
import path from "path";
|
|
@@ -260,15 +265,31 @@ import fs2 from "fs";
|
|
|
260
265
|
import path3 from "path";
|
|
261
266
|
|
|
262
267
|
// src/changes.ts
|
|
268
|
+
var MAX_RESTYLES = 3;
|
|
263
269
|
var watchChanges = (target) => target.evaluate((el) => {
|
|
264
|
-
const
|
|
270
|
+
const w = window;
|
|
271
|
+
w.__demoChanges?.observer.disconnect();
|
|
272
|
+
const changed = /* @__PURE__ */ new Map();
|
|
273
|
+
const watch = { changed, lastAt: Date.now() };
|
|
265
274
|
const observer = new MutationObserver((records) => {
|
|
266
275
|
for (const record of records) {
|
|
276
|
+
if (record.type !== "attributes") {
|
|
277
|
+
watch.lastAt = Date.now();
|
|
278
|
+
}
|
|
267
279
|
const nodes = record.type === "childList" ? Array.from(record.addedNodes) : [record.target];
|
|
268
280
|
for (const node of nodes) {
|
|
269
281
|
const changedEl = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
|
270
282
|
if (changedEl && !el.contains(changedEl) && !changedEl.contains(el)) {
|
|
271
|
-
changed.
|
|
283
|
+
const seen = changed.get(changedEl) ?? {
|
|
284
|
+
content: false,
|
|
285
|
+
restyles: 0
|
|
286
|
+
};
|
|
287
|
+
if (record.type === "attributes") {
|
|
288
|
+
seen.restyles++;
|
|
289
|
+
} else {
|
|
290
|
+
seen.content = true;
|
|
291
|
+
}
|
|
292
|
+
changed.set(changedEl, seen);
|
|
272
293
|
}
|
|
273
294
|
}
|
|
274
295
|
}
|
|
@@ -276,14 +297,21 @@ var watchChanges = (target) => target.evaluate((el) => {
|
|
|
276
297
|
observer.observe(document.body, {
|
|
277
298
|
subtree: true,
|
|
278
299
|
childList: true,
|
|
279
|
-
characterData: true
|
|
300
|
+
characterData: true,
|
|
301
|
+
attributes: true,
|
|
302
|
+
attributeFilter: ["style", "class", "src"]
|
|
280
303
|
});
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
304
|
+
watch.observer = observer;
|
|
305
|
+
w.__demoChanges = watch;
|
|
306
|
+
});
|
|
307
|
+
var watching = (app) => app.evaluate(
|
|
308
|
+
() => Boolean(window.__demoChanges)
|
|
309
|
+
);
|
|
310
|
+
var quietMs = (app) => app.evaluate(() => {
|
|
311
|
+
const watch = window.__demoChanges;
|
|
312
|
+
return watch ? Date.now() - watch.lastAt : Number.POSITIVE_INFINITY;
|
|
285
313
|
});
|
|
286
|
-
var biggestChange = (app) => app.evaluate(() => {
|
|
314
|
+
var biggestChange = (app) => app.evaluate((maxRestyles) => {
|
|
287
315
|
const w = window;
|
|
288
316
|
const watch = w.__demoChanges;
|
|
289
317
|
delete w.__demoChanges;
|
|
@@ -292,9 +320,10 @@ var biggestChange = (app) => app.evaluate(() => {
|
|
|
292
320
|
}
|
|
293
321
|
watch.observer.disconnect();
|
|
294
322
|
let best = null;
|
|
323
|
+
let bestEl = null;
|
|
295
324
|
let label = "";
|
|
296
|
-
for (const el of watch.changed) {
|
|
297
|
-
if (!el.isConnected) {
|
|
325
|
+
for (const [el, seen] of watch.changed) {
|
|
326
|
+
if (!el.isConnected || !seen.content && seen.restyles > maxRestyles) {
|
|
298
327
|
continue;
|
|
299
328
|
}
|
|
300
329
|
const r = el.getBoundingClientRect();
|
|
@@ -303,17 +332,35 @@ var biggestChange = (app) => app.evaluate(() => {
|
|
|
303
332
|
}
|
|
304
333
|
if (!best || r.width * r.height > best.width * best.height) {
|
|
305
334
|
best = { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
335
|
+
bestEl = el;
|
|
306
336
|
label = el.getAttribute("data-testid") ?? el.tagName.toLowerCase() + (el.textContent ? ` "${el.textContent.trim().slice(0, 30)}"` : "");
|
|
307
337
|
}
|
|
308
338
|
}
|
|
309
|
-
|
|
310
|
-
|
|
339
|
+
if (!best || !bestEl) {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
let fontPx = Number.POSITIVE_INFINITY;
|
|
343
|
+
const texts = [
|
|
344
|
+
bestEl,
|
|
345
|
+
...Array.from(bestEl.querySelectorAll("*")).slice(0, 200)
|
|
346
|
+
];
|
|
347
|
+
for (const node of texts) {
|
|
348
|
+
const hasText = Array.from(node.childNodes).some(
|
|
349
|
+
(c) => c.nodeType === Node.TEXT_NODE && c.textContent?.trim()
|
|
350
|
+
);
|
|
351
|
+
if (hasText) {
|
|
352
|
+
fontPx = Math.min(fontPx, parseFloat(getComputedStyle(node).fontSize));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return { rect: best, label, fontPx };
|
|
356
|
+
}, MAX_RESTYLES);
|
|
311
357
|
|
|
312
358
|
// src/director.ts
|
|
313
359
|
var TARGET_TIMEOUT_MS = 15e3;
|
|
314
360
|
var SHOWN = 0.5;
|
|
315
361
|
var FRAME_MAX_SCALE = 1.8;
|
|
316
362
|
var PUSH_WORTHY = 1.4;
|
|
363
|
+
var READABLE_PX = 16;
|
|
317
364
|
var STILL_MAX_PX = 1600;
|
|
318
365
|
var testIdSelector = (selector) => /^[\w-]+$/.test(selector) ? `[data-testid="${selector}"]` : selector;
|
|
319
366
|
var normalise = (text) => text.replace(/\s+/g, " ").trim();
|
|
@@ -347,8 +394,11 @@ var Director = class {
|
|
|
347
394
|
*/
|
|
348
395
|
wideUse = {
|
|
349
396
|
rects: [],
|
|
350
|
-
names: []
|
|
397
|
+
names: [],
|
|
398
|
+
textPx: Number.POSITIVE_INFINITY
|
|
351
399
|
};
|
|
400
|
+
/** When the whole window came into view (virtual ms), for ESTABLISH_MS. */
|
|
401
|
+
wideSince = 0;
|
|
352
402
|
cdp;
|
|
353
403
|
get fast() {
|
|
354
404
|
return this.options.fast;
|
|
@@ -440,16 +490,21 @@ var Director = class {
|
|
|
440
490
|
if (sameShot(next, this.shot)) {
|
|
441
491
|
return;
|
|
442
492
|
}
|
|
493
|
+
this.wasWide = this.isWide();
|
|
443
494
|
this.shot = next;
|
|
444
495
|
this.shotLabel = `the shot on "${t.target}" at ${scale}`;
|
|
445
496
|
await this.moveCamera(next);
|
|
446
497
|
}
|
|
447
|
-
// Films a camera move.
|
|
448
|
-
//
|
|
498
|
+
// Films a camera move. A push-in from the wide shot waits until the whole
|
|
499
|
+
// window has been seen for ESTABLISH_MS: a page never opens zoomed.
|
|
449
500
|
async moveCamera(shot) {
|
|
450
501
|
if (this.fast) {
|
|
451
502
|
return;
|
|
452
503
|
}
|
|
504
|
+
const fromWide = this.wasWide;
|
|
505
|
+
if (fromWide && !this.covered) {
|
|
506
|
+
await this.wait(ESTABLISH_MS - (this.clockMs() - this.wideSince));
|
|
507
|
+
}
|
|
453
508
|
const instant = this.covered;
|
|
454
509
|
await this.page.evaluate(([s, i]) => window.__demo.setShot(s, i), [
|
|
455
510
|
shot,
|
|
@@ -484,7 +539,7 @@ var Director = class {
|
|
|
484
539
|
);
|
|
485
540
|
if (scale < MIN_SCALE) {
|
|
486
541
|
throw new Error(
|
|
487
|
-
`${names} only fit in the wide shot (at ${scale.toFixed(2)})
|
|
542
|
+
`${names} only fit in the wide shot (at ${scale.toFixed(2)}): stay wide, or frame things that sit together`
|
|
488
543
|
);
|
|
489
544
|
}
|
|
490
545
|
const next = shotFor(this.layout, all, scale);
|
|
@@ -495,6 +550,7 @@ var Director = class {
|
|
|
495
550
|
if (sameShot(next, this.shot)) {
|
|
496
551
|
return;
|
|
497
552
|
}
|
|
553
|
+
this.wasWide = this.isWide();
|
|
498
554
|
this.shot = next;
|
|
499
555
|
this.shotLabel = `the shot framing ${names}`;
|
|
500
556
|
await this.moveCamera(next);
|
|
@@ -520,7 +576,15 @@ var Director = class {
|
|
|
520
576
|
await this.page.evaluate((s) => window.__demo.setShot(s), wide);
|
|
521
577
|
}
|
|
522
578
|
await this.wait(Math.max(moving ? ZOOM_MS + SETTLE_MS : 0, parkMs));
|
|
579
|
+
if (moving) {
|
|
580
|
+
this.wideSince = this.clockMs();
|
|
581
|
+
}
|
|
523
582
|
}
|
|
583
|
+
// Whether the camera shows the whole window now.
|
|
584
|
+
isWide() {
|
|
585
|
+
return this.shot.k <= this.layout.fit * 1.01;
|
|
586
|
+
}
|
|
587
|
+
wasWide = false;
|
|
524
588
|
// --- cursor + input -----------------------------------------------------------
|
|
525
589
|
/** Rests the cursor in empty space, off the content. */
|
|
526
590
|
async park() {
|
|
@@ -542,32 +606,41 @@ var Director = class {
|
|
|
542
606
|
const margin = 8;
|
|
543
607
|
if (cx < v.x + margin || cx > v.x + v.width - margin || cy < v.y + margin || cy > v.y + v.height - margin) {
|
|
544
608
|
throw new Error(
|
|
545
|
-
`"${t.target}" is outside ${this.shotLabel}
|
|
609
|
+
`"${t.target}" is outside ${this.shotLabel}: zoomOut first, or frame an area that holds everything this shot uses.`
|
|
546
610
|
);
|
|
547
611
|
}
|
|
548
612
|
return { point: this.toStagePoint(rect), rect };
|
|
549
613
|
}
|
|
550
614
|
async click(t, options = {}) {
|
|
615
|
+
await this.interact(t, { zoom: options.zoom, verb: "clicking" });
|
|
616
|
+
}
|
|
617
|
+
// One interaction with a control, as a person makes it: the cursor glides
|
|
618
|
+
// there and rests, presses (a real click, or only the look of one when an
|
|
619
|
+
// adapter action does the work), `then` runs (typing, the action), and the
|
|
620
|
+
// app's reaction plays out. What it changes is watched from before the
|
|
621
|
+
// press to the end, so typing's live preview, a toast after a save and a
|
|
622
|
+
// restyle all count as the result. Behind a card none of it is filmed.
|
|
623
|
+
async interact(t, options) {
|
|
551
624
|
if (options.zoom !== void 0) {
|
|
552
625
|
await this.zoomTo(t, options.zoom);
|
|
553
626
|
} else {
|
|
554
627
|
this.requireSeen("other");
|
|
555
628
|
}
|
|
556
629
|
const { point, rect } = await this.pointAt(t);
|
|
557
|
-
const
|
|
558
|
-
const
|
|
559
|
-
|
|
630
|
+
const filmed = !this.fast && !this.covered;
|
|
631
|
+
const zoomed = !this.isWide();
|
|
632
|
+
const inner = await watching(this.app);
|
|
633
|
+
const fontPx = inner ? Number.NaN : await this.locator(t).evaluate(
|
|
634
|
+
(el) => parseFloat(getComputedStyle(el).fontSize),
|
|
635
|
+
void 0,
|
|
636
|
+
{
|
|
637
|
+
timeout: 1e3
|
|
638
|
+
}
|
|
639
|
+
).catch(() => Number.NaN);
|
|
640
|
+
if (!inner) {
|
|
560
641
|
await watchChanges(this.locator(t));
|
|
561
642
|
}
|
|
562
|
-
if (
|
|
563
|
-
await this.page.mouse.click(point.x, point.y);
|
|
564
|
-
if (zoomed || wide) {
|
|
565
|
-
await this.page.waitForTimeout(POST_CLICK_MS);
|
|
566
|
-
}
|
|
567
|
-
} else if (this.covered) {
|
|
568
|
-
await this.page.mouse.click(point.x, point.y);
|
|
569
|
-
await this.wait(POST_CLICK_MS);
|
|
570
|
-
} else {
|
|
643
|
+
if (filmed) {
|
|
571
644
|
const ms = await this.page.evaluate(
|
|
572
645
|
([x, y]) => window.__demo.cursorTo(x, y),
|
|
573
646
|
[point.x, point.y]
|
|
@@ -575,33 +648,79 @@ var Director = class {
|
|
|
575
648
|
await this.wait(ms);
|
|
576
649
|
await this.page.mouse.move(point.x, point.y);
|
|
577
650
|
await this.wait(PRE_CLICK_MS);
|
|
651
|
+
}
|
|
652
|
+
if (options.press === "look") {
|
|
653
|
+
await this.wait(PRESS_MS);
|
|
654
|
+
} else if (filmed) {
|
|
578
655
|
await this.page.mouse.down();
|
|
579
656
|
await this.wait(PRESS_MS);
|
|
580
657
|
await this.page.mouse.up();
|
|
658
|
+
} else {
|
|
659
|
+
await this.page.mouse.click(point.x, point.y);
|
|
660
|
+
}
|
|
661
|
+
await options.then?.();
|
|
662
|
+
if (this.covered) {
|
|
663
|
+
await this.settle(COVERED_SETTLED_MS, COVERED_MAX_MS);
|
|
664
|
+
if (!inner) {
|
|
665
|
+
await biggestChange(this.app);
|
|
666
|
+
}
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (filmed) {
|
|
581
670
|
await this.wait(POST_CLICK_MS);
|
|
671
|
+
} else {
|
|
672
|
+
await this.page.waitForTimeout(POST_CLICK_MS);
|
|
673
|
+
}
|
|
674
|
+
await this.settle(SETTLED_MS, REACTION_MS);
|
|
675
|
+
if (inner) {
|
|
676
|
+
return;
|
|
582
677
|
}
|
|
583
678
|
if (zoomed) {
|
|
584
|
-
await this.assertChangeInShot(t);
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
679
|
+
await this.assertChangeInShot(t, options.verb);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
const biggest = await biggestChange(this.app);
|
|
683
|
+
this.wideUse.rects.push(rect, ...biggest ? [biggest.rect] : []);
|
|
684
|
+
this.wideUse.names.push(t.target);
|
|
685
|
+
const smallest = Math.min(
|
|
686
|
+
Number.isFinite(fontPx) ? fontPx : Number.POSITIVE_INFINITY,
|
|
687
|
+
biggest?.fontPx ?? Number.POSITIVE_INFINITY
|
|
688
|
+
);
|
|
689
|
+
if (Number.isFinite(smallest)) {
|
|
690
|
+
this.wideUse.textPx = Math.min(
|
|
691
|
+
this.wideUse.textPx,
|
|
692
|
+
smallest * this.layout.fit
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
// Until nothing in the app has changed for `quiet` ms, at most `max` ms:
|
|
697
|
+
// filmed on the virtual clock, in real time in check.
|
|
698
|
+
async settle(quiet, max) {
|
|
699
|
+
for (let waited = 0; waited < max; waited += POLL_MS) {
|
|
700
|
+
if (await quietMs(this.app) >= quiet) {
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (this.fast) {
|
|
704
|
+
await this.page.waitForTimeout(POLL_MS);
|
|
705
|
+
} else {
|
|
706
|
+
await this.wait(POLL_MS);
|
|
707
|
+
}
|
|
589
708
|
}
|
|
590
709
|
}
|
|
591
710
|
// The wide shot ends (a push-in, a card, the end). Its clicks must have
|
|
592
711
|
// needed it: if their controls and results all fit a push-in, the wide
|
|
593
712
|
// shot showed them too small to read on a phone.
|
|
594
713
|
closeWide() {
|
|
595
|
-
const { rects, names } = this.wideUse;
|
|
596
|
-
this.wideUse = { rects: [], names: [] };
|
|
597
|
-
if (names.length === 0) {
|
|
714
|
+
const { rects, names, textPx } = this.wideUse;
|
|
715
|
+
this.wideUse = { rects: [], names: [], textPx: Number.POSITIVE_INFINITY };
|
|
716
|
+
if (names.length === 0 || textPx >= READABLE_PX) {
|
|
598
717
|
return;
|
|
599
718
|
}
|
|
600
719
|
const scale = fittingScale(this.layout, unionRect(rects));
|
|
601
720
|
if (scale >= PUSH_WORTHY) {
|
|
602
|
-
const
|
|
721
|
+
const used = [...new Set(names)].map((n) => `"${n}"`).join(", ");
|
|
603
722
|
throw new Error(
|
|
604
|
-
`${
|
|
723
|
+
`${used} ${names.length > 1 ? "are" : "is"} used in the wide shot, where its text is ${Math.round(textPx)} px, too small to read, and the controls with what they change fit a push-in at ${Math.min(scale, FRAME_MAX_SCALE).toFixed(1)}: frame them (the engine pushes in after a wide beat), or do it behindCard if it's setup`
|
|
605
724
|
);
|
|
606
725
|
}
|
|
607
726
|
}
|
|
@@ -609,7 +728,7 @@ var Director = class {
|
|
|
609
728
|
// click changed lands outside the shot (an item added to a basket
|
|
610
729
|
// off-screen), the zoom is on the wrong area: the viewer would see a
|
|
611
730
|
// button pressed and nothing happen.
|
|
612
|
-
async assertChangeInShot(t) {
|
|
731
|
+
async assertChangeInShot(t, verb) {
|
|
613
732
|
const biggest = await biggestChange(this.app);
|
|
614
733
|
if (!biggest) {
|
|
615
734
|
return;
|
|
@@ -617,7 +736,7 @@ var Director = class {
|
|
|
617
736
|
if (coverage(biggest.rect, visibleRect(this.layout, this.shot)) < SHOWN) {
|
|
618
737
|
this.unseen = {
|
|
619
738
|
rect: biggest.rect,
|
|
620
|
-
message:
|
|
739
|
+
message: `${verb} "${t.target}" changes ${biggest.label}, which is outside ${this.shotLabel}, and the next step doesn't show it: zoomOut right after, or frame the control with what it changes.`
|
|
621
740
|
};
|
|
622
741
|
}
|
|
623
742
|
}
|
|
@@ -637,20 +756,33 @@ var Director = class {
|
|
|
637
756
|
throw new Error(unseen.message);
|
|
638
757
|
}
|
|
639
758
|
async type(t, text, perCharMs = TYPE_CHAR_MS, options = {}) {
|
|
640
|
-
await this.
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
759
|
+
await this.interact(t, {
|
|
760
|
+
verb: "typing in",
|
|
761
|
+
then: async () => {
|
|
762
|
+
if (options.replace) {
|
|
763
|
+
await this.page.keyboard.press("ControlOrMeta+A");
|
|
764
|
+
} else {
|
|
765
|
+
await this.app.evaluate(() => {
|
|
766
|
+
const el = document.activeElement;
|
|
767
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
768
|
+
try {
|
|
769
|
+
el.setSelectionRange(el.value.length, el.value.length);
|
|
770
|
+
} catch {
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
if (this.fast || this.covered) {
|
|
776
|
+
await this.page.keyboard.type(text);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
await this.wait(POST_KEY_MS);
|
|
780
|
+
for (const char of text) {
|
|
781
|
+
await this.page.keyboard.type(char);
|
|
782
|
+
await this.wait(perCharMs);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
});
|
|
654
786
|
}
|
|
655
787
|
async press(key) {
|
|
656
788
|
this.requireSeen("other");
|
|
@@ -666,13 +798,22 @@ var Director = class {
|
|
|
666
798
|
this.requireSeen("other");
|
|
667
799
|
this.closeWide();
|
|
668
800
|
}
|
|
669
|
-
async during(work) {
|
|
670
|
-
this.requireSeen("other");
|
|
801
|
+
async during(work, options = {}) {
|
|
671
802
|
const recorder = this.options.recorder;
|
|
672
|
-
|
|
673
|
-
|
|
803
|
+
const run = () => this.fast || !recorder ? work() : recorder.during(work());
|
|
804
|
+
if (options.target) {
|
|
805
|
+
let result2;
|
|
806
|
+
await this.interact(options.target, {
|
|
807
|
+
verb: "using",
|
|
808
|
+
press: "look",
|
|
809
|
+
then: async () => {
|
|
810
|
+
result2 = await run();
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
return result2;
|
|
674
814
|
}
|
|
675
|
-
|
|
815
|
+
this.requireSeen("other");
|
|
816
|
+
const result = await run();
|
|
676
817
|
await this.wait(POST_ACTION_MS);
|
|
677
818
|
return result;
|
|
678
819
|
}
|
|
@@ -737,6 +878,7 @@ var Director = class {
|
|
|
737
878
|
if (!options.keepUp) {
|
|
738
879
|
await this.hideCard();
|
|
739
880
|
await this.wait(CARD_OUT_MS);
|
|
881
|
+
this.wideSince = this.clockMs();
|
|
740
882
|
}
|
|
741
883
|
}
|
|
742
884
|
async behind(work) {
|
|
@@ -897,10 +1039,16 @@ var runStep = async (d, step, adapter) => {
|
|
|
897
1039
|
throw new Error(`unknown step "${step.do}"`);
|
|
898
1040
|
}
|
|
899
1041
|
const params = Object.fromEntries(
|
|
900
|
-
Object.entries(step).filter(
|
|
1042
|
+
Object.entries(step).filter(
|
|
1043
|
+
([key]) => key !== "do" && key !== "behindCard"
|
|
1044
|
+
)
|
|
901
1045
|
);
|
|
902
1046
|
const parsed = action.params.parse(params);
|
|
903
|
-
|
|
1047
|
+
const target = action.target?.(parsed);
|
|
1048
|
+
await d.during(
|
|
1049
|
+
() => action.run(d, parsed),
|
|
1050
|
+
target === void 0 ? void 0 : { target: toTarget(target) }
|
|
1051
|
+
);
|
|
904
1052
|
};
|
|
905
1053
|
var needsLinger = (previous) => previous !== void 0 && previous.do !== "hold" && previous.do !== "card" && !isBehindCard(previous);
|
|
906
1054
|
var TITLE = -1;
|
|
@@ -916,9 +1064,6 @@ var planCards = (steps, hasTitle) => {
|
|
|
916
1064
|
while (j < steps.length && isBehindCard(steps[j])) {
|
|
917
1065
|
behind.push(j++);
|
|
918
1066
|
}
|
|
919
|
-
if (j < steps.length && (steps[j].do === "zoom" || steps[j].do === "frame")) {
|
|
920
|
-
behind.push(j++);
|
|
921
|
-
}
|
|
922
1067
|
plans.set(start, {
|
|
923
1068
|
behind,
|
|
924
1069
|
opens: j < steps.length && steps[j].do !== "card"
|
|
@@ -948,9 +1093,9 @@ var stillCards = (steps, title) => [
|
|
|
948
1093
|
var WAITS = /* @__PURE__ */ new Set(["waitFor", "expectText"]);
|
|
949
1094
|
var runSteps = async (d, steps, adapter, options = {}) => {
|
|
950
1095
|
const idleLimitMs = adapter.idleTimeoutSeconds ? adapter.idleTimeoutSeconds * 1e3 - IDLE_MARGIN_MS : Number.POSITIVE_INFINITY;
|
|
951
|
-
let
|
|
1096
|
+
let quietMs2 = options.initialQuietMs ?? 0;
|
|
952
1097
|
const tooQuiet = () => new Error(
|
|
953
|
-
`the app would sit untouched for ${Math.round(
|
|
1098
|
+
`the app would sit untouched for ${Math.round(quietMs2 / 1e3)} s; its idle timeout is ${adapter.idleTimeoutSeconds} s. Add an interaction or shorten the holds.`
|
|
954
1099
|
);
|
|
955
1100
|
const plans = planCards(steps, Boolean(options.title));
|
|
956
1101
|
const covered = new Set([...plans.values()].flatMap((p) => p.behind));
|
|
@@ -1017,8 +1162,8 @@ var runSteps = async (d, steps, adapter, options = {}) => {
|
|
|
1017
1162
|
try {
|
|
1018
1163
|
const linger = step.do === "card" && needsLinger(steps[index - 1]);
|
|
1019
1164
|
if (step.do === "hold" || step.do === "card" || CAMERA_MOVES.has(step.do) && !covered.has(index)) {
|
|
1020
|
-
|
|
1021
|
-
if (
|
|
1165
|
+
quietMs2 += step.do === "hold" ? Number(step.seconds) * 1e3 : step.do === "card" ? cardSeconds(step) * 1e3 + CARD_DISSOLVE_MS + (linger ? LINGER_MS : 0) : CAMERA_MOVE_MS;
|
|
1166
|
+
if (quietMs2 > idleLimitMs) {
|
|
1022
1167
|
throw tooQuiet();
|
|
1023
1168
|
}
|
|
1024
1169
|
}
|
|
@@ -1032,13 +1177,13 @@ var runSteps = async (d, steps, adapter, options = {}) => {
|
|
|
1032
1177
|
await runStep(d, step, adapter);
|
|
1033
1178
|
}
|
|
1034
1179
|
if (WAITS.has(step.do)) {
|
|
1035
|
-
|
|
1036
|
-
if (
|
|
1180
|
+
quietMs2 += d.clockMs() - started;
|
|
1181
|
+
if (quietMs2 > idleLimitMs) {
|
|
1037
1182
|
throw tooQuiet();
|
|
1038
1183
|
}
|
|
1039
1184
|
}
|
|
1040
1185
|
if (INTERACTIONS.has(step.do) || adapter.actions?.[step.do]) {
|
|
1041
|
-
|
|
1186
|
+
quietMs2 = 0;
|
|
1042
1187
|
}
|
|
1043
1188
|
} catch (cause) {
|
|
1044
1189
|
await fail(index, cause);
|
|
@@ -1055,7 +1200,7 @@ var runSteps = async (d, steps, adapter, options = {}) => {
|
|
|
1055
1200
|
}
|
|
1056
1201
|
};
|
|
1057
1202
|
if (options.title) {
|
|
1058
|
-
|
|
1203
|
+
quietMs2 += titleSeconds(options.title, options.kind) * 1e3 + CARD_OUT_MS;
|
|
1059
1204
|
await runCard(TITLE);
|
|
1060
1205
|
}
|
|
1061
1206
|
for (const index of steps.keys()) {
|
|
@@ -1099,22 +1244,33 @@ var stepMs = (step) => {
|
|
|
1099
1244
|
return POST_ACTION_MS + CURSOR_MS;
|
|
1100
1245
|
}
|
|
1101
1246
|
};
|
|
1102
|
-
var estimateSeconds = (story) =>
|
|
1247
|
+
var estimateSeconds = (story) => Math.round(estimateMs(story) / 1e3);
|
|
1248
|
+
var estimateMs = (story) => {
|
|
1103
1249
|
const steps = story.steps;
|
|
1104
1250
|
const plans = planCards(steps, Boolean(story.title));
|
|
1105
1251
|
const covered = new Set([...plans.values()].flatMap((p) => p.behind));
|
|
1106
1252
|
const after = (at) => steps[(plans.get(at)?.behind.at(-1) ?? at) + 1];
|
|
1107
1253
|
let ms = 0;
|
|
1254
|
+
let wideAt = 0;
|
|
1108
1255
|
if (story.title) {
|
|
1109
1256
|
ms += titleSeconds(story.title, story.kind) * 1e3;
|
|
1110
1257
|
ms += after(TITLE)?.do === "card" ? 0 : CARD_OUT_MS;
|
|
1258
|
+
wideAt = ms;
|
|
1111
1259
|
}
|
|
1112
1260
|
steps.forEach((step, i) => {
|
|
1113
1261
|
if (covered.has(i)) {
|
|
1114
1262
|
return;
|
|
1115
1263
|
}
|
|
1116
1264
|
if (step.do !== "card") {
|
|
1265
|
+
const pushes = step.do === "zoom" || step.do === "frame" || step.do === "click" && step.zoom !== void 0;
|
|
1266
|
+
if (pushes && wideAt !== null) {
|
|
1267
|
+
ms += Math.max(0, ESTABLISH_MS - (ms - wideAt));
|
|
1268
|
+
wideAt = null;
|
|
1269
|
+
}
|
|
1117
1270
|
ms += stepMs(step);
|
|
1271
|
+
if (step.do === "zoomOut") {
|
|
1272
|
+
wideAt = ms;
|
|
1273
|
+
}
|
|
1118
1274
|
return;
|
|
1119
1275
|
}
|
|
1120
1276
|
const previous = steps[i - 1];
|
|
@@ -1122,13 +1278,45 @@ var estimateSeconds = (story) => {
|
|
|
1122
1278
|
ms += (linger ? LINGER_MS : 0) + CARD_IN_MS;
|
|
1123
1279
|
ms += cardSeconds(step) * 1e3;
|
|
1124
1280
|
ms += after(i)?.do === "card" || !after(i) && story.closing ? 0 : CARD_OUT_MS;
|
|
1281
|
+
wideAt = ms;
|
|
1125
1282
|
});
|
|
1126
1283
|
if (story.closing) {
|
|
1127
1284
|
const last = steps.at(-1)?.do;
|
|
1128
1285
|
ms += last !== "card" && last !== "hold" ? LINGER_MS : 0;
|
|
1129
1286
|
ms += CARD_IN_MS + titleSeconds(story.closing, story.kind) * 1e3;
|
|
1130
1287
|
}
|
|
1131
|
-
return
|
|
1288
|
+
return ms;
|
|
1289
|
+
};
|
|
1290
|
+
var COVERED_STEP_MS = {
|
|
1291
|
+
click: 300,
|
|
1292
|
+
type: 350,
|
|
1293
|
+
press: 100,
|
|
1294
|
+
waitFor: 200
|
|
1295
|
+
};
|
|
1296
|
+
var COVERED_ACTION_MS = 500;
|
|
1297
|
+
var slowCards = (story) => {
|
|
1298
|
+
const steps = story.steps;
|
|
1299
|
+
const plans = planCards(steps, Boolean(story.title));
|
|
1300
|
+
const slow = [];
|
|
1301
|
+
for (const [at, plan] of plans) {
|
|
1302
|
+
const card = at === TITLE ? story.title : steps[at];
|
|
1303
|
+
if (!card || plan.behind.length === 0) {
|
|
1304
|
+
continue;
|
|
1305
|
+
}
|
|
1306
|
+
const setupMs = plan.behind.reduce(
|
|
1307
|
+
(sum, i) => sum + (COVERED_STEP_MS[steps[i].do] ?? COVERED_ACTION_MS),
|
|
1308
|
+
0
|
|
1309
|
+
);
|
|
1310
|
+
const readSeconds = at === TITLE ? titleSeconds(card, story.kind) : cardSeconds(card);
|
|
1311
|
+
if (setupMs / 1e3 > readSeconds + 0.3) {
|
|
1312
|
+
slow.push({
|
|
1313
|
+
where: at === TITLE ? "title" : `steps.${at}`,
|
|
1314
|
+
readSeconds,
|
|
1315
|
+
setupSeconds: Math.round(setupMs / 100) / 10
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
return slow;
|
|
1132
1320
|
};
|
|
1133
1321
|
|
|
1134
1322
|
// src/frames.ts
|
|
@@ -1497,7 +1685,8 @@ var registerDemoTests = (test, adapter, demoDir) => {
|
|
|
1497
1685
|
ok: true,
|
|
1498
1686
|
steps: story.steps.length,
|
|
1499
1687
|
kind: story.kind ?? "showcase",
|
|
1500
|
-
seconds: estimateSeconds(story)
|
|
1688
|
+
seconds: estimateSeconds(story),
|
|
1689
|
+
slowCards: slowCards(story)
|
|
1501
1690
|
});
|
|
1502
1691
|
} catch (error) {
|
|
1503
1692
|
writeJson(reportPath, failureReport(error));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@navipartner/demo-video",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "Marketing demo videos of a React app, filmed by Playwright against the app's own e2e mocks.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -9,18 +9,20 @@
|
|
|
9
9
|
},
|
|
10
10
|
"type": "module",
|
|
11
11
|
"engines": {
|
|
12
|
-
"node": ">=22"
|
|
12
|
+
"node": ">=22.12"
|
|
13
13
|
},
|
|
14
14
|
"main": "./dist/index.js",
|
|
15
15
|
"types": "./dist/index.d.ts",
|
|
16
16
|
"exports": {
|
|
17
17
|
".": {
|
|
18
18
|
"types": "./dist/index.d.ts",
|
|
19
|
-
"import": "./dist/index.js"
|
|
19
|
+
"import": "./dist/index.js",
|
|
20
|
+
"default": "./dist/index.js"
|
|
20
21
|
},
|
|
21
22
|
"./runner": {
|
|
22
23
|
"types": "./dist/runner/index.d.ts",
|
|
23
|
-
"import": "./dist/runner/index.js"
|
|
24
|
+
"import": "./dist/runner/index.js",
|
|
25
|
+
"default": "./dist/runner/index.js"
|
|
24
26
|
}
|
|
25
27
|
},
|
|
26
28
|
"bin": {
|
|
@@ -71,7 +71,7 @@ step of the spec, decide:
|
|
|
71
71
|
|
|
72
72
|
| The step is… | In the video |
|
|
73
73
|
|---|---|
|
|
74
|
-
| The idea itself (scan, pay, the preview changing) | On screen, pushed in |
|
|
74
|
+
| The idea itself (scan, pay, the preview changing) | On screen: wide when it reads, pushed in only when it's too small |
|
|
75
75
|
| Needed, but not the idea (a language dialog, a cookie banner, a tab, a login) | `"behindCard": true` right after the chapter's card: done while the card covers the app |
|
|
76
76
|
| Filling a form | One or two short values, in fields whose effect shows on screen (the preview, the total). The rest stays as the mocks have it |
|
|
77
77
|
| Proof (a total, a confirmation) | `expectText`, framed and held |
|
|
@@ -88,8 +88,10 @@ Before any JSON, write the plan. For each chapter, in this order:
|
|
|
88
88
|
2. **Behind the card:** the setup it needs, if any.
|
|
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
|
-
4. **Shots:** the chapter opens on
|
|
92
|
-
|
|
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 the control *and* what
|
|
93
|
+
it changes (a live preview included); the engine shows the whole window
|
|
94
|
+
first. End on the result, framed if it needs reading.
|
|
93
95
|
5. **The card:** statement + benefit, written last (below), and the
|
|
94
96
|
snapshot (`show`) of the result it ends on, if that result is visual.
|
|
95
97
|
6. **Proof:** the value the app shows, checked with `expectText`.
|
|
@@ -156,7 +158,10 @@ end NaviPartner logo · "Now in NP Restaurant."
|
|
|
156
158
|
- `brandNeeds`: every `config` key and asset slot `setup` uses, each with
|
|
157
159
|
what it is and where it shows. Be specific: a pitch's research fills
|
|
158
160
|
exactly these.
|
|
159
|
-
- `actions` for what a test does through code (a scanner bridge
|
|
161
|
+
- `actions` for what a test does through code (a scanner bridge, a
|
|
162
|
+
colour picker, a file upload). An action the viewer sees names its
|
|
163
|
+
control (`target`), so the cursor goes there and the camera rules
|
|
164
|
+
apply; don't add holds to make up for a missing one. Also
|
|
160
165
|
`idleTimeoutSeconds` if the app resets when idle.
|
|
161
166
|
3. Write the primary story, then `check` it.
|
|
162
167
|
|
|
@@ -187,10 +192,13 @@ A chapter in steps:
|
|
|
187
192
|
{ "do": "frame", "targets": ["receipt", "receipt-total"] },
|
|
188
193
|
{ "do": "hold", "seconds": 2 }
|
|
189
194
|
|
|
190
|
-
- **Shots:**
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
195
|
+
- **Shots:** stay wide while the shot reads. When a push-in is needed,
|
|
196
|
+
prefer `frame` (2–4 targets: the control and what it changes) to guessing
|
|
197
|
+
one `zoom` target, and keep the live preview in it; use `maxScale` to
|
|
198
|
+
stay looser. When an action changes something outside the shot,
|
|
199
|
+
`zoomOut` right after it. When `check` asks for a shot, question the shot
|
|
200
|
+
before adding a frame: staying wide, or framing the result, is often the
|
|
201
|
+
better fix.
|
|
194
202
|
- **Typing:** `"replace": true` clears what the field holds first. Type
|
|
195
203
|
short, believable values that change something on screen.
|
|
196
204
|
- **Leave the beats to the engine.** It pauses around every click, settles
|
|
@@ -244,7 +252,7 @@ the base story.
|
|
|
244
252
|
|
|
245
253
|
Videos are silent: they must work on mute.
|
|
246
254
|
|
|
247
|
-
## 6. Check, then
|
|
255
|
+
## 6. Check, then film a draft
|
|
248
256
|
|
|
249
257
|
pnpm --dir <app> exec demo-video check [--story <name>] --json
|
|
250
258
|
|
|
@@ -254,15 +262,15 @@ how long the video will run against its kind's range. Its error names
|
|
|
254
262
|
the fix; reference.md lists them all. Its `visible` list shows what is on
|
|
255
263
|
screen at the failing step. Repeat until it passes, at most 3 rounds; a
|
|
256
264
|
story that still fails is reported as "couldn't show this", with the last
|
|
257
|
-
report. Then:
|
|
265
|
+
report. Then film a draft:
|
|
258
266
|
|
|
259
|
-
pnpm --dir <app> exec demo-video render [--story <name>]
|
|
267
|
+
pnpm --dir <app> exec demo-video render --draft [--story <name>]
|
|
260
268
|
|
|
261
|
-
|
|
262
|
-
runs a check first when cards show
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
269
|
+
A draft films at 30 fps, in about half the time of the final (which is
|
|
270
|
+
about 15× the video's length). It runs a check first when cards show
|
|
271
|
+
snapshots, to capture them. Playwright starts the app's dev server from the
|
|
272
|
+
adapter's `webServer`; if it can't on this machine, start it yourself with
|
|
273
|
+
the same command and env, and it is reused.
|
|
266
274
|
|
|
267
275
|
## 7. Review like a viewer
|
|
268
276
|
|
|
@@ -277,11 +285,31 @@ stills beside it. Judge against look.md:
|
|
|
277
285
|
screen; chapters end on their result.
|
|
278
286
|
- **Truth:** every word is true of what's on screen.
|
|
279
287
|
|
|
280
|
-
Adjust the JSON and
|
|
281
|
-
the plan from step 3 is the problem.
|
|
288
|
+
Adjust the JSON and film another draft. Two rounds of tuning is normal;
|
|
289
|
+
more means the plan from step 3 is the problem.
|
|
290
|
+
|
|
291
|
+
## 8. The final, only on the developer's go-ahead
|
|
292
|
+
|
|
293
|
+
Give the developer the draft (its MP4 and contact sheet) and **stop**. Film
|
|
294
|
+
the final 60 fps video only when the developer has watched the latest draft
|
|
295
|
+
and says, specifically, that they are satisfied with the whole flow and want
|
|
296
|
+
the final: "the flow is right, render the final", "go ahead at 60".
|
|
297
|
+
|
|
298
|
+
These are not a go-ahead, so ask instead ("Happy with the whole flow? Say
|
|
299
|
+
so and I'll render the final at 60 fps."):
|
|
300
|
+
- "looks good" or praise of one part, without asking for the final;
|
|
301
|
+
- approval of an earlier draft;
|
|
302
|
+
- a request for changes, however small;
|
|
303
|
+
- no answer.
|
|
304
|
+
|
|
305
|
+
A go-ahead covers the draft it was given for. After any change to the
|
|
306
|
+
story, film a new draft and wait for a new go-ahead. Then:
|
|
307
|
+
|
|
308
|
+
pnpm --dir <app> exec demo-video render [--story <name>]
|
|
282
309
|
|
|
283
310
|
## Report
|
|
284
311
|
|
|
285
|
-
Give the MP4 path and duration
|
|
286
|
-
anything the video could not show truthfully
|
|
287
|
-
not a real tenant's).
|
|
312
|
+
Give the MP4 path and duration (the draft's, or the final's), the chapter
|
|
313
|
+
plan, the contact sheet, and anything the video could not show truthfully
|
|
314
|
+
(it shows demo fixture data, not a real tenant's). After a draft, say that
|
|
315
|
+
the final waits for their go-ahead.
|
|
@@ -13,10 +13,11 @@ importance:
|
|
|
13
13
|
2. **No dead time.** Setup happens behind cards, only values that show
|
|
14
14
|
something are typed, and every beat is there so the viewer sees
|
|
15
15
|
something. Never rushed: results and cards stay up long enough to read.
|
|
16
|
-
3. **Purposeful camera.**
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
the cursor, and there are no
|
|
16
|
+
3. **Purposeful camera.** Every page opens wide, on the whole window. The
|
|
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. It moves between
|
|
19
|
+
shots, never during the action, never chases the cursor, and there are no
|
|
20
|
+
hard cuts inside a chapter.
|
|
20
21
|
4. **Soft, light, branded.** A pale brand-tinted background, a floating app
|
|
21
22
|
window, big serif statements, and the brand colour used boldly.
|
|
22
23
|
5. **Believable data.** The most real-looking records the e2e mocks have.
|
|
@@ -104,9 +105,11 @@ text only, centred.
|
|
|
104
105
|
the receipt that appears), checked by an `expectText`. No invented
|
|
105
106
|
durations or customer statistics.
|
|
106
107
|
- **Never on a card:** testids, barcodes, internal names or any other code.
|
|
107
|
-
- **Reading time:** a card stays up 0.
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
- **Reading time:** a card stays up 0.5 s plus ~0.25 s a word of its
|
|
109
|
+
statement and ~0.2 s a word of its sub-line (an eyebrow's words 0.12 s),
|
|
110
|
+
at least 1.6 s. The open and end cards stay at most 3.5 s in a showcase
|
|
111
|
+
and 2.5 s in a clip, unless the story asks for more. Setup behind a card
|
|
112
|
+
that takes longer holds it up: `check` says so.
|
|
110
113
|
|
|
111
114
|
## Transitions
|
|
112
115
|
|
|
@@ -120,17 +123,19 @@ text only, centred.
|
|
|
120
123
|
|
|
121
124
|
## Camera
|
|
122
125
|
|
|
123
|
-
- **Wide
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
- **Push in
|
|
132
|
-
|
|
133
|
-
|
|
126
|
+
- **Wide is the default.** The app floats in the middle of the frame at
|
|
127
|
+
~90 % width, with a small corner radius, a soft long shadow and no browser
|
|
128
|
+
chrome. A wide shot whose text reads (16 px or more on the 1080p frame)
|
|
129
|
+
needs no push-in.
|
|
130
|
+
- **A page never opens zoomed.** Every chapter opens wide when its card
|
|
131
|
+
dissolves, and so does every new screen. When a push-in is needed, the
|
|
132
|
+
whole window is seen first (the engine holds it ~0.8 s), then the camera
|
|
133
|
+
moves in.
|
|
134
|
+
- **Push in only where it's needed:** where the text is too small to read
|
|
135
|
+
wide (`check` asks for it), or to read a result. Frame the control *and*
|
|
136
|
+
what it changes, and no tighter than it takes to read (scale 1.3–1.8,
|
|
137
|
+
never past 2: tighter loses the context and softens the picture). A live
|
|
138
|
+
preview that the action changes belongs in the shot.
|
|
134
139
|
- **Hold steady through the action.** The clicks happen inside the shot; the
|
|
135
140
|
camera doesn't move with them. At most two push-ins a chapter.
|
|
136
141
|
- **Show the result.** When an action changes something outside the shot (a
|
|
@@ -150,7 +155,11 @@ text only, centred.
|
|
|
150
155
|
and doesn't pulse, ripple or jerk.
|
|
151
156
|
- **A beat around every click:** it rests on the control (160 ms, its hover
|
|
152
157
|
state shows), presses (110 ms, the pressed state shows), and the app's
|
|
153
|
-
reaction plays
|
|
158
|
+
reaction plays: 450 ms, then until the app has settled (a toast after a
|
|
159
|
+
save, a preview redrawn), up to a second more.
|
|
160
|
+
- **Every control the viewer sees used gets the cursor**, a colour picker or
|
|
161
|
+
an upload as much as a button: an adapter action names its control, and
|
|
162
|
+
the cursor goes there and rests before it acts.
|
|
154
163
|
- Typing runs at ~45 ms a character.
|
|
155
164
|
- Between actions it rests where it doesn't cover what the viewer should
|
|
156
165
|
read.
|
|
@@ -238,8 +247,9 @@ the product as the hero), not their technique.
|
|
|
238
247
|
([Nemo](https://www.nemovideo.com/blog/kinetic-typography-product-ads),
|
|
239
248
|
[Arcade, 14 million demo sessions](https://www.arcade.software/post/2026-arcade-benchmarks))
|
|
240
249
|
- **Reading time.** Netflix allows at most 20 characters a second, text up
|
|
241
|
-
0.83–7 s; the BBC works to about 0.3 s a word. Our
|
|
242
|
-
|
|
250
|
+
0.83–7 s; the BBC works to about 0.3 s a word. Our cards are read faster
|
|
251
|
+
than subtitles (0.25 s a statement word, 0.2 s a sub-line word): the viewer
|
|
252
|
+
isn't also following speech.
|
|
243
253
|
([Netflix](https://backlothelp.netflix.com/hc/en-us/articles/219375728))
|
|
244
254
|
- **Camera.** Screen Studio's "Focused" motion settles quickly so UI can be
|
|
245
255
|
read; that is the model: quick moves, then stillness.
|
|
@@ -49,7 +49,7 @@ export default defineDemo({
|
|
|
49
49
|
| Step | Fields |
|
|
50
50
|
|---|---|
|
|
51
51
|
| `click` | `target`, `nth`, `zoom` (push in on it first, 1.3–2), `behindCard` |
|
|
52
|
-
| `type` | `target`, `nth`, `text`, `perCharMs` (≥ 40, default 45), `replace` (clear the field first; without it the text
|
|
52
|
+
| `type` | `target`, `nth`, `text`, `perCharMs` (≥ 40, default 45), `replace` (clear the field first; without it the text goes at its end), `behindCard` |
|
|
53
53
|
| `press` | `key`, `behindCard` |
|
|
54
54
|
| `zoom` | `target`, `nth`, `scale` (1.3–2) |
|
|
55
55
|
| `frame` | `targets` (2–4 testids or `{ target, nth }`), `maxScale` (1.3–2, default 1.8) |
|
|
@@ -77,16 +77,24 @@ checked by an `expectText` in the story.
|
|
|
77
77
|
|
|
78
78
|
**Behind a card:** `behindCard` steps right after a card (or at the very
|
|
79
79
|
start, behind the title card) run while the card covers the app, with no
|
|
80
|
-
cursor and no camera.
|
|
81
|
-
|
|
82
|
-
is done.
|
|
83
|
-
|
|
84
|
-
**Camera rules** (`check` enforces them):
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
80
|
+
cursor and no camera. `click`, `type`, `press`, `waitFor` and adapter
|
|
81
|
+
actions take it; never a push-in, since every chapter opens wide. A card stays up until what's behind
|
|
82
|
+
it is done: `check` names a card whose setup outlasts its reading time.
|
|
83
|
+
|
|
84
|
+
**Camera rules** (`check` enforces them): every page opens wide, and a
|
|
85
|
+
push-in from the wide shot comes after the whole window has been seen. At
|
|
86
|
+
most two push-ins a chapter, and a push-in needs something done or read in
|
|
87
|
+
its shot. A control a step uses must be inside the current shot. What it
|
|
88
|
+
changes (watched from the press to the app settling: typing's preview, a
|
|
89
|
+
toast, a restyle) must be in the shot or shown by the next step (a
|
|
90
|
+
`zoomOut`, or a zoom that frames it). A wide shot whose text is under 16 px
|
|
91
|
+
on the frame, where the controls and what they change would fit a push-in
|
|
92
|
+
of 1.4 or more, needs one.
|
|
93
|
+
|
|
94
|
+
**Adapter actions** may name the control they use (`target(params)`); the
|
|
95
|
+
cursor then goes there and rests, and the camera rules apply as for a
|
|
96
|
+
click. Without one, an action has no cursor and only a short beat after
|
|
97
|
+
it.
|
|
90
98
|
|
|
91
99
|
**Timing:** the engine adds the beats around clicks, the camera's settle and
|
|
92
100
|
a linger before each card (look.md has the numbers). Holds may not add up
|
|
@@ -97,11 +105,11 @@ length against its kind's range (`seconds` and `kind` in `--json`).
|
|
|
97
105
|
|
|
98
106
|
| It says | Fix |
|
|
99
107
|
|---|---|
|
|
100
|
-
| `"x" is outside the shot …` |
|
|
101
|
-
| `clicking "x" changes y, which is outside the shot …` |
|
|
102
|
-
| `"x" is
|
|
108
|
+
| `"x" is outside the shot …` | `zoomOut` first, or frame an area that holds everything the shot uses |
|
|
109
|
+
| `clicking / typing in / using "x" changes y, which is outside the shot …` | `zoomOut` right after, or frame the control with what it changes |
|
|
110
|
+
| `"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` |
|
|
103
111
|
| `N camera push-ins in one chapter` | Push in once on the chapter's area and hold steady |
|
|
104
|
-
| `… only fit in the wide shot` |
|
|
112
|
+
| `… only fit in the wide shot` | Stay wide, or frame things that sit together |
|
|
105
113
|
| `a zoom with nothing in its shot` | Remove it, or act or read something in it |
|
|
106
114
|
| `zoom 2.4; use 1.3–2` | Zoom less: context reads better and stays sharp |
|
|
107
115
|
| `behindCard only right after a card` | Move the step to just after a card, or drop `behindCard` |
|