@navipartner/demo-video 0.5.1 → 0.7.0
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 +72 -96
- package/bin/args.d.mts +0 -3
- package/bin/args.mjs +0 -4
- package/bin/demo-video.mjs +11 -17
- package/bin/init.mjs +5 -21
- package/bin/pick.d.mts +0 -6
- package/bin/pick.mjs +59 -151
- package/dist/{adapter-D_iU2dh2.d.ts → adapter-CDOXdUZI.d.ts} +0 -7
- package/dist/{chunk-BYAFDWPA.js → chunk-D7UYGNSF.js} +5 -66
- package/dist/index.d.ts +2 -6
- package/dist/index.js +1 -1
- package/dist/runner/index.d.ts +1 -1
- package/dist/runner/index.js +13 -13
- package/package.json +1 -1
- package/skills/np-demo-video/SKILL.md +46 -107
- package/skills/np-demo-video/linear.md +24 -25
- package/skills/np-demo-video/look.md +2 -4
- package/skills/np-demo-video/reference.md +8 -15
package/README.md
CHANGED
|
@@ -1,128 +1,104 @@
|
|
|
1
1
|
# @navipartner/demo-video
|
|
2
2
|
|
|
3
|
-
Marketing demo videos of React apps
|
|
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
|
|
5
|
+
Playwright e2e mocks, frame by frame at 60 fps, so pans stay smooth. It
|
|
6
|
+
works in pnpm repos on GitHub Actions, with `@playwright/test` 1.59 or later.
|
|
4
7
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
- Smooth 60 fps, frame by frame.
|
|
9
|
-
- pnpm repos, GitHub Actions, `@playwright/test` 1.59+.
|
|
8
|
+
NaviPartner's brand is the only one kept. A video in a customer's branding
|
|
9
|
+
is made in Linear, where @Linear researches their brand for that one video
|
|
10
|
+
and saves nothing.
|
|
10
11
|
|
|
11
12
|
## Setup
|
|
12
13
|
|
|
14
|
+
From the repo root:
|
|
15
|
+
|
|
13
16
|
npx --ignore-scripts @navipartner/demo-video init
|
|
14
17
|
pnpm install
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
- Then ask Claude for a video.
|
|
19
|
+
`init` adds the skill, the `demo video` label and the workflow, and finds
|
|
20
|
+
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.
|
|
22
|
+
It's safe to re-run, and `--dry-run` previews. Then ask Claude for a demo
|
|
23
|
+
video of the app: it writes the adapter and the primary story with you.
|
|
22
24
|
|
|
23
25
|
## Get a video
|
|
24
26
|
|
|
25
|
-
|
|
|
27
|
+
| Where | Ask | For |
|
|
26
28
|
|---|---|---|
|
|
27
|
-
| @Linear "video of checkout from np-pos
|
|
28
|
-
| @Linear "
|
|
29
|
-
|
|
|
30
|
-
| `/demo-video checkout
|
|
31
|
-
|
|
|
32
|
-
| `demo video` label on a PR | whoever labels | every changed story, each push |
|
|
33
|
-
| Actions → Demo video → Run workflow | repo writers | any story, any branch |
|
|
34
|
-
| `pnpm exec demo-video render` | developers | any story, locally |
|
|
35
|
-
|
|
36
|
-
- Requests only pick existing stories.
|
|
37
|
-
- New stories come via PRs.
|
|
38
|
-
- PR comments: first line is the request.
|
|
39
|
-
- Unknown names get the menu.
|
|
29
|
+
| Linear | @Linear "video of checkout from np-pos" | anyone who wants a video of an existing story |
|
|
30
|
+
| Linear | @Linear "… for Northwind (northwind.example)" | the same, in a customer's researched brand |
|
|
31
|
+
| A PR | the `demo video` label | reviewing the stories the PR adds or changes |
|
|
32
|
+
| 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 |
|
|
40
34
|
|
|
41
|
-
|
|
35
|
+
Requests only pick existing stories; new stories come through a developer's
|
|
36
|
+
PR. In a PR comment, the first line is the request, a translation is found
|
|
37
|
+
by its language (`checkout in danish`), and a name that doesn't match gets
|
|
38
|
+
the menu.
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
- Name the repo, story and customer.
|
|
45
|
-
- Follows the skill's `linear.md`.
|
|
46
|
-
- Brand in the repo: 60 fps video.
|
|
47
|
-
- No brand yet: a pitch draft.
|
|
48
|
-
- MP4 and contact sheet attached.
|
|
49
|
-
- Nothing committed, nothing kept.
|
|
50
|
-
- Linear AI credits, no API key.
|
|
51
|
-
- Slow sandbox: ~15× video length.
|
|
40
|
+
### In Linear
|
|
52
41
|
|
|
53
|
-
|
|
42
|
+
Ask @Linear in plain words, naming the repo, the story and the customer. In
|
|
43
|
+
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.
|
|
54
46
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
47
|
+
For a customer, the video is a **pitch draft**. @Linear researches their
|
|
48
|
+
brand from their own website, the engine checks it and films at 30 fps, and
|
|
49
|
+
the brand is deleted afterwards. Fonts are Google Fonts stand-ins. A pitch
|
|
50
|
+
draft is for that customer only: check it before sending it, and any other
|
|
51
|
+
use needs their written permission.
|
|
59
52
|
|
|
60
53
|
## Stories and brands
|
|
61
54
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
-
|
|
55
|
+
A story is `demo/stories/<name>.json`: the scenes of one journey, built only
|
|
56
|
+
from what the e2e mocks return. One story per app is its primary story. A
|
|
57
|
+
translation is a story of its own, such as `checkout-da.json`, with the same
|
|
58
|
+
flow and its cards in that language.
|
|
59
|
+
|
|
60
|
+
Repos keep no brands. `navipartner` ships with the engine, `check` refuses a
|
|
61
|
+
`demo/brands/` folder, and stories never name a brand. The adapter's
|
|
62
|
+
`brandNeeds` lists what a brand gives the app (a background image, a
|
|
63
|
+
secondary colour), which is exactly what a pitch draft researches. The skill
|
|
64
|
+
(`skills/np-demo-video/`) has every field and the look.
|
|
72
65
|
|
|
73
66
|
## Commands
|
|
74
67
|
|
|
75
|
-
Run in the app's folder: `pnpm exec demo-video <command>`.
|
|
68
|
+
Run them in the app's folder: `pnpm exec demo-video <command>`.
|
|
76
69
|
|
|
77
70
|
| Command | Does |
|
|
78
71
|
|---|---|
|
|
79
|
-
| `init` | sets up a repo or app |
|
|
80
|
-
| `list` | stories, primary first |
|
|
81
|
-
| `check` | dry run in seconds
|
|
82
|
-
| `render` | MP4, contact sheet
|
|
83
|
-
| `record
|
|
84
|
-
| `brand-needs` | what a brand must give |
|
|
85
|
-
| `pitch <proposal.json>` | pitch draft from a researched brand |
|
|
86
|
-
| `pick` | what a request means (workflows) |
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
| `--app <dir>` | app folder, default `.` |
|
|
95
|
-
| `--json` | machine-readable output |
|
|
96
|
-
|
|
97
|
-
Output lands in `demo/out/`. Failures keep Playwright's output in `demo/out/failed/`.
|
|
98
|
-
|
|
99
|
-
## Develop
|
|
72
|
+
| `init` | sets up a repo or an app |
|
|
73
|
+
| `list` | the stories, primary first |
|
|
74
|
+
| `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/` |
|
|
76
|
+
| `record`, `encode`, `stills` | `render` in parts |
|
|
77
|
+
| `brand-needs` | what a brand must give this app |
|
|
78
|
+
| `pitch <proposal.json>` | a pitch draft from a researched brand |
|
|
79
|
+
| `pick` | what a request means (used by the workflows) |
|
|
80
|
+
|
|
81
|
+
`--story <name>` picks a story (default: the primary one), `--draft` films
|
|
82
|
+
at 30 fps in about half the time, `--app <dir>` names the app folder and
|
|
83
|
+
`--json` gives machine-readable output. When something fails, Playwright's
|
|
84
|
+
output is kept in `demo/out/failed/`.
|
|
85
|
+
|
|
86
|
+
## Develop and release
|
|
100
87
|
|
|
101
88
|
pnpm install
|
|
102
89
|
pnpm test && pnpm typecheck && pnpm lint && pnpm build
|
|
103
90
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
2. `git tag vX.Y.Z && git push origin vX.Y.Z`.
|
|
110
|
-
|
|
111
|
-
- CI publishes to npm (`latest`).
|
|
112
|
-
- Trusted publishing, no npm token.
|
|
113
|
-
- `v0` tag moves along.
|
|
114
|
-
- Manual `release.yml` run: `next` prerelease.
|
|
115
|
-
|
|
116
|
-
## Public package, private repo
|
|
117
|
-
|
|
118
|
-
- `check-public.mjs` checks every tarball.
|
|
119
|
-
- Allowed files only, no source maps.
|
|
120
|
-
- No secrets, emails or local paths.
|
|
121
|
-
- Names aren't caught: keep customers out.
|
|
91
|
+
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`
|
|
94
|
+
through trusted publishing (no npm token) and moves the `v0` tag. Running
|
|
95
|
+
`release.yml` by hand publishes a `next` prerelease.
|
|
122
96
|
|
|
123
|
-
|
|
97
|
+
The package is public; this repo is not. `scripts/check-public.mjs` checks
|
|
98
|
+
every tarball for files outside the allowlist, source maps, secrets, emails
|
|
99
|
+
and local paths. It can't catch names, so keep customers and people out of
|
|
100
|
+
everything that ships.
|
|
124
101
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
- Breaking that means `v1`.
|
|
102
|
+
Projects pin the CLI in their lockfile, but their workflows follow `v0` at
|
|
103
|
+
once. So `render.yml` and `request.yml` must work with every CLI from 0.4;
|
|
104
|
+
anything that can't is `v1`.
|
package/bin/args.d.mts
CHANGED
|
@@ -27,9 +27,6 @@ export interface StoryEntry {
|
|
|
27
27
|
name: string;
|
|
28
28
|
description: string | null;
|
|
29
29
|
primary: boolean;
|
|
30
|
-
/** A variant: the story it extends, and the brand it is for. */
|
|
31
|
-
extends?: string;
|
|
32
|
-
brand?: string | null;
|
|
33
30
|
}
|
|
34
31
|
export function listStories(demoDir: string): StoryEntry[];
|
|
35
32
|
export function resolveStoryboard(
|
package/bin/args.mjs
CHANGED
|
@@ -62,10 +62,6 @@ export const listStories = (demoDir) => {
|
|
|
62
62
|
name: f.slice(0, -".json".length),
|
|
63
63
|
description: story.description ?? null,
|
|
64
64
|
primary: story.primary === true,
|
|
65
|
-
// A variant: the base story's flow, for one brand.
|
|
66
|
-
...(story.extends
|
|
67
|
-
? { extends: story.extends, brand: story.brand ?? null }
|
|
68
|
-
: {}),
|
|
69
65
|
};
|
|
70
66
|
})
|
|
71
67
|
.sort(
|
package/bin/demo-video.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import path from "node:path";
|
|
|
9
9
|
import { listStories, parseArgs } from "./args.mjs";
|
|
10
10
|
import { encodeRecording, extractStillsFrom } from "./encode.mjs";
|
|
11
11
|
import { initApp, initProject } from "./init.mjs";
|
|
12
|
-
import { appMenu, menuMarkdown, pick
|
|
12
|
+
import { appMenu, menuMarkdown, pick } from "./pick.mjs";
|
|
13
13
|
import { buildPitchBrand } from "./pitch.mjs";
|
|
14
14
|
import { writeRunner } from "./runner.mjs";
|
|
15
15
|
|
|
@@ -124,35 +124,31 @@ const runInit = (argv) => {
|
|
|
124
124
|
return 0;
|
|
125
125
|
};
|
|
126
126
|
|
|
127
|
-
// demo-video pick [--request "<text>"
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
// flags are exact names (the Run workflow form).
|
|
127
|
+
// demo-video pick [--request "<text>"] [--json] <app dir>...: which existing
|
|
128
|
+
// story a request means (see pick.mjs): a /demo-video comment, or a request
|
|
129
|
+
// to @Linear. No request: the primary story.
|
|
131
130
|
const runPick = (argv) => {
|
|
132
|
-
|
|
131
|
+
let request = "";
|
|
133
132
|
const dirs = [];
|
|
134
133
|
let json = false;
|
|
135
134
|
for (let i = 0; i < argv.length; i++) {
|
|
136
135
|
const arg = argv[i];
|
|
137
136
|
if (arg === "--json") {
|
|
138
137
|
json = true;
|
|
139
|
-
} else if (
|
|
140
|
-
|
|
138
|
+
} else if (arg === "--request") {
|
|
139
|
+
request = argv[++i] ?? "";
|
|
141
140
|
} else {
|
|
142
141
|
dirs.push(arg);
|
|
143
142
|
}
|
|
144
143
|
}
|
|
145
144
|
if (dirs.length === 0) {
|
|
146
145
|
console.error(
|
|
147
|
-
'Usage: demo-video pick [--request "<text>"
|
|
146
|
+
'Usage: demo-video pick [--request "<text>"] [--json] <app dir>...',
|
|
148
147
|
);
|
|
149
148
|
return 2;
|
|
150
149
|
}
|
|
151
150
|
const apps = dirs.map((d) => appMenu(path.resolve(d)));
|
|
152
|
-
const result =
|
|
153
|
-
flags.request !== undefined
|
|
154
|
-
? pick(flags.request, apps)
|
|
155
|
-
: pickExact(apps, flags);
|
|
151
|
+
const result = pick(request, apps);
|
|
156
152
|
const out = result.menu
|
|
157
153
|
? {
|
|
158
154
|
menu: true,
|
|
@@ -165,9 +161,7 @@ const runPick = (argv) => {
|
|
|
165
161
|
} else if (out.menu) {
|
|
166
162
|
console.log(out.markdown);
|
|
167
163
|
} else {
|
|
168
|
-
console.log(
|
|
169
|
-
`${out.app}: story ${out.story}, brand ${out.brand ?? "(the app's default)"}`,
|
|
170
|
-
);
|
|
164
|
+
console.log(`${out.app}: story ${out.story}`);
|
|
171
165
|
}
|
|
172
166
|
return 0;
|
|
173
167
|
};
|
|
@@ -345,7 +339,7 @@ const run = () => {
|
|
|
345
339
|
} else {
|
|
346
340
|
for (const s of stories) {
|
|
347
341
|
console.log(
|
|
348
|
-
`${s.primary ? "* " : " "}${s.name}${s.description ? `: ${s.description}` : ""}
|
|
342
|
+
`${s.primary ? "* " : " "}${s.name}${s.description ? `: ${s.description}` : ""}`,
|
|
349
343
|
);
|
|
350
344
|
}
|
|
351
345
|
}
|
package/bin/init.mjs
CHANGED
|
@@ -22,26 +22,14 @@ const engineVersion = () =>
|
|
|
22
22
|
|
|
23
23
|
export const CALLER_WORKFLOW = `name: Demo video
|
|
24
24
|
# The "${LABEL}" label films the stories a PR adds or changes. A comment
|
|
25
|
-
# "/demo-video [<story>]
|
|
26
|
-
#
|
|
27
|
-
# @Linear (the skill's linear.md)
|
|
25
|
+
# "/demo-video [<story>]" on a PR films an existing story in NaviPartner's
|
|
26
|
+
# brand. A customer's branding: ask
|
|
27
|
+
# @Linear (the skill's linear.md); repos keep no brands.
|
|
28
28
|
on:
|
|
29
29
|
pull_request:
|
|
30
30
|
types: [labeled, synchronize]
|
|
31
31
|
issue_comment:
|
|
32
32
|
types: [created]
|
|
33
|
-
# Actions → Demo video → Run workflow: film a story with a brand.
|
|
34
|
-
workflow_dispatch:
|
|
35
|
-
inputs:
|
|
36
|
-
app:
|
|
37
|
-
description: "App folder, e.g. apps/retail-self-service (empty: the only app)"
|
|
38
|
-
default: ""
|
|
39
|
-
story:
|
|
40
|
-
description: "Story name (empty: the app's primary story)"
|
|
41
|
-
default: ""
|
|
42
|
-
brand:
|
|
43
|
-
description: "Brand (empty: the app's default)"
|
|
44
|
-
default: ""
|
|
45
33
|
permissions: {}
|
|
46
34
|
jobs:
|
|
47
35
|
render:
|
|
@@ -51,16 +39,12 @@ jobs:
|
|
|
51
39
|
pull-requests: write
|
|
52
40
|
uses: navipartner/demo-video/.github/workflows/render.yml@v0
|
|
53
41
|
request:
|
|
54
|
-
if: github.event_name == '
|
|
42
|
+
if: github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/demo-video')
|
|
55
43
|
permissions:
|
|
56
44
|
contents: read
|
|
57
45
|
pull-requests: write
|
|
58
46
|
issues: write
|
|
59
47
|
uses: navipartner/demo-video/.github/workflows/request.yml@v0
|
|
60
|
-
with:
|
|
61
|
-
app: \${{ inputs.app }}
|
|
62
|
-
story: \${{ inputs.story }}
|
|
63
|
-
brand: \${{ inputs.brand }}
|
|
64
48
|
`;
|
|
65
49
|
|
|
66
50
|
const read = (file) =>
|
|
@@ -261,7 +245,7 @@ export const initRepo = (root, { dryRun = false, label = true } = {}) => {
|
|
|
261
245
|
.find(Boolean);
|
|
262
246
|
if (existing && !existing.includes("request.yml@")) {
|
|
263
247
|
todo.push(
|
|
264
|
-
'.github/workflows/demo-video.yml predates the "request" job (/demo-video comments
|
|
248
|
+
'.github/workflows/demo-video.yml predates the "request" job (/demo-video comments): delete it and re-run init to get the current one.',
|
|
265
249
|
);
|
|
266
250
|
}
|
|
267
251
|
} else {
|
package/bin/pick.d.mts
CHANGED
|
@@ -4,20 +4,14 @@ export interface AppMenu {
|
|
|
4
4
|
dir: string;
|
|
5
5
|
name: string;
|
|
6
6
|
stories: StoryEntry[];
|
|
7
|
-
brands: string[];
|
|
8
7
|
}
|
|
9
8
|
export type Pick =
|
|
10
9
|
| {
|
|
11
10
|
app: string;
|
|
12
11
|
story: string;
|
|
13
|
-
brand: string | null;
|
|
14
12
|
menu?: undefined;
|
|
15
13
|
}
|
|
16
14
|
| { menu: true; reason: string; apps?: AppMenu[] };
|
|
17
15
|
export function appMenu(appDir: string): AppMenu;
|
|
18
16
|
export function pick(request: string, apps: AppMenu[]): Pick;
|
|
19
17
|
export function menuMarkdown(apps: AppMenu[], reason: string): string;
|
|
20
|
-
export function pickExact(
|
|
21
|
-
apps: AppMenu[],
|
|
22
|
-
names: { app?: string; story?: string; brand?: string },
|
|
23
|
-
): Pick;
|
package/bin/pick.mjs
CHANGED
|
@@ -1,27 +1,23 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
1
|
import path from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
2
|
|
|
5
3
|
import { listStories } from "./args.mjs";
|
|
6
4
|
|
|
7
5
|
// `demo-video pick`: turns a `/demo-video …` request into one of the
|
|
8
|
-
// already-made stories
|
|
9
|
-
//
|
|
6
|
+
// already-made stories. No AI and nothing written: stories are a developer's
|
|
7
|
+
// to make (locally, with the skill); a request only chooses. Every video is
|
|
8
|
+
// in NaviPartner's brand: a customer's is researched by @Linear for one video
|
|
9
|
+
// (the skill's linear.md), never kept in a repo.
|
|
10
10
|
//
|
|
11
|
-
// /demo-video
|
|
12
|
-
// /demo-video help
|
|
13
|
-
// /demo-video checkout
|
|
14
|
-
// /demo-video
|
|
11
|
+
// /demo-video → the primary story (one app)
|
|
12
|
+
// /demo-video help → the menu
|
|
13
|
+
// /demo-video checkout → story "checkout"
|
|
14
|
+
// /demo-video checkout in danish → its translation "checkout-da"
|
|
15
|
+
// /demo-video retail checkout → the app whose folder says retail
|
|
15
16
|
//
|
|
16
17
|
// Anything it can't settle on one story is answered with the menu.
|
|
17
18
|
|
|
18
|
-
const engineDir = path.resolve(
|
|
19
|
-
path.dirname(fileURLToPath(import.meta.url)),
|
|
20
|
-
"..",
|
|
21
|
-
);
|
|
22
|
-
|
|
23
19
|
const STOP = new Set(
|
|
24
|
-
"a an and as at by for from in into make me of on or our please show story the this to video with branding brand marketing demo film render".split(
|
|
20
|
+
"a an and as at by for from in into make me of on or our please show story the this to video with branding brand marketing demo film render navipartner på".split(
|
|
25
21
|
" ",
|
|
26
22
|
),
|
|
27
23
|
);
|
|
@@ -31,44 +27,46 @@ const words = (text) =>
|
|
|
31
27
|
(w) => [w, ...(w.includes("-") ? w.split("-") : [])],
|
|
32
28
|
);
|
|
33
29
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
30
|
+
// A translation is a story named for its language (checkout-da): "danish"
|
|
31
|
+
// and "dansk" say its "da" too.
|
|
32
|
+
const languageNames = (part) => {
|
|
33
|
+
if (!/^[a-z]{2}$/.test(part)) {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
return [
|
|
38
|
+
new Intl.DisplayNames(["en"], { type: "language" }).of(part),
|
|
39
|
+
new Intl.DisplayNames([part], { type: "language" }).of(part),
|
|
40
|
+
]
|
|
41
|
+
.filter((name) => name && name.toLowerCase() !== part)
|
|
42
|
+
.map((name) => name.toLowerCase());
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
49
46
|
};
|
|
50
47
|
|
|
51
|
-
/** What an app offers: its stories
|
|
48
|
+
/** What an app offers: its stories. */
|
|
52
49
|
export const appMenu = (appDir) => ({
|
|
53
50
|
dir: appDir,
|
|
54
51
|
name: path.basename(appDir),
|
|
55
52
|
stories: listStories(path.join(appDir, "demo")),
|
|
56
|
-
brands: [
|
|
57
|
-
...new Set([
|
|
58
|
-
...brandNames(adapterBrandsDir(appDir)),
|
|
59
|
-
...brandNames(path.join(engineDir, "brands")),
|
|
60
|
-
]),
|
|
61
|
-
],
|
|
62
53
|
});
|
|
63
54
|
|
|
55
|
+
const LINEAR =
|
|
56
|
+
"A customer's branding isn't kept in a repo: in Linear, ask @Linear for the video, and it researches their brand for that one video.";
|
|
57
|
+
|
|
64
58
|
/**
|
|
65
|
-
* Picks from `apps` (appMenu results). Returns { app, story
|
|
66
|
-
*
|
|
59
|
+
* Picks from `apps` (appMenu results). Returns { app, story } or
|
|
60
|
+
* { menu: true, reason }.
|
|
67
61
|
*/
|
|
68
62
|
export const pick = (request, apps) => {
|
|
69
63
|
// A bare /demo-video films the primary story; this asks what there is.
|
|
70
64
|
if (/^\s*(help|menu|list)\s*$/i.test(request)) {
|
|
71
|
-
return { menu: true, reason: "The stories
|
|
65
|
+
return { menu: true, reason: "The stories:" };
|
|
66
|
+
}
|
|
67
|
+
const branded = /(?:^|\s)in\s+([\w-]+)\s+brand(?:ing)?\b/i.exec(request);
|
|
68
|
+
if (branded && branded[1].toLowerCase() !== "navipartner") {
|
|
69
|
+
return { menu: true, reason: LINEAR };
|
|
72
70
|
}
|
|
73
71
|
return pickStory(request, apps);
|
|
74
72
|
};
|
|
@@ -76,22 +74,10 @@ export const pick = (request, apps) => {
|
|
|
76
74
|
const pickStory = (request, apps) => {
|
|
77
75
|
const said = words(request);
|
|
78
76
|
const has = (word) => said.includes(word.toLowerCase());
|
|
77
|
+
const saysPart = (part) => has(part) || languageNames(part).some(has);
|
|
79
78
|
|
|
80
|
-
//
|
|
81
|
-
const
|
|
82
|
-
const brands = allBrands.filter(has);
|
|
83
|
-
if (brands.length > 1) {
|
|
84
|
-
return {
|
|
85
|
-
menu: true,
|
|
86
|
-
reason: `Name one brand, not ${brands.join(" and ")}.`,
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
const brand = brands[0] ?? null;
|
|
90
|
-
|
|
91
|
-
// What's left of the request once the brand and filler are gone.
|
|
92
|
-
const content = said.filter(
|
|
93
|
-
(w) => !STOP.has(w) && !brands.includes(w) && !/^\d+$/.test(w),
|
|
94
|
-
);
|
|
79
|
+
// What's left of the request once the filler is gone.
|
|
80
|
+
const content = said.filter((w) => !STOP.has(w) && !/^\d+$/.test(w));
|
|
95
81
|
|
|
96
82
|
// The app: named by its folder, else by a part of it no other app's
|
|
97
83
|
// folder has ("retail", not "self"), else the only one.
|
|
@@ -114,23 +100,18 @@ const pickStory = (request, apps) => {
|
|
|
114
100
|
}
|
|
115
101
|
}
|
|
116
102
|
|
|
117
|
-
// Score every story: its name (or
|
|
103
|
+
// Score every story: its name (or its parts) said outright counts most,
|
|
118
104
|
// then words shared with its description.
|
|
119
|
-
// Variants aren't scored: the base story plus the brand finds them.
|
|
120
105
|
const scored = candidates.flatMap((app) =>
|
|
121
|
-
app.stories
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
(w) => w.length > 2 && description.has(w),
|
|
131
|
-
).length;
|
|
132
|
-
return { app, story, score };
|
|
133
|
-
}),
|
|
106
|
+
app.stories.map((story) => {
|
|
107
|
+
const nameParts = story.name.split("-");
|
|
108
|
+
// "checkout express" says the story checkout-express in full.
|
|
109
|
+
let score = has(story.name) || nameParts.every(saysPart) ? 10 : 0;
|
|
110
|
+
score += nameParts.filter(saysPart).length * 3;
|
|
111
|
+
const description = new Set(words(story.description ?? ""));
|
|
112
|
+
score += content.filter((w) => w.length > 2 && description.has(w)).length;
|
|
113
|
+
return { app, story, score };
|
|
114
|
+
}),
|
|
134
115
|
);
|
|
135
116
|
const best = Math.max(0, ...scored.map((s) => s.score));
|
|
136
117
|
const top = scored.filter((s) => s.score === best);
|
|
@@ -147,7 +128,7 @@ const pickStory = (request, apps) => {
|
|
|
147
128
|
.map((app) => ({ app, story: app.stories.find((s) => s.primary) }))
|
|
148
129
|
.filter((p) => p.story);
|
|
149
130
|
if (noFeature && candidates.length === 1 && primaries.length === 1) {
|
|
150
|
-
return
|
|
131
|
+
return { app: primaries[0].app.dir, story: primaries[0].story.name };
|
|
151
132
|
}
|
|
152
133
|
return {
|
|
153
134
|
menu: true,
|
|
@@ -163,24 +144,7 @@ const pickStory = (request, apps) => {
|
|
|
163
144
|
reason: `That matches ${top.map((t) => `\`${t.story.name}\``).join(", ")}. Pick one by name:`,
|
|
164
145
|
};
|
|
165
146
|
}
|
|
166
|
-
return
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
// The brand must be one the chosen app has: another app's brand would fail
|
|
170
|
-
// at render time with a worse message. A story with a variant for that brand
|
|
171
|
-
// (checkout + acme → checkout.acme) films the variant: its words are the
|
|
172
|
-
// customer's.
|
|
173
|
-
const withBrand = (app, story, brand) => {
|
|
174
|
-
if (brand && !app.brands.includes(brand)) {
|
|
175
|
-
return {
|
|
176
|
-
menu: true,
|
|
177
|
-
reason: `No brand \`${brand}\` in ${app.name}. Pick one:`,
|
|
178
|
-
apps: [app],
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
const variant =
|
|
182
|
-
brand && app.stories.find((s) => s.extends === story && s.brand === brand);
|
|
183
|
-
return { app: app.dir, story: variant ? variant.name : story, brand };
|
|
147
|
+
return { app: top[0].app.dir, story: top[0].story.name };
|
|
184
148
|
};
|
|
185
149
|
|
|
186
150
|
/** The menu as Markdown, for the PR reply. */
|
|
@@ -191,75 +155,19 @@ export const menuMarkdown = (apps, reason) => {
|
|
|
191
155
|
if (app.stories.length === 0) {
|
|
192
156
|
lines.push("- no stories yet");
|
|
193
157
|
}
|
|
194
|
-
for (const s of app.stories
|
|
158
|
+
for (const s of app.stories) {
|
|
195
159
|
lines.push(
|
|
196
160
|
`- \`${s.name}\`${s.primary ? " (primary)" : ""}${s.description ? `: ${s.description}` : ""}`,
|
|
197
161
|
);
|
|
198
|
-
for (const v of app.stories.filter((x) => x.extends === s.name)) {
|
|
199
|
-
lines.push(` - in \`${v.brand}\` branding: \`${v.name}\``);
|
|
200
|
-
}
|
|
201
162
|
}
|
|
202
|
-
lines.push(
|
|
163
|
+
lines.push("");
|
|
203
164
|
}
|
|
204
|
-
const example = apps.find((a) => a.stories.
|
|
165
|
+
const example = apps.find((a) => a.stories.length > 0);
|
|
205
166
|
if (example) {
|
|
206
|
-
lines.push(
|
|
207
|
-
`For example: \`/demo-video ${example.stories.find((s) => !s.extends).name}${example.brands.length > 1 ? ` in ${example.brands.find((b) => b !== "navipartner") ?? example.brands[0]} branding` : ""}\``,
|
|
208
|
-
);
|
|
209
|
-
lines.push(
|
|
210
|
-
"",
|
|
211
|
-
"In Linear, ask @Linear instead, also for a customer without a brand here (a pitch draft).",
|
|
212
|
-
);
|
|
167
|
+
lines.push(`For example: \`/demo-video ${example.stories[0].name}\``);
|
|
213
168
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* The form's version (Actions → Run workflow): app, story and brand by name,
|
|
219
|
-
* no guessing. Empty story = the primary story, empty brand = the adapter's
|
|
220
|
-
* default, empty app = the only app. A name that doesn't exist gets the menu,
|
|
221
|
-
* so a typo never films something else. `apps` in a menu result narrows it.
|
|
222
|
-
*/
|
|
223
|
-
export const pickExact = (apps, { app = "", story = "", brand = "" }) => {
|
|
224
|
-
const slashes = (p) => p.split("\\").join("/");
|
|
225
|
-
const wanted = slashes(app).replace(/\/+$/, "");
|
|
226
|
-
const candidates = wanted
|
|
227
|
-
? apps.filter((a) => {
|
|
228
|
-
const dir = slashes(a.dir);
|
|
229
|
-
return (
|
|
230
|
-
a.name === wanted || dir === wanted || dir.endsWith(`/${wanted}`)
|
|
231
|
-
);
|
|
232
|
-
})
|
|
233
|
-
: apps;
|
|
234
|
-
if (candidates.length === 0) {
|
|
235
|
-
return { menu: true, reason: `No app \`${wanted}\`. The apps:` };
|
|
169
|
+
if (reason !== LINEAR) {
|
|
170
|
+
lines.push("", LINEAR);
|
|
236
171
|
}
|
|
237
|
-
|
|
238
|
-
return { menu: true, reason: "Which app? Fill in App with one of:" };
|
|
239
|
-
}
|
|
240
|
-
const chosen = candidates[0];
|
|
241
|
-
const found = story
|
|
242
|
-
? chosen.stories.find((s) => s.name === story)
|
|
243
|
-
: chosen.stories.find((s) => s.primary);
|
|
244
|
-
if (!found) {
|
|
245
|
-
return {
|
|
246
|
-
menu: true,
|
|
247
|
-
reason: story
|
|
248
|
-
? `No story \`${story}\` in ${chosen.name}. Pick one:`
|
|
249
|
-
: `${chosen.name} has no primary story. Pick one:`,
|
|
250
|
-
apps: [chosen],
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
if (found.extends) {
|
|
254
|
-
// A variant is made for one brand.
|
|
255
|
-
if (brand && brand !== found.brand) {
|
|
256
|
-
return {
|
|
257
|
-
menu: true,
|
|
258
|
-
reason: `\`${found.name}\` is made for \`${found.brand}\`. Leave Brand empty, or pick \`${found.extends}\`:`,
|
|
259
|
-
apps: [chosen],
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
return { app: chosen.dir, story: found.name, brand: found.brand };
|
|
263
|
-
}
|
|
264
|
-
return withBrand(chosen, found.name, brand || null);
|
|
172
|
+
return lines.join("\n");
|
|
265
173
|
};
|
|
@@ -152,13 +152,6 @@ interface DemoAdapter {
|
|
|
152
152
|
setup(page: Page, ctx: {
|
|
153
153
|
brand: Brand;
|
|
154
154
|
}): Promise<void>;
|
|
155
|
-
/**
|
|
156
|
-
* The app's own brands folder, relative to demo/ (default "./brands").
|
|
157
|
-
* Searched before the brands shipped with the engine (navipartner, ...).
|
|
158
|
-
*/
|
|
159
|
-
brands?: string;
|
|
160
|
-
/** Default "navipartner". */
|
|
161
|
-
defaultBrand?: string;
|
|
162
155
|
/**
|
|
163
156
|
* What this app takes from a brand, each with what it is and where it
|
|
164
157
|
* shows ("full-screen photo behind the menu, landscape"). Apps differ: one
|
|
@@ -502,76 +502,15 @@ var parseStoryboard = (raw, adapter, source = "storyboard") => {
|
|
|
502
502
|
throw new StoryboardError(`Invalid ${source}:
|
|
503
503
|
${lines.join("\n")}`);
|
|
504
504
|
};
|
|
505
|
-
var cardChange = z.object({
|
|
506
|
-
eyebrow: cardFields.eyebrow,
|
|
507
|
-
heading: cardFields.heading,
|
|
508
|
-
sub: cardFields.sub,
|
|
509
|
-
logo: cardFields.logo,
|
|
510
|
-
style: z.enum(["pale", "brand"]).optional(),
|
|
511
|
-
seconds: z.number().min(0.5).max(10).optional()
|
|
512
|
-
}).strict();
|
|
513
|
-
var variantSchema = z.object({
|
|
514
|
-
extends: z.string().regex(/^[\w-]+$/, "the base story's name"),
|
|
515
|
-
brand: z.string().regex(/^[\w-]+$/, "a brand name"),
|
|
516
|
-
description: z.string().min(1).max(280).optional(),
|
|
517
|
-
title: cardChange.optional(),
|
|
518
|
-
closing: cardChange.optional(),
|
|
519
|
-
cards: z.array(cardChange).optional()
|
|
520
|
-
}).strict();
|
|
521
505
|
var readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
|
522
|
-
var
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const result = variantSchema.safeParse(raw);
|
|
526
|
-
if (!result.success) {
|
|
527
|
-
const lines = result.error.issues.map(
|
|
528
|
-
(i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`
|
|
529
|
-
);
|
|
530
|
-
throw new StoryboardError(`Invalid ${name}:
|
|
531
|
-
${lines.join("\n")}`);
|
|
532
|
-
}
|
|
533
|
-
const variant = result.data;
|
|
534
|
-
const baseFile = path.join(path.dirname(file), `${variant.extends}.json`);
|
|
535
|
-
if (!fs.existsSync(baseFile)) {
|
|
536
|
-
throw new StoryboardError(
|
|
537
|
-
`${name} extends "${variant.extends}", but there is no ${variant.extends}.json beside it`
|
|
538
|
-
);
|
|
539
|
-
}
|
|
540
|
-
const base = readJson(baseFile);
|
|
541
|
-
if (isVariant(base)) {
|
|
542
|
-
throw new StoryboardError(
|
|
543
|
-
`${name} extends ${variant.extends}.json, which is a variant itself; extend the story it extends`
|
|
544
|
-
);
|
|
545
|
-
}
|
|
546
|
-
const steps = Array.isArray(base.steps) ? base.steps : [];
|
|
547
|
-
const cardAt = steps.flatMap((s, i) => s.do === "card" ? [i] : []);
|
|
548
|
-
if (variant.cards && variant.cards.length !== cardAt.length) {
|
|
506
|
+
var loadStoryboard = (file, adapter) => {
|
|
507
|
+
const raw = readJson(file);
|
|
508
|
+
if (typeof raw === "object" && raw !== null && ("extends" in raw || "brand" in raw)) {
|
|
549
509
|
throw new StoryboardError(
|
|
550
|
-
`${
|
|
510
|
+
`${path.basename(file)} names a brand or extends a story: stories hold neither. A customer's brand is researched by @Linear for one video; a translation is a story of its own (checkout-da.json).`
|
|
551
511
|
);
|
|
552
512
|
}
|
|
553
|
-
|
|
554
|
-
const merged = steps.map(
|
|
555
|
-
(step, i) => variant.cards ? change(step, variant.cards[cardAt.indexOf(i)]) : step
|
|
556
|
-
);
|
|
557
|
-
const rest = { ...base };
|
|
558
|
-
delete rest.primary;
|
|
559
|
-
const story = parseStoryboard(
|
|
560
|
-
{
|
|
561
|
-
...rest,
|
|
562
|
-
description: variant.description ?? base.description,
|
|
563
|
-
title: change(base.title, variant.title),
|
|
564
|
-
closing: change(base.closing, variant.closing),
|
|
565
|
-
steps: merged
|
|
566
|
-
},
|
|
567
|
-
adapter,
|
|
568
|
-
`${name} (on ${variant.extends}.json)`
|
|
569
|
-
);
|
|
570
|
-
return { ...story, brand: variant.brand, variantOf: variant.extends };
|
|
571
|
-
};
|
|
572
|
-
var loadStoryboard = (file, adapter) => {
|
|
573
|
-
const raw = readJson(file);
|
|
574
|
-
return isVariant(raw) ? loadVariant(raw, file, adapter) : parseStoryboard(raw, adapter, path.basename(file));
|
|
513
|
+
return parseStoryboard(raw, adapter, path.basename(file));
|
|
575
514
|
};
|
|
576
515
|
var MIN_ZOOM = MIN_SCALE;
|
|
577
516
|
var MAX_ZOOM = MAX_SCALE;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { 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 { D as DemoAdapter } from './adapter-CDOXdUZI.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-CDOXdUZI.js';
|
|
3
3
|
import { Page } from '@playwright/test';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
export { z } from 'zod';
|
|
@@ -236,10 +236,6 @@ declare const cardSchema: z.ZodEffects<z.ZodObject<{
|
|
|
236
236
|
type Card = z.infer<typeof cardSchema>;
|
|
237
237
|
interface Storyboard {
|
|
238
238
|
app: string;
|
|
239
|
-
/** A variant's brand (see loadStoryboard): it is filmed with this one only. */
|
|
240
|
-
brand?: string;
|
|
241
|
-
/** The story a variant extends. */
|
|
242
|
-
variantOf?: string;
|
|
243
239
|
/** What the story shows, for people choosing one (and the menu). */
|
|
244
240
|
description?: string;
|
|
245
241
|
/** The app's primary journey: the story filmed when none is named. One per app. */
|
package/dist/index.js
CHANGED
package/dist/runner/index.d.ts
CHANGED
package/dist/runner/index.js
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
unionRect,
|
|
31
31
|
visibleRect,
|
|
32
32
|
wideShot
|
|
33
|
-
} from "../chunk-
|
|
33
|
+
} from "../chunk-D7UYGNSF.js";
|
|
34
34
|
|
|
35
35
|
// src/runner/config.ts
|
|
36
36
|
import path from "path";
|
|
@@ -135,10 +135,17 @@ var engineRoot = () => {
|
|
|
135
135
|
}
|
|
136
136
|
};
|
|
137
137
|
var sharedBrandsDir = () => path2.join(engineRoot(), "brands");
|
|
138
|
-
var brandDirs = (
|
|
139
|
-
|
|
138
|
+
var brandDirs = (pitchDir) => [
|
|
139
|
+
...pitchDir ? [pitchDir] : [],
|
|
140
140
|
sharedBrandsDir()
|
|
141
141
|
];
|
|
142
|
+
var refuseRepoBrands = (demoDir) => {
|
|
143
|
+
if (fs.existsSync(path2.join(demoDir, "brands"))) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`${path2.join(demoDir, "brands")} isn't used: repos keep no brands. NaviPartner's ships with the engine; a customer's is researched by @Linear for one video and never saved. Delete the folder.`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
142
149
|
var brandNames = (dir) => fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -".json".length)) : [];
|
|
143
150
|
var loadBrand = (dirs, name) => {
|
|
144
151
|
if (!NAME_RE.test(name)) {
|
|
@@ -1196,17 +1203,10 @@ var registerDemoTests = (test, adapter, demoDir) => {
|
|
|
1196
1203
|
].join("\n")
|
|
1197
1204
|
);
|
|
1198
1205
|
}
|
|
1199
|
-
|
|
1200
|
-
throw new StoryboardError(
|
|
1201
|
-
`${path4.basename(env.storyboardPath, ".json")} is ${storyboard.variantOf} for the brand ${storyboard.brand}. Render it without --brand, or render ${storyboard.variantOf} with --brand ${env.brand}.`
|
|
1202
|
-
);
|
|
1203
|
-
}
|
|
1206
|
+
refuseRepoBrands(demoDir);
|
|
1204
1207
|
const brand = loadBrand(
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
...brandDirs(demoDir, adapter.brands)
|
|
1208
|
-
],
|
|
1209
|
-
env.brand ?? storyboard.brand ?? adapter.defaultBrand ?? "navipartner"
|
|
1208
|
+
brandDirs(env.brandsDir),
|
|
1209
|
+
env.brand ?? "navipartner"
|
|
1210
1210
|
);
|
|
1211
1211
|
const missing = missingNeeds(brand, adapter.brandNeeds);
|
|
1212
1212
|
if (missing.length > 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: np-demo-video
|
|
3
|
-
description: Produce or change a marketing demo video of a NaviPartner React app (retail self-checkout, food-order kiosk, or any app with a demo/ adapter) in the house look, which is calm camera pushes, serif statement cards and customer branding, recorded by the engine @navipartner/demo-video. Use when asked for a product demo video, a showcase of a flow, a marketing or SoMe clip, a
|
|
3
|
+
description: Produce or change a marketing demo video of a NaviPartner React app (retail self-checkout, food-order kiosk, or any app with a demo/ adapter) in the house look, which is calm camera pushes, serif statement cards and customer branding, recorded by the engine @navipartner/demo-video. Use when asked for a product demo video, a showcase of a flow, a marketing or SoMe clip, a translation of a story into another language, a video in a customer's branding asked for in Linear (e.g. "video of checkout for Northwind (northwind.example)"; follow linear.md), or to re-record a demo after UI changes.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# NaviPartner demo video
|
|
@@ -18,7 +18,8 @@ workflow.
|
|
|
18
18
|
From the request, take:
|
|
19
19
|
- **Product:** which app.
|
|
20
20
|
- **Chapters:** the 1–4 ideas to show.
|
|
21
|
-
- **Brand:**
|
|
21
|
+
- **Brand:** NaviPartner's, always, in a repo. A customer's branding is
|
|
22
|
+
@Linear's: it researches their brand for one video (linear.md).
|
|
22
23
|
- **Data:** the products, names and amounts the request mentions ("two
|
|
23
24
|
waters").
|
|
24
25
|
|
|
@@ -28,8 +29,7 @@ Card text defaults to English. Ask only for what you cannot infer.
|
|
|
28
29
|
|
|
29
30
|
Every app has one **primary story**: the journey the app exists for, from
|
|
30
31
|
start to the moment of value. It is the app's first story, and the one filmed
|
|
31
|
-
when a request names no feature ("make a video of the QR app"
|
|
32
|
-
the customer's branding").
|
|
32
|
+
when a request names no feature ("make a video of the QR app").
|
|
33
33
|
|
|
34
34
|
| App | Primary story | Ends on |
|
|
35
35
|
|---|---|---|
|
|
@@ -51,8 +51,8 @@ two. In the PR, say which spec the story follows, so the developer can confirm
|
|
|
51
51
|
it is the right journey.
|
|
52
52
|
|
|
53
53
|
Run `pnpm --dir <app> exec demo-video list` first. If a primary story exists and the
|
|
54
|
-
request names no feature, render it (`pnpm --dir <app> exec demo-video render` without `--story
|
|
55
|
-
|
|
54
|
+
request names no feature, render it (`pnpm --dir <app> exec demo-video render` without `--story`)
|
|
55
|
+
instead of writing a new one. For a
|
|
56
56
|
repo with several apps and no app named, ask which app, or offer each app's
|
|
57
57
|
primary story. A PR never films it by default; only stories a PR adds or
|
|
58
58
|
changes are filmed there.
|
|
@@ -78,10 +78,10 @@ Check it against look.md:
|
|
|
78
78
|
- Does every card describe exactly what the chapter after it shows?
|
|
79
79
|
- Does the hook land by second 6?
|
|
80
80
|
|
|
81
|
-
Example, retail self-checkout
|
|
81
|
+
Example, retail self-checkout:
|
|
82
82
|
|
|
83
83
|
```
|
|
84
|
-
open Introducing ·
|
|
84
|
+
open Introducing · NaviPartner logo · sub "Self-checkout"
|
|
85
85
|
ch1 sees: a guest scans two waters and they land in the basket
|
|
86
86
|
shots: wide for the scans (the rows appear at the top);
|
|
87
87
|
push in 1.5 on the basket rows to read them
|
|
@@ -91,7 +91,7 @@ ch2 sees: the guest pays by card and the payment goes through
|
|
|
91
91
|
pull out when the payment screen opens
|
|
92
92
|
card: "Pay *by card*."
|
|
93
93
|
payoff "Paid *in seconds*." · sub "64,50 kr. by card." (expectText on the total)
|
|
94
|
-
end
|
|
94
|
+
end NaviPartner logo · "Self-checkout by NaviPartner"
|
|
95
95
|
```
|
|
96
96
|
|
|
97
97
|
## 3. Learn the app
|
|
@@ -110,9 +110,9 @@ end Acme logo · "Self-checkout by NaviPartner"
|
|
|
110
110
|
with an absurdly long name or an error item.
|
|
111
111
|
- If the request needs data the e2e tests don't have, say so instead of
|
|
112
112
|
inventing it.
|
|
113
|
-
- One exception: a
|
|
114
|
-
`config.demoData
|
|
115
|
-
new barcodes, prices or flows.
|
|
113
|
+
- One exception: a pitch draft's brand may rename the mock's products
|
|
114
|
+
in `config.demoData`, when the adapter takes it (a `brandNeeds` entry).
|
|
115
|
+
It gives them names only, never new barcodes, prices or flows.
|
|
116
116
|
|
|
117
117
|
### A new app
|
|
118
118
|
|
|
@@ -143,12 +143,12 @@ end Acme logo · "Self-checkout by NaviPartner"
|
|
|
143
143
|
## 4. Write the story
|
|
144
144
|
|
|
145
145
|
A developer writes stories locally (VS Code, with this skill) and a PR
|
|
146
|
-
confirms them. A `/demo-video` PR comment
|
|
147
|
-
|
|
148
|
-
write one. A story is
|
|
146
|
+
confirms them. A `/demo-video` PR comment and a request to @Linear only
|
|
147
|
+
pick an existing story: they never write one. A story is
|
|
149
148
|
`<app>/demo/stories/<name>.json`, named for what it shows (`checkout`,
|
|
150
|
-
`settings`), and holds no brand:
|
|
151
|
-
|
|
149
|
+
`settings`), and holds no brand: it films in NaviPartner's brand, or in a
|
|
150
|
+
customer's that @Linear researches for one video. A translation is a story
|
|
151
|
+
of its own (below).
|
|
152
152
|
|
|
153
153
|
{ "app": "<adapter app>",
|
|
154
154
|
"description": "One sentence: what the story shows. Follows e2e/<spec>.",
|
|
@@ -187,11 +187,9 @@ How the look maps onto steps:
|
|
|
187
187
|
press, holds the press, waits for the app to react, settles the camera
|
|
188
188
|
before acting, lingers on a chapter's result before its card, and opens
|
|
189
189
|
every chapter wide with the cursor at rest. Don't add holds for those.
|
|
190
|
-
- **Works with
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
`nth`, the mock's order. Run `check` without `--brand` as well as with the
|
|
194
|
-
brand you're writing for.
|
|
190
|
+
- **Works with any brand:** a pitch draft films the same story in a
|
|
191
|
+
customer's brand. Never target text a brand can change (renamed products,
|
|
192
|
+
an order number): pick products by testid and `nth`, the mock's order.
|
|
195
193
|
- **Safety:** after each navigation, `waitFor` the next screen's testid. Put
|
|
196
194
|
`expectText` on prices and confirmations so the video fails if the copy is
|
|
197
195
|
wrong, and so a card may quote them.
|
|
@@ -216,87 +214,29 @@ The words are what the viewer remembers; write them with care.
|
|
|
216
214
|
- Statements ≤ 7 words, subs ≤ 16 (`check` stops at 8 and 16), sentence
|
|
217
215
|
case, ending with a full stop.
|
|
218
216
|
|
|
219
|
-
###
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
A customer's
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
-
|
|
241
|
-
|
|
242
|
-
it only when their brand guide allows that colour version).
|
|
243
|
-
- **Fonts:** only a font whose licence covers use in a video. Google Fonts are
|
|
244
|
-
open-licensed and fine. A customer's own or commercial font is used only if
|
|
245
|
-
they confirm their licence covers it; otherwise take the closest Google
|
|
246
|
-
Font and say so in the report. Never copy font files from their website.
|
|
247
|
-
- **No claims for them:** cards state what the video shows. No customer
|
|
248
|
-
quotes, statistics or "trusted by" lines, and a closing card says what the
|
|
249
|
-
product is, not that the customer endorses it.
|
|
250
|
-
- **Publishing:** a customer-branded video goes to the customer for approval
|
|
251
|
-
before it is published anywhere. Say so in the report.
|
|
252
|
-
- **`config`:** only keys the app's branding code accepts. Override every
|
|
253
|
-
value the e2e mock sets that would show through, such as a mock
|
|
254
|
-
`backgroundColor`.
|
|
255
|
-
- **`config.demoData`** (when the adapter supports it): the customer's names
|
|
256
|
-
for the mock's products, keyed by the mock's barcodes. Mock data such as
|
|
257
|
-
"Toothpaste" or "TEST CARD" reads as a test run in a customer video. Use
|
|
258
|
-
plain product names ("Lemonade"), or ones the customer gave you, never a
|
|
259
|
-
third party's brand ("Coca-Cola"). Keep the mock's prices, so pick
|
|
260
|
-
products that are believable at those prices, and put the totals on the
|
|
261
|
-
cards from the render, not from your own sums.
|
|
262
|
-
- **App limits:** an app may only offer presets (retail has two font pairs).
|
|
263
|
-
Pick the closest and say in the report what the kiosk can't show.
|
|
264
|
-
- **`video` fonts** load from Google Fonts: the heading in 400, 700 and
|
|
265
|
-
italic 400, the body in 400 and 600. If a font lacks one, give `fontsUrl`
|
|
266
|
-
yourself.
|
|
267
|
-
- Render with `--brand <name>` and check the stills before calling it done.
|
|
268
|
-
|
|
269
|
-
### Videos asked for in Linear
|
|
270
|
-
|
|
271
|
-
Asked for a video in a Linear session (an existing story in a brand the repo
|
|
272
|
-
has, or a **pitch draft** for a customer without one): follow
|
|
273
|
-
[linear.md](linear.md) and nothing else here. A pitch writes nothing to the repo, and its researched
|
|
274
|
-
brand is not a brand: never commit it or copy its values into one. When the
|
|
275
|
-
customer agrees to more, make a real brand with the rules above.
|
|
276
|
-
|
|
277
|
-
### A variant: the story in a customer's words
|
|
278
|
-
|
|
279
|
-
When a customer video needs its own words (their product, their place), write
|
|
280
|
-
a variant beside the story instead of a copy: `checkout.acme.json`.
|
|
281
|
-
|
|
282
|
-
{ "extends": "checkout",
|
|
283
|
-
"brand": "acme",
|
|
284
|
-
"description": "The checkout story, for Acme.",
|
|
285
|
-
"title": { "sub": "Self-service at Acme" },
|
|
286
|
-
"closing": { "sub": "…" },
|
|
287
|
-
"cards": [ { "heading": "A burger *and a lemonade*." }, { … }, { … } ] }
|
|
288
|
-
|
|
289
|
-
- The steps are always the base story's: a variant changes words and brand,
|
|
290
|
-
never the flow. Fix the flow in the base, once, for every variant.
|
|
291
|
-
- `cards` gives every card step of the base, in order; each entry, like
|
|
292
|
-
`title` and `closing`, changes only the fields it names. When the base
|
|
293
|
-
gains or loses a card, the variant stops loading until it is updated.
|
|
294
|
-
- It films with its own brand only. `/demo-video checkout in acme branding`
|
|
295
|
-
and the form (story `checkout`, brand `acme`) pick the variant.
|
|
296
|
-
- The same rules as any card: numbers proven by the base's `expectText`s,
|
|
297
|
-
and the customer's permission for their name (see "A new brand").
|
|
298
|
-
- `check --story checkout.acme` before the PR, and re-check the variants
|
|
299
|
-
when you change their base.
|
|
217
|
+
### Brands
|
|
218
|
+
|
|
219
|
+
Repos keep no brands. NaviPartner's ships with the engine, and every video
|
|
220
|
+
in a repo is in it. Never create `demo/brands/`: `check` refuses it.
|
|
221
|
+
|
|
222
|
+
A video in a customer's branding is asked for in Linear: @Linear researches
|
|
223
|
+
their brand for that one video and keeps nothing (follow
|
|
224
|
+
[linear.md](linear.md), and nothing else here, when that's the request).
|
|
225
|
+
|
|
226
|
+
### A translation
|
|
227
|
+
|
|
228
|
+
A story in another language is a story of its own, named for its language:
|
|
229
|
+
`checkout-da.json` beside `checkout.json`.
|
|
230
|
+
|
|
231
|
+
- The same flow as the base, plus whatever switches the app to that
|
|
232
|
+
language (its language button, as the e2e tests do it).
|
|
233
|
+
- Its title, cards and closing in that language, the same rules as any card.
|
|
234
|
+
- `description` says the language ("The checkout, in Danish."). It is never
|
|
235
|
+
`primary`.
|
|
236
|
+
- `/demo-video checkout in danish` (or `checkout da`, `checkout på dansk`)
|
|
237
|
+
picks it.
|
|
238
|
+
- `check --story checkout-da` before the PR, and again when you change the
|
|
239
|
+
base story.
|
|
300
240
|
|
|
301
241
|
### Not supported yet
|
|
302
242
|
|
|
@@ -315,7 +255,8 @@ step for real and fails on anything that would film badly:
|
|
|
315
255
|
| `clicking "x" changes y, which is outside the shot …` | Frame what the click changes, or `zoomOut` right after the click |
|
|
316
256
|
| `N camera push-ins in one chapter` | Push in once on the chapter's area and hold steady |
|
|
317
257
|
| `… only fit in the wide shot` | `frame` things that sit together, or stay wide |
|
|
318
|
-
| `…
|
|
258
|
+
| `… names a brand or extends a story` | Remove it: a customer's branding is @Linear's, a translation is a story of its own |
|
|
259
|
+
| `…/demo/brands isn't used` | Delete the folder: repos keep no brands |
|
|
319
260
|
| `a zoom with nothing in its shot` | Remove it, or act or read something in it |
|
|
320
261
|
| `zoom 2.4; use 1.3–2` | Zoom less: context reads better and stays sharp |
|
|
321
262
|
| `"…" looks like a testid-like name / barcode / code` | Cards are for people: say it in words |
|
|
@@ -326,7 +267,7 @@ Fix what the report names. Its `visible` list shows what is on screen at the
|
|
|
326
267
|
failing step. Repeat until it passes, at most 3 rounds; a story that still
|
|
327
268
|
fails is reported as "couldn't show this", with the last report. Then:
|
|
328
269
|
|
|
329
|
-
pnpm --dir <app> exec demo-video render [--story <name>]
|
|
270
|
+
pnpm --dir <app> exec demo-video render [--story <name>]
|
|
330
271
|
|
|
331
272
|
Rendering films every frame (60 fps, frame by frame), so it takes about
|
|
332
273
|
five times the video's length. While tuning, `render --draft` films at
|
|
@@ -358,7 +299,5 @@ Give:
|
|
|
358
299
|
- the MP4 path and duration
|
|
359
300
|
- the chapter plan
|
|
360
301
|
- the contact sheet and stills
|
|
361
|
-
- which brand it was rendered with, and for a customer brand, that the
|
|
362
|
-
video needs the customer's approval before it is published
|
|
363
302
|
- anything the video could not show truthfully (it shows demo fixture data,
|
|
364
303
|
not a real tenant's)
|
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
# A video asked for in Linear
|
|
2
2
|
|
|
3
3
|
Someone asks @Linear for a video, in their own words: "make a video of the
|
|
4
|
-
checkout story for
|
|
5
|
-
|
|
4
|
+
checkout story from np-pos for Northwind (northwind.example)". You film one
|
|
5
|
+
of the app's **existing** stories:
|
|
6
6
|
|
|
7
|
-
- **
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
customer only.
|
|
7
|
+
- **For NaviPartner itself:** a normal video at 60 fps.
|
|
8
|
+
- **For a customer:** a **pitch draft**. Repos keep no brands, so you
|
|
9
|
+
research theirs, the engine checks it and films at 30 fps, for a sales
|
|
10
|
+
pitch to that customer only. Nothing about them is saved.
|
|
12
11
|
|
|
13
12
|
## Hard rules
|
|
14
13
|
|
|
15
14
|
- **Nothing is saved.** Never commit, push or open a pull request. Never
|
|
16
|
-
change a story, the app
|
|
15
|
+
change a story, the app or its mocks, and never add a brand to the repo. Everything you
|
|
17
16
|
make lives in `/tmp/pitch` or `demo/out/` and is deleted at the end.
|
|
18
17
|
- **An existing story, unchanged.** If no story fits the request, say so and
|
|
19
18
|
list the stories. Don't write one.
|
|
20
|
-
- **
|
|
19
|
+
- **The customer's own material only.** Find their official
|
|
21
20
|
website (use the one named, if any). Take the logo, colours and images
|
|
22
21
|
from it: the site itself, its brand or press page, or the CDN the site
|
|
23
22
|
loads its own images from. No logo databases, no other companies' sites,
|
|
@@ -38,19 +37,17 @@ repo root when the app is the repo, otherwise the folder with
|
|
|
38
37
|
(without `--with-deps`: a sandbox has the system packages and no root),
|
|
39
38
|
then `mkdir -p /tmp/pitch`.
|
|
40
39
|
2. **See what there is:** `pnpm exec demo-video pick --request help .` lists
|
|
41
|
-
the stories
|
|
42
|
-
3. **Pick the story
|
|
43
|
-
`pnpm exec demo-video pick --json --request "<story>
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
meanwhile. It prints the MP4 (`output`) and the contact sheet (`sheet`).
|
|
53
|
-
Go to step 7.
|
|
40
|
+
the stories.
|
|
41
|
+
3. **Pick the story:**
|
|
42
|
+
`pnpm exec demo-video pick --json --request "<story>" .` with the story
|
|
43
|
+
the request names, and its language if it asks for one ("checkout in
|
|
44
|
+
danish" picks the translation `checkout-da`). No story named: leave it
|
|
45
|
+
out, for the primary story. It answers `{ "story" }`, or a menu if the
|
|
46
|
+
request matches none.
|
|
47
|
+
4. **For NaviPartner itself:**
|
|
48
|
+
`pnpm exec demo-video render --story <story> --json`. At 60 fps this
|
|
49
|
+
takes about 15× the video's length and prints nothing meanwhile. It
|
|
50
|
+
prints the MP4 (`output`) and the contact sheet (`sheet`). Go to step 7.
|
|
54
51
|
5. **A pitch draft: research the brand.**
|
|
55
52
|
- Read what the app needs:
|
|
56
53
|
`pnpm exec demo-video brand-needs --json > /tmp/pitch/needs.json`.
|
|
@@ -68,6 +65,9 @@ repo root when the app is the repo, otherwise the folder with
|
|
|
68
65
|
image is a direct https URL from the customer's own site that fits
|
|
69
66
|
its `what`. Other keys take the same kind of value as in `example`.
|
|
70
67
|
Give optional ones only when their material has them.
|
|
68
|
+
- **`config.demoData`** (only if the app's needs list it): plain names
|
|
69
|
+
for the mock's products, never a third party's brand ("Coca-Cola"),
|
|
70
|
+
believable at the mock's prices.
|
|
71
71
|
6. **A pitch draft: film it.**
|
|
72
72
|
`pnpm exec demo-video pitch /tmp/pitch/proposal.json --story <story> --json`.
|
|
73
73
|
It checks the proposal, downloads the images, checks the story with the
|
|
@@ -80,12 +80,11 @@ repo root when the app is the repo, otherwise the folder with
|
|
|
80
80
|
well, the app looks branded. Film again only if something is clearly
|
|
81
81
|
wrong.
|
|
82
82
|
8. **Deliver:** attach the MP4 and the contact sheet to the issue, with a
|
|
83
|
-
line saying which story
|
|
84
|
-
also:
|
|
83
|
+
line saying which story and how long. For a pitch draft, also:
|
|
85
84
|
> Pitch draft for <customer> only. The brand was researched from their own
|
|
86
85
|
> material (<sources>) and isn't kept anywhere. Fonts are Google Fonts
|
|
87
86
|
> stand-ins. Check the video before sending it: any other use needs
|
|
88
|
-
> <customer>'s permission
|
|
87
|
+
> <customer>'s written permission.
|
|
89
88
|
|
|
90
89
|
Add your notes: what you couldn't find and what you used instead.
|
|
91
90
|
9. **Clean up:** `rm -rf /tmp/pitch demo/out`. Then `git status` must show
|
|
@@ -112,10 +112,8 @@ end card 3–5 s logo + tagline, still
|
|
|
112
112
|
colour. The brand's logo and wordmark are on the open and end cards. The
|
|
113
113
|
app itself is shown in the customer's branding (their kiosk looks like
|
|
114
114
|
their kiosk).
|
|
115
|
-
- **
|
|
116
|
-
customer's
|
|
117
|
-
SKILL.md), swaps the logo, colours, fonts, tagline and data (their
|
|
118
|
-
products, their language), following the same roles.
|
|
115
|
+
- **Brand:** NaviPartner's (below). A pitch draft (linear.md) swaps in a
|
|
116
|
+
customer's researched logo, colours and fonts, following the same roles.
|
|
119
117
|
|
|
120
118
|
### NaviPartner brand
|
|
121
119
|
|
|
@@ -7,8 +7,7 @@ the mocks booted the app), `viewport` (default 1728×972; a portrait kiosk is
|
|
|
7
7
|
scaled to fit) and `setup(page, { brand })`, which runs the app's e2e mocks
|
|
8
8
|
**unchanged** and lays the brand over the response that carries it.
|
|
9
9
|
Optional: `actions` (app steps such as a barcode scan), `idleTimeoutSeconds`,
|
|
10
|
-
`
|
|
11
|
-
and `brandNeeds`: the `config` keys and asset slots `setup` uses, each a
|
|
10
|
+
`locale`, `timezone`, and `brandNeeds`: the `config` keys and asset slots `setup` uses, each a
|
|
12
11
|
description (where it shows, what shape) or `{ what, optional: true }`. A
|
|
13
12
|
brand without a need that isn't optional fails `check`.
|
|
14
13
|
|
|
@@ -68,25 +67,19 @@ export default defineDemo({
|
|
|
68
67
|
beats around clicks, the camera's settle and a linger before each card.
|
|
69
68
|
Holds may not add up past the app's idle timeout.
|
|
70
69
|
|
|
71
|
-
##
|
|
72
|
-
|
|
73
|
-
`extends` (the base story's name), `brand`, optional `description`, `title`,
|
|
74
|
-
`closing` and `cards` (one entry per card step of the base, in order). Each
|
|
75
|
-
card entry takes the card fields and changes only those it names. No
|
|
76
|
-
`steps`, no `primary`: the flow is the base's. Filmed with its brand only.
|
|
77
|
-
|
|
78
|
-
## Brand (`<app>/demo/brands/<name>.json`, assets in `<name>/`)
|
|
70
|
+
## Brand (the engine's `navipartner`, or one a pitch draft builds)
|
|
79
71
|
|
|
80
72
|
- `tenantName`
|
|
81
73
|
- `config`: opaque to the engine, the keys the app's branding API reads
|
|
82
|
-
(`primaryColor`, …). An adapter may also read `config.demoData`:
|
|
83
|
-
|
|
84
|
-
|
|
74
|
+
(`primaryColor`, …). An adapter may also read `config.demoData`: names
|
|
75
|
+
for the mock's products, keyed by the mock's barcodes (prices and flows
|
|
76
|
+
stay the mock's). It takes `demoData` out before it
|
|
85
77
|
overlays the branding.
|
|
86
|
-
- `assets`: slot → file, e.g. `{ "logo": "
|
|
78
|
+
- `assets`: slot → file, e.g. `{ "logo": "navipartner/logo.svg" }`
|
|
87
79
|
- `video`: `primary`, `ink`, `accent`, `text`, `headingFont`, `bodyFont`,
|
|
88
80
|
optional `fontsUrl`, `logo` (asset slot)
|
|
89
81
|
- optional `logoColor` (recolours white fills in an SVG logo) and
|
|
90
82
|
`stage.background`
|
|
91
83
|
|
|
92
|
-
|
|
84
|
+
Repos keep no brands: `check` refuses a `demo/brands/` folder. A pitch
|
|
85
|
+
draft's brand is built by `demo-video pitch` from its proposal (linear.md).
|