@navipartner/demo-video 0.5.1 → 0.6.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 +21 -17
- package/bin/args.d.mts +0 -3
- package/bin/args.mjs +0 -4
- package/bin/demo-video.mjs +4 -6
- package/bin/init.mjs +4 -8
- package/bin/pick.d.mts +1 -3
- package/bin/pick.mjs +65 -118
- 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 +44 -104
- 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
|
@@ -4,7 +4,8 @@ Marketing demo videos of React apps.
|
|
|
4
4
|
|
|
5
5
|
- Filmed on the app's own e2e mocks.
|
|
6
6
|
- NaviPartner look: camera pushes, serif cards.
|
|
7
|
-
-
|
|
7
|
+
- NaviPartner's brand, the only one kept.
|
|
8
|
+
- Customer brands researched per video, never saved.
|
|
8
9
|
- Smooth 60 fps, frame by frame.
|
|
9
10
|
- pnpm repos, GitHub Actions, `@playwright/test` 1.59+.
|
|
10
11
|
|
|
@@ -24,11 +25,12 @@ Marketing demo videos of React apps.
|
|
|
24
25
|
|
|
25
26
|
| Ask | Who | You get |
|
|
26
27
|
|---|---|---|
|
|
27
|
-
| @Linear "video of checkout from np-pos for
|
|
28
|
-
| @Linear "
|
|
28
|
+
| @Linear "video of checkout from np-pos for Northwind (northwind.example)" | Linear users | that story in Northwind's researched brand, attached |
|
|
29
|
+
| @Linear "video of checkout from np-pos" | Linear users | that story, NaviPartner brand, attached |
|
|
29
30
|
| `/demo-video` on a PR | members | primary story, from the PR branch |
|
|
30
|
-
| `/demo-video checkout
|
|
31
|
-
| `/demo-video
|
|
31
|
+
| `/demo-video checkout` | members | that story |
|
|
32
|
+
| `/demo-video checkout in danish` | members | its translation |
|
|
33
|
+
| `/demo-video help` | members | menu of stories |
|
|
32
34
|
| `demo video` label on a PR | whoever labels | every changed story, each push |
|
|
33
35
|
| Actions → Demo video → Run workflow | repo writers | any story, any branch |
|
|
34
36
|
| `pnpm exec demo-video render` | developers | any story, locally |
|
|
@@ -37,14 +39,15 @@ Marketing demo videos of React apps.
|
|
|
37
39
|
- New stories come via PRs.
|
|
38
40
|
- PR comments: first line is the request.
|
|
39
41
|
- Unknown names get the menu.
|
|
42
|
+
- Customer branding only through @Linear.
|
|
40
43
|
|
|
41
44
|
### In Linear
|
|
42
45
|
|
|
43
46
|
- Ask @Linear in plain words.
|
|
44
47
|
- Name the repo, story and customer.
|
|
45
48
|
- Follows the skill's `linear.md`.
|
|
46
|
-
-
|
|
47
|
-
-
|
|
49
|
+
- NaviPartner itself: 60 fps video.
|
|
50
|
+
- A customer: a pitch draft.
|
|
48
51
|
- MP4 and contact sheet attached.
|
|
49
52
|
- Nothing committed, nothing kept.
|
|
50
53
|
- Linear AI credits, no API key.
|
|
@@ -52,22 +55,24 @@ Marketing demo videos of React apps.
|
|
|
52
55
|
|
|
53
56
|
### Pitch drafts
|
|
54
57
|
|
|
55
|
-
-
|
|
56
|
-
-
|
|
58
|
+
- Researched from the customer's own website.
|
|
59
|
+
- Checked, then filmed at 30 fps.
|
|
57
60
|
- Google Fonts stand-ins only.
|
|
58
61
|
- That customer only; check before sending.
|
|
62
|
+
- Other use needs their written permission.
|
|
59
63
|
|
|
60
64
|
## Stories and brands
|
|
61
65
|
|
|
62
66
|
- **Story:** `demo/stories/<name>.json`, scenes as JSON.
|
|
63
67
|
- Built only from the mocks.
|
|
64
68
|
- One story per app is primary.
|
|
65
|
-
- **
|
|
66
|
-
-
|
|
67
|
-
- `navipartner
|
|
68
|
-
-
|
|
69
|
-
-
|
|
70
|
-
-
|
|
69
|
+
- **Translation:** its own story, e.g. `checkout-da.json`.
|
|
70
|
+
- Same flow, cards in that language.
|
|
71
|
+
- **Brands:** only `navipartner`, shipped with the engine.
|
|
72
|
+
- No `demo/brands/` in repos: `check` refuses it.
|
|
73
|
+
- Stories never name a brand.
|
|
74
|
+
- **Brand needs:** the adapter lists what a brand gives.
|
|
75
|
+
- Pitch research fills exactly those.
|
|
71
76
|
- The skill has fields and the look.
|
|
72
77
|
|
|
73
78
|
## Commands
|
|
@@ -88,11 +93,10 @@ Run in the app's folder: `pnpm exec demo-video <command>`.
|
|
|
88
93
|
| Flag | Means |
|
|
89
94
|
|---|---|
|
|
90
95
|
| `--story <name>` | default: primary story |
|
|
91
|
-
| `--brand <name>` | default: `navipartner` |
|
|
92
96
|
| `--draft` | 30 fps, twice as fast |
|
|
93
|
-
| `--brands <dir>` | extra brands folder, searched first |
|
|
94
97
|
| `--app <dir>` | app folder, default `.` |
|
|
95
98
|
| `--json` | machine-readable output |
|
|
99
|
+
| `--brand`, `--brands <dir>` | used by `pitch` only |
|
|
96
100
|
|
|
97
101
|
Output lands in `demo/out/`. Failures keep Playwright's output in `demo/out/failed/`.
|
|
98
102
|
|
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
|
@@ -136,7 +136,7 @@ const runPick = (argv) => {
|
|
|
136
136
|
const arg = argv[i];
|
|
137
137
|
if (arg === "--json") {
|
|
138
138
|
json = true;
|
|
139
|
-
} else if (["--request", "--app", "--story"
|
|
139
|
+
} else if (["--request", "--app", "--story"].includes(arg)) {
|
|
140
140
|
flags[arg.slice(2)] = argv[++i] ?? "";
|
|
141
141
|
} else {
|
|
142
142
|
dirs.push(arg);
|
|
@@ -144,7 +144,7 @@ const runPick = (argv) => {
|
|
|
144
144
|
}
|
|
145
145
|
if (dirs.length === 0) {
|
|
146
146
|
console.error(
|
|
147
|
-
'Usage: demo-video pick [--request "<text>" | --app <a> --story <s>
|
|
147
|
+
'Usage: demo-video pick [--request "<text>" | --app <a> --story <s>] [--json] <app dir>...',
|
|
148
148
|
);
|
|
149
149
|
return 2;
|
|
150
150
|
}
|
|
@@ -165,9 +165,7 @@ const runPick = (argv) => {
|
|
|
165
165
|
} else if (out.menu) {
|
|
166
166
|
console.log(out.markdown);
|
|
167
167
|
} else {
|
|
168
|
-
console.log(
|
|
169
|
-
`${out.app}: story ${out.story}, brand ${out.brand ?? "(the app's default)"}`,
|
|
170
|
-
);
|
|
168
|
+
console.log(`${out.app}: story ${out.story}`);
|
|
171
169
|
}
|
|
172
170
|
return 0;
|
|
173
171
|
};
|
|
@@ -345,7 +343,7 @@ const run = () => {
|
|
|
345
343
|
} else {
|
|
346
344
|
for (const s of stories) {
|
|
347
345
|
console.log(
|
|
348
|
-
`${s.primary ? "* " : " "}${s.name}${s.description ? `: ${s.description}` : ""}
|
|
346
|
+
`${s.primary ? "* " : " "}${s.name}${s.description ? `: ${s.description}` : ""}`,
|
|
349
347
|
);
|
|
350
348
|
}
|
|
351
349
|
}
|
package/bin/init.mjs
CHANGED
|
@@ -22,15 +22,15 @@ 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, or Actions → Demo video → Run workflow,
|
|
26
|
+
# films an existing story in NaviPartner's 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
|
|
33
|
+
# Actions → Demo video → Run workflow: film a story.
|
|
34
34
|
workflow_dispatch:
|
|
35
35
|
inputs:
|
|
36
36
|
app:
|
|
@@ -39,9 +39,6 @@ on:
|
|
|
39
39
|
story:
|
|
40
40
|
description: "Story name (empty: the app's primary story)"
|
|
41
41
|
default: ""
|
|
42
|
-
brand:
|
|
43
|
-
description: "Brand (empty: the app's default)"
|
|
44
|
-
default: ""
|
|
45
42
|
permissions: {}
|
|
46
43
|
jobs:
|
|
47
44
|
render:
|
|
@@ -60,7 +57,6 @@ jobs:
|
|
|
60
57
|
with:
|
|
61
58
|
app: \${{ inputs.app }}
|
|
62
59
|
story: \${{ inputs.story }}
|
|
63
|
-
brand: \${{ inputs.brand }}
|
|
64
60
|
`;
|
|
65
61
|
|
|
66
62
|
const read = (file) =>
|
package/bin/pick.d.mts
CHANGED
|
@@ -4,13 +4,11 @@ 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[] };
|
|
@@ -19,5 +17,5 @@ export function pick(request: string, apps: AppMenu[]): Pick;
|
|
|
19
17
|
export function menuMarkdown(apps: AppMenu[], reason: string): string;
|
|
20
18
|
export function pickExact(
|
|
21
19
|
apps: AppMenu[],
|
|
22
|
-
names: { app?: string; story?: string
|
|
20
|
+
names: { app?: string; story?: string },
|
|
23
21
|
): 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,36 +155,30 @@ 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
|
-
|
|
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}\``);
|
|
168
|
+
}
|
|
169
|
+
if (reason !== LINEAR) {
|
|
170
|
+
lines.push("", LINEAR);
|
|
213
171
|
}
|
|
214
172
|
return lines.join("\n");
|
|
215
173
|
};
|
|
216
174
|
|
|
217
175
|
/**
|
|
218
|
-
* The form's version (Actions → Run workflow): app
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
176
|
+
* The form's version (Actions → Run workflow): app and story by name, no
|
|
177
|
+
* guessing. Empty story = the primary story, empty app = the only app. A
|
|
178
|
+
* name that doesn't exist gets the menu, so a typo never films something
|
|
179
|
+
* else. `apps` in a menu result narrows it.
|
|
222
180
|
*/
|
|
223
|
-
export const pickExact = (apps, { app = "", story = ""
|
|
181
|
+
export const pickExact = (apps, { app = "", story = "" }) => {
|
|
224
182
|
const slashes = (p) => p.split("\\").join("/");
|
|
225
183
|
const wanted = slashes(app).replace(/\/+$/, "");
|
|
226
184
|
const candidates = wanted
|
|
@@ -250,16 +208,5 @@ export const pickExact = (apps, { app = "", story = "", brand = "" }) => {
|
|
|
250
208
|
apps: [chosen],
|
|
251
209
|
};
|
|
252
210
|
}
|
|
253
|
-
|
|
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);
|
|
211
|
+
return { app: chosen.dir, story: found.name };
|
|
265
212
|
};
|
|
@@ -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
|
|
|
@@ -147,8 +147,9 @@ confirms them. A `/demo-video` PR comment, the Run workflow form and a
|
|
|
147
147
|
request to @Linear only pick an existing story and a brand: they never
|
|
148
148
|
write one. A story is
|
|
149
149
|
`<app>/demo/stories/<name>.json`, named for what it shows (`checkout`,
|
|
150
|
-
`settings`), and holds no brand:
|
|
151
|
-
|
|
150
|
+
`settings`), and holds no brand: it films in NaviPartner's brand, or in a
|
|
151
|
+
customer's that @Linear researches for one video. A translation is a story
|
|
152
|
+
of its own (below).
|
|
152
153
|
|
|
153
154
|
{ "app": "<adapter app>",
|
|
154
155
|
"description": "One sentence: what the story shows. Follows e2e/<spec>.",
|
|
@@ -187,11 +188,9 @@ How the look maps onto steps:
|
|
|
187
188
|
press, holds the press, waits for the app to react, settles the camera
|
|
188
189
|
before acting, lingers on a chapter's result before its card, and opens
|
|
189
190
|
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.
|
|
191
|
+
- **Works with any brand:** a pitch draft films the same story in a
|
|
192
|
+
customer's brand. Never target text a brand can change (renamed products,
|
|
193
|
+
an order number): pick products by testid and `nth`, the mock's order.
|
|
195
194
|
- **Safety:** after each navigation, `waitFor` the next screen's testid. Put
|
|
196
195
|
`expectText` on prices and confirmations so the video fails if the copy is
|
|
197
196
|
wrong, and so a card may quote them.
|
|
@@ -216,87 +215,29 @@ The words are what the viewer remembers; write them with care.
|
|
|
216
215
|
- Statements ≤ 7 words, subs ≤ 16 (`check` stops at 8 and 16), sentence
|
|
217
216
|
case, ending with a full stop.
|
|
218
217
|
|
|
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.
|
|
218
|
+
### Brands
|
|
219
|
+
|
|
220
|
+
Repos keep no brands. NaviPartner's ships with the engine, and every video
|
|
221
|
+
in a repo is in it. Never create `demo/brands/`: `check` refuses it.
|
|
222
|
+
|
|
223
|
+
A video in a customer's branding is asked for in Linear: @Linear researches
|
|
224
|
+
their brand for that one video and keeps nothing (follow
|
|
225
|
+
[linear.md](linear.md), and nothing else here, when that's the request).
|
|
226
|
+
|
|
227
|
+
### A translation
|
|
228
|
+
|
|
229
|
+
A story in another language is a story of its own, named for its language:
|
|
230
|
+
`checkout-da.json` beside `checkout.json`.
|
|
231
|
+
|
|
232
|
+
- The same flow as the base, plus whatever switches the app to that
|
|
233
|
+
language (its language button, as the e2e tests do it).
|
|
234
|
+
- Its title, cards and closing in that language, the same rules as any card.
|
|
235
|
+
- `description` says the language ("The checkout, in Danish."). It is never
|
|
236
|
+
`primary`.
|
|
237
|
+
- `/demo-video checkout in danish` (or `checkout da`, `checkout på dansk`)
|
|
238
|
+
picks it.
|
|
239
|
+
- `check --story checkout-da` before the PR, and again when you change the
|
|
240
|
+
base story.
|
|
300
241
|
|
|
301
242
|
### Not supported yet
|
|
302
243
|
|
|
@@ -315,7 +256,8 @@ step for real and fails on anything that would film badly:
|
|
|
315
256
|
| `clicking "x" changes y, which is outside the shot …` | Frame what the click changes, or `zoomOut` right after the click |
|
|
316
257
|
| `N camera push-ins in one chapter` | Push in once on the chapter's area and hold steady |
|
|
317
258
|
| `… only fit in the wide shot` | `frame` things that sit together, or stay wide |
|
|
318
|
-
| `…
|
|
259
|
+
| `… names a brand or extends a story` | Remove it: a customer's branding is @Linear's, a translation is a story of its own |
|
|
260
|
+
| `…/demo/brands isn't used` | Delete the folder: repos keep no brands |
|
|
319
261
|
| `a zoom with nothing in its shot` | Remove it, or act or read something in it |
|
|
320
262
|
| `zoom 2.4; use 1.3–2` | Zoom less: context reads better and stays sharp |
|
|
321
263
|
| `"…" looks like a testid-like name / barcode / code` | Cards are for people: say it in words |
|
|
@@ -326,7 +268,7 @@ Fix what the report names. Its `visible` list shows what is on screen at the
|
|
|
326
268
|
failing step. Repeat until it passes, at most 3 rounds; a story that still
|
|
327
269
|
fails is reported as "couldn't show this", with the last report. Then:
|
|
328
270
|
|
|
329
|
-
pnpm --dir <app> exec demo-video render [--story <name>]
|
|
271
|
+
pnpm --dir <app> exec demo-video render [--story <name>]
|
|
330
272
|
|
|
331
273
|
Rendering films every frame (60 fps, frame by frame), so it takes about
|
|
332
274
|
five times the video's length. While tuning, `render --draft` films at
|
|
@@ -358,7 +300,5 @@ Give:
|
|
|
358
300
|
- the MP4 path and duration
|
|
359
301
|
- the chapter plan
|
|
360
302
|
- 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
303
|
- anything the video could not show truthfully (it shows demo fixture data,
|
|
364
304
|
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).
|