@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 CHANGED
@@ -1,7 +1,9 @@
1
1
  # @navipartner/demo-video
2
2
 
3
- Marketing demo videos of React apps, in the NaviPartner look: calm camera
4
- pushes and serif statement cards. The engine films an app on its own
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, run `init --app <folder>` for each one you want.
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 |
@@ -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 reportPath = path.join(check.workDir, "check.json");
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
- if (runPlaywright(render, "record") !== 0) {
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 reportPath = path.join(args.workDir, "check.json");
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 = runPlaywright(args, "record");
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 = runPlaywright(args, "record");
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(`No package.json in ${appDir}`);
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
- `This repo has ${apps.length} apps with Playwright e2e tests: ${apps.join(", ")}. Set up each one you want videos of with init --app <folder>.`,
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
- "No app with a playwright.config found: the demo films an app on its Playwright e2e tests. Add them, then run init again (or init --app <folder>).",
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
- type(target: Target, text: string, perCharMs?: number): Promise<void>;
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. It dissolves back out
102
- * unless `keepUp`: then the next card replaces it directly.
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
- keepUp?: boolean;
106
- }): Promise<void>;
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 };