@navipartner/demo-video 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
 
@@ -30,7 +33,7 @@ video of the app: it writes the adapter and the primary story with you.
30
33
  | Linear | @Linear "… for Northwind (northwind.example)" | the same, in a customer's researched brand |
31
34
  | A PR | the `demo video` label | reviewing the stories the PR adds or changes |
32
35
  | A PR | `/demo-video checkout` (`help` for the menu) | filming any story from the PR's branch |
33
- | Your machine | `pnpm exec demo-video render` | writing and tuning a story |
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 |
34
37
 
35
38
  Requests only pick existing stories; new stories come through a developer's
36
39
  PR. In a PR comment, the first line is the request, a translation is found
@@ -41,8 +44,9 @@ the menu.
41
44
 
42
45
  Ask @Linear in plain words, naming the repo, the story and the customer. In
43
46
  a coding session it follows the skill's `linear.md`, attaches the MP4 and
44
- its contact sheet to the issue, and commits nothing. It runs on Linear's AI
45
- credits; its sandbox is slow, about 15× the video's length at 60 fps.
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.
46
50
 
47
51
  For a customer, the video is a **pitch draft**. @Linear researches their
48
52
  brand from their own website, the engine checks it and films at 30 fps, and
@@ -72,7 +76,7 @@ Run them in the app's folder: `pnpm exec demo-video <command>`.
72
76
  | `init` | sets up a repo or an app |
73
77
  | `list` | the stories, primary first |
74
78
  | `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/` |
79
+ | `render` | the MP4, a contact sheet and ten stills, in `demo/out/` (it checks first when cards show snapshots) |
76
80
  | `record`, `encode`, `stills` | `render` in parts |
77
81
  | `brand-needs` | what a brand must give this app |
78
82
  | `pitch <proposal.json>` | a pitch draft from a researched brand |
@@ -89,8 +93,8 @@ output is kept in `demo/out/failed/`.
89
93
  pnpm test && pnpm typecheck && pnpm lint && pnpm build
90
94
 
91
95
  CI runs the tests, actionlint and a smoke render of the fixture app. To
92
- release, bump `version` in `package.json` on main, then
93
- `git tag vX.Y.Z && git push origin vX.Y.Z`. CI publishes to npm as `latest`
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`
94
98
  through trusted publishing (no npm token) and moves the `v0` tag. Running
95
99
  `release.yml` by hand publishes a `next` prerelease.
96
100
 
@@ -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,91 @@ 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
+ }
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
+ }
68
144
  return;
69
145
  }
70
146
  const where = report.step
@@ -100,6 +176,14 @@ const runInit = (argv) => {
100
176
  return 2;
101
177
  }
102
178
  const root = process.cwd();
179
+ if (app && !fs.existsSync(path.join(root, app, "package.json"))) {
180
+ // Name the folders that are apps, so the fix is a copy away.
181
+ const apps = findApps(root);
182
+ console.error(
183
+ `${app} isn't an app: it has no package.json.${apps.length ? `\nApps here: ${apps.join(", ")}` : ""}`,
184
+ );
185
+ return 2;
186
+ }
103
187
  const result = app
104
188
  ? initApp(path.resolve(root, app), { dryRun })
105
189
  : initProject(root, { dryRun });
@@ -207,21 +291,25 @@ const runPitch = async (argv) => {
207
291
  brandsDir,
208
292
  "--json",
209
293
  ];
294
+ // The check also captures the card stills, in the customer's brand.
210
295
  const check = parseArgs(["check", ...common], process.cwd());
296
+ check.stillsDir = path.join(check.workDir, "stills");
211
297
  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);
298
+ const report = readReport(check);
217
299
  if (!report.ok || checked !== 0) {
300
+ finish(check, false);
218
301
  throw new Error(
219
302
  `The researched brand doesn't film with this story: ${report.error}`,
220
303
  );
221
304
  }
222
305
  // A pitch is a draft for one customer: 30 fps, about half the time.
223
306
  const render = parseArgs(["render", ...common, "--draft"], process.cwd());
224
- if (runPlaywright(render, "record") !== 0) {
307
+ const filmed = runPlaywright(
308
+ { ...render, stillsDir: check.stillsDir },
309
+ "record",
310
+ );
311
+ finish(check, true);
312
+ if (filmed !== 0) {
225
313
  finish(render, false);
226
314
  throw new Error("Filming the pitch draft failed (see above)");
227
315
  }
@@ -279,14 +367,7 @@ const run = () => {
279
367
  case "check": {
280
368
  const status = runPlaywright(args, "check");
281
369
  // 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
- };
370
+ const report = readReport(args);
290
371
  const ok = report.ok && status === 0;
291
372
  finish(args, ok);
292
373
  if (args.json) {
@@ -298,14 +379,14 @@ const run = () => {
298
379
  }
299
380
  case "record": {
300
381
  // The recording stays in the work folder for `encode`.
301
- const status = runPlaywright(args, "record");
382
+ const status = film(args);
302
383
  if (status !== 0) {
303
384
  finish(args, false);
304
385
  }
305
386
  return status;
306
387
  }
307
388
  case "render": {
308
- const status = runPlaywright(args, "record");
389
+ const status = film(args);
309
390
  if (status !== 0) {
310
391
  finish(args, false);
311
392
  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): no cursor, no camera
75
+ * 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: {
@@ -91,19 +116,26 @@ interface DirectorApi {
91
116
  * Runs an adapter action. While filming, time is stopped, so an action that
92
117
  * waits on the app (a scan that fetches) needs frames filmed meanwhile.
93
118
  */
94
- during<T>(work: () => Promise<T>): 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>;
95
123
  /**
96
124
  * The app's clock, in ms: real time in check mode, the filmed (virtual)
97
125
  * time while filming. The idle accounting measures waits with it.
98
126
  */
99
127
  clockMs(): number;
100
128
  /**
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.
129
+ * Dissolves the card in over the app and holds it, doing `behind` while
130
+ * it covers the app. It dissolves back out unless `keepUp`: then the next
131
+ * card replaces it directly.
103
132
  */
104
- card(spec: CardSpec, options?: {
105
- keepUp?: boolean;
106
- }): Promise<void>;
133
+ card(spec: CardSpec, options?: CardOptions): Promise<void>;
134
+ /**
135
+ * Saves a still of the element as it looks now, for a card that shows it.
136
+ * False when it isn't on screen.
137
+ */
138
+ capture(target: Target, file: string): Promise<boolean>;
107
139
  /** Testids currently visible in the app, for failure reports. */
108
140
  visibleTestIds(): Promise<string[]>;
109
141
  }
@@ -111,6 +143,13 @@ interface DirectorApi {
111
143
  interface DemoAction<S extends z.ZodRawShape = z.ZodRawShape> {
112
144
  params: z.ZodObject<S>;
113
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;
114
153
  }
115
154
  /** One thing a brand gives the app: what it is and where it shows. */
116
155
  type BrandNeed = string | {
@@ -166,4 +205,4 @@ interface DemoAdapter {
166
205
  declare const defineDemo: (adapter: DemoAdapter) => DemoAdapter;
167
206
  declare const defineAction: <S extends z.ZodRawShape>(action: DemoAction<S>) => DemoAction<S>;
168
207
 
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 };
208
+ 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 };