@navipartner/demo-video 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -4
- package/bin/demo-video.mjs +92 -17
- package/bin/init.d.mts +2 -0
- package/bin/init.mjs +17 -6
- package/dist/{adapter-CDOXdUZI.d.ts → adapter-bOi1ZBRa.d.ts} +36 -7
- package/dist/{chunk-D7UYGNSF.js → chunk-RMBVQL2J.js} +199 -48
- package/dist/index.d.ts +119 -20
- package/dist/index.js +1 -1
- package/dist/runner/index.d.ts +1 -1
- package/dist/runner/index.js +527 -180
- package/package.json +1 -1
- package/skills/np-demo-video/SKILL.md +213 -229
- package/skills/np-demo-video/look.md +169 -79
- package/skills/np-demo-video/reference.md +81 -30
package/README.md
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# @navipartner/demo-video
|
|
2
2
|
|
|
3
|
-
Marketing demo videos of React apps, in the NaviPartner look:
|
|
4
|
-
pushes and serif
|
|
3
|
+
Marketing demo videos of React apps, in the NaviPartner look: brisk camera
|
|
4
|
+
pushes and big serif cards that can show a snapshot of the result beside
|
|
5
|
+
the words. A story is a showcase of an app's main journey (45–90 s) or a
|
|
6
|
+
clip of one feature (15–30 s). The engine films an app on its own
|
|
5
7
|
Playwright e2e mocks, frame by frame at 60 fps, so pans stay smooth. It
|
|
6
8
|
works in pnpm repos on GitHub Actions, with `@playwright/test` 1.59 or later.
|
|
7
9
|
|
|
@@ -18,7 +20,8 @@ From the repo root:
|
|
|
18
20
|
|
|
19
21
|
`init` adds the skill, the `demo video` label and the workflow, and finds
|
|
20
22
|
the apps: folders with a `playwright.config`. A repo with one app is set up
|
|
21
|
-
in one go; with several,
|
|
23
|
+
in one go; with several, it prints the `init --app <folder>` line to run
|
|
24
|
+
for each.
|
|
22
25
|
It's safe to re-run, and `--dry-run` previews. Then ask Claude for a demo
|
|
23
26
|
video of the app: it writes the adapter and the primary story with you.
|
|
24
27
|
|
|
@@ -72,7 +75,7 @@ Run them in the app's folder: `pnpm exec demo-video <command>`.
|
|
|
72
75
|
| `init` | sets up a repo or an app |
|
|
73
76
|
| `list` | the stories, primary first |
|
|
74
77
|
| `check` | a dry run in seconds that names what would film badly |
|
|
75
|
-
| `render` | the MP4, a contact sheet and ten stills, in `demo/out/` |
|
|
78
|
+
| `render` | the MP4, a contact sheet and ten stills, in `demo/out/` (it checks first when cards show snapshots) |
|
|
76
79
|
| `record`, `encode`, `stills` | `render` in parts |
|
|
77
80
|
| `brand-needs` | what a brand must give this app |
|
|
78
81
|
| `pitch <proposal.json>` | a pitch draft from a researched brand |
|
package/bin/demo-video.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import path from "node:path";
|
|
|
8
8
|
|
|
9
9
|
import { listStories, parseArgs } from "./args.mjs";
|
|
10
10
|
import { encodeRecording, extractStillsFrom } from "./encode.mjs";
|
|
11
|
-
import { initApp, initProject } from "./init.mjs";
|
|
11
|
+
import { findApps, initApp, initProject } 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";
|
|
@@ -56,15 +56,85 @@ const runPlaywright = (args, mode) => {
|
|
|
56
56
|
DEMO_OUT: args.outDir,
|
|
57
57
|
DEMO_WORK: args.workDir,
|
|
58
58
|
DEMO_DRAFT: args.draft ? "1" : "",
|
|
59
|
+
DEMO_STILLS: args.stillsDir ?? "",
|
|
59
60
|
},
|
|
60
61
|
},
|
|
61
62
|
);
|
|
62
63
|
return result.status ?? 1;
|
|
63
64
|
};
|
|
64
65
|
|
|
66
|
+
const readReport = (args) => {
|
|
67
|
+
const reportPath = path.join(args.workDir, "check.json");
|
|
68
|
+
return fs.existsSync(reportPath)
|
|
69
|
+
? JSON.parse(fs.readFileSync(reportPath, "utf8"))
|
|
70
|
+
: {
|
|
71
|
+
ok: false,
|
|
72
|
+
error:
|
|
73
|
+
"the dry run produced no report (Playwright did not run the demo test — see its output above)",
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// A card that shows a still of its chapter's result (`show`) needs the
|
|
78
|
+
// still before filming: a check captures it, as the element last looks in
|
|
79
|
+
// that chapter. Returns the check's run, whose folder holds the stills
|
|
80
|
+
// until the take is filmed, or null when no card shows one.
|
|
81
|
+
const captureStills = (args) => {
|
|
82
|
+
const story = JSON.parse(fs.readFileSync(args.storyboardPath, "utf8"));
|
|
83
|
+
const shows =
|
|
84
|
+
story.title?.show !== undefined ||
|
|
85
|
+
(story.steps ?? []).some((s) => s?.do === "card" && s.show !== undefined);
|
|
86
|
+
if (!shows) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const workDir = path.join(path.dirname(args.workDir), "check");
|
|
90
|
+
const runnerDir = path.join(workDir, "runner");
|
|
91
|
+
const check = {
|
|
92
|
+
...args,
|
|
93
|
+
command: "check",
|
|
94
|
+
workDir,
|
|
95
|
+
runnerDir,
|
|
96
|
+
configPath: path.join(runnerDir, "playwright.config.mjs"),
|
|
97
|
+
stillsDir: path.join(workDir, "stills"),
|
|
98
|
+
};
|
|
99
|
+
const status = runPlaywright(check, "check");
|
|
100
|
+
const report = readReport(check);
|
|
101
|
+
if (!report.ok || status !== 0) {
|
|
102
|
+
finish(check, false);
|
|
103
|
+
throw new Error(
|
|
104
|
+
`The story doesn't pass check, so its card stills can't be captured: ${report.error}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return check;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Films the take, with the card stills when the story has any.
|
|
111
|
+
const film = (args) => {
|
|
112
|
+
const check = captureStills(args);
|
|
113
|
+
const status = runPlaywright(
|
|
114
|
+
{ ...args, stillsDir: check?.stillsDir },
|
|
115
|
+
"record",
|
|
116
|
+
);
|
|
117
|
+
if (check) {
|
|
118
|
+
finish(check, true);
|
|
119
|
+
}
|
|
120
|
+
return status;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// How long each kind runs (look.md, src/timing.ts KIND_SECONDS).
|
|
124
|
+
const KIND_SECONDS = { showcase: [45, 90], feature: [15, 30] };
|
|
125
|
+
|
|
65
126
|
const printReport = (report) => {
|
|
66
127
|
if (report.ok) {
|
|
67
128
|
console.log(`✓ story passes (${report.steps} steps)`);
|
|
129
|
+
const range = KIND_SECONDS[report.kind];
|
|
130
|
+
if (report.seconds !== undefined && range) {
|
|
131
|
+
const [low, high] = range;
|
|
132
|
+
const fits =
|
|
133
|
+
report.seconds < low ? "short" : report.seconds > high ? "long" : null;
|
|
134
|
+
console.log(
|
|
135
|
+
` about ${report.seconds} s; a ${report.kind === "feature" ? "feature clip" : "showcase"} runs ${low}–${high} s${fits ? `, so this is ${fits}` : ""}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
68
138
|
return;
|
|
69
139
|
}
|
|
70
140
|
const where = report.step
|
|
@@ -100,6 +170,14 @@ const runInit = (argv) => {
|
|
|
100
170
|
return 2;
|
|
101
171
|
}
|
|
102
172
|
const root = process.cwd();
|
|
173
|
+
if (app && !fs.existsSync(path.join(root, app, "package.json"))) {
|
|
174
|
+
// Name the folders that are apps, so the fix is a copy away.
|
|
175
|
+
const apps = findApps(root);
|
|
176
|
+
console.error(
|
|
177
|
+
`${app} isn't an app: it has no package.json.${apps.length ? `\nApps here: ${apps.join(", ")}` : ""}`,
|
|
178
|
+
);
|
|
179
|
+
return 2;
|
|
180
|
+
}
|
|
103
181
|
const result = app
|
|
104
182
|
? initApp(path.resolve(root, app), { dryRun })
|
|
105
183
|
: initProject(root, { dryRun });
|
|
@@ -207,21 +285,25 @@ const runPitch = async (argv) => {
|
|
|
207
285
|
brandsDir,
|
|
208
286
|
"--json",
|
|
209
287
|
];
|
|
288
|
+
// The check also captures the card stills, in the customer's brand.
|
|
210
289
|
const check = parseArgs(["check", ...common], process.cwd());
|
|
290
|
+
check.stillsDir = path.join(check.workDir, "stills");
|
|
211
291
|
const checked = runPlaywright(check, "check");
|
|
212
|
-
const
|
|
213
|
-
const report = fs.existsSync(reportPath)
|
|
214
|
-
? JSON.parse(fs.readFileSync(reportPath, "utf8"))
|
|
215
|
-
: { ok: false, error: "the dry run produced no report" };
|
|
216
|
-
finish(check, report.ok && checked === 0);
|
|
292
|
+
const report = readReport(check);
|
|
217
293
|
if (!report.ok || checked !== 0) {
|
|
294
|
+
finish(check, false);
|
|
218
295
|
throw new Error(
|
|
219
296
|
`The researched brand doesn't film with this story: ${report.error}`,
|
|
220
297
|
);
|
|
221
298
|
}
|
|
222
299
|
// A pitch is a draft for one customer: 30 fps, about half the time.
|
|
223
300
|
const render = parseArgs(["render", ...common, "--draft"], process.cwd());
|
|
224
|
-
|
|
301
|
+
const filmed = runPlaywright(
|
|
302
|
+
{ ...render, stillsDir: check.stillsDir },
|
|
303
|
+
"record",
|
|
304
|
+
);
|
|
305
|
+
finish(check, true);
|
|
306
|
+
if (filmed !== 0) {
|
|
225
307
|
finish(render, false);
|
|
226
308
|
throw new Error("Filming the pitch draft failed (see above)");
|
|
227
309
|
}
|
|
@@ -279,14 +361,7 @@ const run = () => {
|
|
|
279
361
|
case "check": {
|
|
280
362
|
const status = runPlaywright(args, "check");
|
|
281
363
|
// The report comes back through the work folder and goes with it.
|
|
282
|
-
const
|
|
283
|
-
const report = fs.existsSync(reportPath)
|
|
284
|
-
? JSON.parse(fs.readFileSync(reportPath, "utf8"))
|
|
285
|
-
: {
|
|
286
|
-
ok: false,
|
|
287
|
-
error:
|
|
288
|
-
"the dry run produced no report (Playwright did not run the demo test — see its output above)",
|
|
289
|
-
};
|
|
364
|
+
const report = readReport(args);
|
|
290
365
|
const ok = report.ok && status === 0;
|
|
291
366
|
finish(args, ok);
|
|
292
367
|
if (args.json) {
|
|
@@ -298,14 +373,14 @@ const run = () => {
|
|
|
298
373
|
}
|
|
299
374
|
case "record": {
|
|
300
375
|
// The recording stays in the work folder for `encode`.
|
|
301
|
-
const status =
|
|
376
|
+
const status = film(args);
|
|
302
377
|
if (status !== 0) {
|
|
303
378
|
finish(args, false);
|
|
304
379
|
}
|
|
305
380
|
return status;
|
|
306
381
|
}
|
|
307
382
|
case "render": {
|
|
308
|
-
const status =
|
|
383
|
+
const status = film(args);
|
|
309
384
|
if (status !== 0) {
|
|
310
385
|
finish(args, false);
|
|
311
386
|
return status;
|
package/bin/init.d.mts
CHANGED
|
@@ -5,6 +5,8 @@ export interface InitResult {
|
|
|
5
5
|
}
|
|
6
6
|
export const IGNORE_LINE: string;
|
|
7
7
|
export const LABEL: string;
|
|
8
|
+
/** How init is run before the package is installed. */
|
|
9
|
+
export const INIT: string;
|
|
8
10
|
export const CALLER_WORKFLOW: string;
|
|
9
11
|
export function initRepo(
|
|
10
12
|
root: string,
|
package/bin/init.mjs
CHANGED
|
@@ -9,6 +9,8 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
// workflow. Safe to re-run; with dryRun it reports what it would change.
|
|
10
10
|
|
|
11
11
|
const PKG = "@navipartner/demo-video";
|
|
12
|
+
/** How init is run before the package is installed (the README's way). */
|
|
13
|
+
export const INIT = `npx --ignore-scripts ${PKG} init`;
|
|
12
14
|
export const IGNORE_LINE = "**/demo/out/";
|
|
13
15
|
export const LABEL = "demo video";
|
|
14
16
|
|
|
@@ -366,7 +368,7 @@ export const initApp = (appDir, { dryRun = false } = {}) => {
|
|
|
366
368
|
const todo = [];
|
|
367
369
|
const pkgFile = path.join(appDir, "package.json");
|
|
368
370
|
if (!fs.existsSync(pkgFile)) {
|
|
369
|
-
throw new Error(
|
|
371
|
+
throw new Error(`${appDir} has no package.json, so it isn't an app.`);
|
|
370
372
|
}
|
|
371
373
|
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
|
|
372
374
|
const version = engineVersion();
|
|
@@ -396,14 +398,19 @@ export const initApp = (appDir, { dryRun = false } = {}) => {
|
|
|
396
398
|
);
|
|
397
399
|
}
|
|
398
400
|
|
|
401
|
+
const configFile = ["ts", "mts", "js", "mjs", "cts", "cjs"]
|
|
402
|
+
.map((ext) => path.join(appDir, `playwright.config.${ext}`))
|
|
403
|
+
.find((f) => fs.existsSync(f));
|
|
404
|
+
if (!configFile) {
|
|
405
|
+
todo.push(
|
|
406
|
+
"Add a playwright.config to this folder: videos run on the app's e2e setup.",
|
|
407
|
+
);
|
|
408
|
+
}
|
|
399
409
|
const demoDir = path.join(appDir, "demo");
|
|
400
410
|
const adapter = path.join(demoDir, "demo.config.ts");
|
|
401
411
|
if (fs.existsSync(adapter)) {
|
|
402
412
|
changes.push(["ok", "demo/demo.config.ts", "exists"]);
|
|
403
413
|
} else {
|
|
404
|
-
const configFile = ["ts", "mts", "js", "mjs"]
|
|
405
|
-
.map((ext) => path.join(appDir, `playwright.config.${ext}`))
|
|
406
|
-
.find((f) => fs.existsSync(f));
|
|
407
414
|
const webServer = configFile ? webServerOf(read(configFile)) : {};
|
|
408
415
|
const skeleton = adapterSkeleton({
|
|
409
416
|
app: (pkg.name ?? path.basename(appDir)).replace(/^@[^/]+\//, ""),
|
|
@@ -492,12 +499,16 @@ export const initProject = (root, options = {}) => {
|
|
|
492
499
|
}
|
|
493
500
|
const todo = [...repo.todo];
|
|
494
501
|
if (apps.length > 1) {
|
|
502
|
+
// Exactly what to run: one line per app, copied as is.
|
|
495
503
|
todo.push(
|
|
496
|
-
|
|
504
|
+
[
|
|
505
|
+
`${apps.length} apps found and none set up yet. Run this for each app you want videos of:`,
|
|
506
|
+
...apps.map((app) => ` ${INIT} --app ${app}`),
|
|
507
|
+
].join("\n"),
|
|
497
508
|
);
|
|
498
509
|
} else {
|
|
499
510
|
todo.push(
|
|
500
|
-
|
|
511
|
+
`No app found (a folder with package.json and playwright.config). Run ${INIT} --app <folder> once it has both.`,
|
|
501
512
|
);
|
|
502
513
|
}
|
|
503
514
|
return { apps, changes: repo.changes, todo };
|
|
@@ -38,6 +38,13 @@ interface VideoBrand {
|
|
|
38
38
|
fontsUrl: string;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* What a story is (look.md): a showcase walks the app's main journey for a
|
|
43
|
+
* buyer (45–90 s); a feature clip shows one feature (15–30 s). The beats are
|
|
44
|
+
* the same; the shape differs.
|
|
45
|
+
*/
|
|
46
|
+
type Kind = "showcase" | "feature";
|
|
47
|
+
|
|
41
48
|
/** A testid (letters, digits, dash, underscore) or a Playwright selector. */
|
|
42
49
|
interface Target {
|
|
43
50
|
target: string;
|
|
@@ -53,6 +60,21 @@ interface CardSpec {
|
|
|
53
60
|
logo?: boolean;
|
|
54
61
|
style: "pale" | "brand";
|
|
55
62
|
seconds: number;
|
|
63
|
+
/** A still of what the chapter achieves, beside the words (a URL). */
|
|
64
|
+
still?: string;
|
|
65
|
+
}
|
|
66
|
+
interface CardOptions {
|
|
67
|
+
/** The next card replaces this one directly, with no app in between. */
|
|
68
|
+
keepUp?: boolean;
|
|
69
|
+
/** The video's open card: already up on the first frame, no dissolve in. */
|
|
70
|
+
opening?: boolean;
|
|
71
|
+
/** The story's kind: how long the open card may stay up. */
|
|
72
|
+
kind?: Kind;
|
|
73
|
+
/**
|
|
74
|
+
* Done while the card covers the app (setup, the chapter's first shot):
|
|
75
|
+
* no cursor, no camera moves, and the card stays up until it's done.
|
|
76
|
+
*/
|
|
77
|
+
behind?: () => Promise<void>;
|
|
56
78
|
}
|
|
57
79
|
interface DirectorApi {
|
|
58
80
|
/** The app's iframe, for actions that talk to the app directly. */
|
|
@@ -75,7 +97,10 @@ interface DirectorApi {
|
|
|
75
97
|
click(target: Target, options?: {
|
|
76
98
|
zoom?: number;
|
|
77
99
|
}): Promise<void>;
|
|
78
|
-
|
|
100
|
+
/** `replace`: select what the field holds first, so the text replaces it. */
|
|
101
|
+
type(target: Target, text: string, perCharMs?: number, options?: {
|
|
102
|
+
replace?: boolean;
|
|
103
|
+
}): Promise<void>;
|
|
79
104
|
press(key: string): Promise<void>;
|
|
80
105
|
park(): Promise<void>;
|
|
81
106
|
waitFor(target: Target, options: {
|
|
@@ -98,12 +123,16 @@ interface DirectorApi {
|
|
|
98
123
|
*/
|
|
99
124
|
clockMs(): number;
|
|
100
125
|
/**
|
|
101
|
-
* Dissolves the card in over the app and holds it
|
|
102
|
-
* unless `keepUp`: then the next
|
|
126
|
+
* Dissolves the card in over the app and holds it, doing `behind` while
|
|
127
|
+
* it covers the app. It dissolves back out unless `keepUp`: then the next
|
|
128
|
+
* card replaces it directly.
|
|
103
129
|
*/
|
|
104
|
-
card(spec: CardSpec, options?:
|
|
105
|
-
|
|
106
|
-
|
|
130
|
+
card(spec: CardSpec, options?: CardOptions): Promise<void>;
|
|
131
|
+
/**
|
|
132
|
+
* Saves a still of the element as it looks now, for a card that shows it.
|
|
133
|
+
* False when it isn't on screen.
|
|
134
|
+
*/
|
|
135
|
+
capture(target: Target, file: string): Promise<boolean>;
|
|
107
136
|
/** Testids currently visible in the app, for failure reports. */
|
|
108
137
|
visibleTestIds(): Promise<string[]>;
|
|
109
138
|
}
|
|
@@ -166,4 +195,4 @@ interface DemoAdapter {
|
|
|
166
195
|
declare const defineDemo: (adapter: DemoAdapter) => DemoAdapter;
|
|
167
196
|
declare const defineAction: <S extends z.ZodRawShape>(action: DemoAction<S>) => DemoAction<S>;
|
|
168
197
|
|
|
169
|
-
export { type Brand as B, type CardSpec as C, type DemoAdapter as D, type Target as T, type VideoBrand as V, type DemoAction as a, type DirectorApi as b, defineDemo as c, defineAction as d };
|
|
198
|
+
export { type Brand as B, type CardSpec as C, type DemoAdapter as D, type Kind as K, type Target as T, type VideoBrand as V, type DemoAction as a, type DirectorApi as b, defineDemo as c, defineAction as d };
|