@navipartner/demo-video 0.0.0-stage → 0.4.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 +109 -3
- package/bin/args.d.mts +30 -0
- package/bin/args.mjs +153 -0
- package/bin/demo-video.mjs +272 -0
- package/bin/encode.mjs +187 -0
- package/bin/init.d.mts +24 -0
- package/bin/init.mjs +447 -0
- package/bin/pick.d.mts +18 -0
- package/bin/pick.mjs +252 -0
- package/bin/runner.d.mts +5 -0
- package/bin/runner.mjs +47 -0
- package/brands/navipartner/logo.svg +19 -0
- package/brands/navipartner.json +22 -0
- package/dist/adapter-CWqMoNdw.d.ts +157 -0
- package/dist/chunk-BYAFDWPA.js +768 -0
- package/dist/index.d.ts +255 -0
- package/dist/index.js +56 -0
- package/dist/runner/index.d.ts +23 -0
- package/dist/runner/index.js +1258 -0
- package/package.json +67 -3
- package/skills/np-demo-video/SKILL.md +348 -0
- package/skills/np-demo-video/look.md +158 -0
- package/skills/np-demo-video/reference.md +82 -0
package/bin/encode.mjs
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Turns the webm Playwright recorded into an MP4, then extracts review stills.
|
|
2
|
+
// Used by bin/demo-video.mjs (encode, stills, render). Reads <out>/manifest.json
|
|
3
|
+
// written by the runner for the webm path and the start/end offsets that trim
|
|
4
|
+
// the load-in and the tail. Plain JS on purpose: no TS loader is needed.
|
|
5
|
+
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
import ffmpegPath from "ffmpeg-static";
|
|
11
|
+
|
|
12
|
+
const STILLS = 10;
|
|
13
|
+
// One frame a second, six to a row: the whole video's pacing at a glance.
|
|
14
|
+
const SHEET_COLUMNS = 6;
|
|
15
|
+
|
|
16
|
+
const run = (args) => {
|
|
17
|
+
const result = spawnSync(
|
|
18
|
+
ffmpegPath,
|
|
19
|
+
["-hide_banner", "-loglevel", "error", "-y", ...args],
|
|
20
|
+
{ stdio: "inherit" },
|
|
21
|
+
);
|
|
22
|
+
if (result.status !== 0) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`ffmpeg failed (${result.status}): ffmpeg ${args.join(" ")}`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ffmpeg-static ships no ffprobe; decode to null and read the final timestamp
|
|
30
|
+
// from ffmpeg's own progress output instead.
|
|
31
|
+
const probeDuration = (file) => {
|
|
32
|
+
const result = spawnSync(
|
|
33
|
+
ffmpegPath,
|
|
34
|
+
["-hide_banner", "-i", file, "-f", "null", "-"],
|
|
35
|
+
{ encoding: "utf8" },
|
|
36
|
+
);
|
|
37
|
+
const matches = [...result.stderr.matchAll(/time=(\d+):(\d+):(\d+\.\d+)/g)];
|
|
38
|
+
if (matches.length === 0) {
|
|
39
|
+
throw new Error(`Could not read the duration of ${file}`);
|
|
40
|
+
}
|
|
41
|
+
const [, h, m, s] = matches[matches.length - 1];
|
|
42
|
+
return Number(h) * 3600 + Number(m) * 60 + Number(s);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const encode = (manifest, output) => {
|
|
46
|
+
const cut = [];
|
|
47
|
+
if (manifest.startAt != null) {
|
|
48
|
+
cut.push("-ss", manifest.startAt.toFixed(3));
|
|
49
|
+
}
|
|
50
|
+
if (manifest.endAt != null) {
|
|
51
|
+
cut.push("-to", manifest.endAt.toFixed(3));
|
|
52
|
+
}
|
|
53
|
+
run([
|
|
54
|
+
...cut,
|
|
55
|
+
"-i",
|
|
56
|
+
manifest.source,
|
|
57
|
+
"-c:v",
|
|
58
|
+
"libx264",
|
|
59
|
+
"-preset",
|
|
60
|
+
"slow",
|
|
61
|
+
"-crf",
|
|
62
|
+
"16",
|
|
63
|
+
// Spends bits on flat, soft areas too, so gradients don't block.
|
|
64
|
+
"-aq-mode",
|
|
65
|
+
"3",
|
|
66
|
+
"-pix_fmt",
|
|
67
|
+
"yuv420p",
|
|
68
|
+
"-colorspace",
|
|
69
|
+
"bt709",
|
|
70
|
+
"-color_primaries",
|
|
71
|
+
"bt709",
|
|
72
|
+
"-color_trc",
|
|
73
|
+
"bt709",
|
|
74
|
+
"-color_range",
|
|
75
|
+
"tv",
|
|
76
|
+
"-r",
|
|
77
|
+
String(manifest.fps),
|
|
78
|
+
"-movflags",
|
|
79
|
+
"+faststart",
|
|
80
|
+
output,
|
|
81
|
+
]);
|
|
82
|
+
return probeDuration(output);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const extractStills = (video, duration, reviewDir) => {
|
|
86
|
+
fs.rmSync(reviewDir, { recursive: true, force: true });
|
|
87
|
+
fs.mkdirSync(reviewDir, { recursive: true });
|
|
88
|
+
const stills = [];
|
|
89
|
+
for (let i = 0; i < STILLS; i++) {
|
|
90
|
+
const at = ((i + 0.5) / STILLS) * duration;
|
|
91
|
+
const still = path.join(
|
|
92
|
+
reviewDir,
|
|
93
|
+
`still-${String(i + 1).padStart(2, "0")}-${at.toFixed(1)}s.jpg`,
|
|
94
|
+
);
|
|
95
|
+
stills.push(still);
|
|
96
|
+
run([
|
|
97
|
+
"-ss",
|
|
98
|
+
at.toFixed(2),
|
|
99
|
+
"-i",
|
|
100
|
+
video,
|
|
101
|
+
"-frames:v",
|
|
102
|
+
"1",
|
|
103
|
+
"-q:v",
|
|
104
|
+
"3",
|
|
105
|
+
still,
|
|
106
|
+
]);
|
|
107
|
+
}
|
|
108
|
+
return stills;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const contactSheet = (video, duration, reviewDir) => {
|
|
112
|
+
const sheet = path.join(reviewDir, "sheet.jpg");
|
|
113
|
+
const rows = Math.max(1, Math.ceil(duration / SHEET_COLUMNS));
|
|
114
|
+
run([
|
|
115
|
+
"-i",
|
|
116
|
+
video,
|
|
117
|
+
"-vf",
|
|
118
|
+
`fps=1,scale=320:-1,tile=${SHEET_COLUMNS}x${rows}:padding=4:color=white`,
|
|
119
|
+
"-frames:v",
|
|
120
|
+
"1",
|
|
121
|
+
"-q:v",
|
|
122
|
+
"3",
|
|
123
|
+
sheet,
|
|
124
|
+
]);
|
|
125
|
+
return sheet;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const readManifest = (workDir) => {
|
|
129
|
+
const manifestPath = path.join(workDir, "manifest.json");
|
|
130
|
+
if (!fs.existsSync(manifestPath)) {
|
|
131
|
+
throw new Error(`No recording in ${workDir}; record first`);
|
|
132
|
+
}
|
|
133
|
+
return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const outputPath = (outDir, manifest) =>
|
|
137
|
+
path.join(outDir, `${manifest.storyboard}-demo.mp4`);
|
|
138
|
+
|
|
139
|
+
// ffmpeg-static downloads its binary in an install script. pnpm >= 10 skips
|
|
140
|
+
// dependency install scripts unless they are allowed, which leaves the path
|
|
141
|
+
// set but the file missing.
|
|
142
|
+
const assertFfmpeg = () => {
|
|
143
|
+
if (!ffmpegPath || !fs.existsSync(ffmpegPath)) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`ffmpeg-static has no binary${ffmpegPath ? ` at ${ffmpegPath}` : " for this platform"}. ` +
|
|
146
|
+
'With pnpm >= 10, allow its install script ("pnpm": { "onlyBuiltDependencies": ["ffmpeg-static"] } in the root package.json) and reinstall.',
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Encodes the recording in workDir to an MP4 in outDir, then writes review
|
|
153
|
+
* stills and a contact sheet. Returns { output, duration, stills, sheet }.
|
|
154
|
+
*/
|
|
155
|
+
export const encodeRecording = (workDir, outDir) => {
|
|
156
|
+
assertFfmpeg();
|
|
157
|
+
const manifest = readManifest(workDir);
|
|
158
|
+
if (!fs.existsSync(manifest.source)) {
|
|
159
|
+
throw new Error(`Recording ${manifest.source} is missing`);
|
|
160
|
+
}
|
|
161
|
+
const output = outputPath(outDir, manifest);
|
|
162
|
+
const duration = encode(manifest, output);
|
|
163
|
+
const reviewDir = path.join(outDir, "review");
|
|
164
|
+
const stills = extractStills(output, duration, reviewDir);
|
|
165
|
+
const sheet = contactSheet(output, duration, reviewDir);
|
|
166
|
+
return { output, duration, stills, sheet };
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** Re-extracts review stills from the newest MP4 in outDir. */
|
|
170
|
+
export const extractStillsFrom = (outDir) => {
|
|
171
|
+
assertFfmpeg();
|
|
172
|
+
const videos = fs.existsSync(outDir)
|
|
173
|
+
? fs
|
|
174
|
+
.readdirSync(outDir)
|
|
175
|
+
.filter((f) => f.endsWith("-demo.mp4"))
|
|
176
|
+
.map((f) => path.join(outDir, f))
|
|
177
|
+
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)
|
|
178
|
+
: [];
|
|
179
|
+
if (videos.length === 0) {
|
|
180
|
+
throw new Error(`No video in ${outDir}; render first`);
|
|
181
|
+
}
|
|
182
|
+
const output = videos[0];
|
|
183
|
+
const duration = probeDuration(output);
|
|
184
|
+
extractStills(output, duration, path.join(outDir, "review"));
|
|
185
|
+
contactSheet(output, duration, path.join(outDir, "review"));
|
|
186
|
+
return output;
|
|
187
|
+
};
|
package/bin/init.d.mts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type Change = [status: string, what: string, detail: string];
|
|
2
|
+
export interface InitResult {
|
|
3
|
+
changes: Change[];
|
|
4
|
+
todo: string[];
|
|
5
|
+
}
|
|
6
|
+
export const IGNORE_LINE: string;
|
|
7
|
+
export const LABEL: string;
|
|
8
|
+
export const CALLER_WORKFLOW: string;
|
|
9
|
+
export function initRepo(
|
|
10
|
+
root: string,
|
|
11
|
+
options?: { dryRun?: boolean; label?: boolean },
|
|
12
|
+
): InitResult;
|
|
13
|
+
export function initApp(
|
|
14
|
+
appDir: string,
|
|
15
|
+
options?: { dryRun?: boolean },
|
|
16
|
+
): InitResult;
|
|
17
|
+
export function adapterSkeleton(options: {
|
|
18
|
+
app: string;
|
|
19
|
+
command?: string;
|
|
20
|
+
url?: string;
|
|
21
|
+
env: Record<string, string>;
|
|
22
|
+
mocks: { name: string; file: string }[];
|
|
23
|
+
demoDir: string;
|
|
24
|
+
}): string;
|
package/bin/init.mjs
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
// `demo-video init` sets a pnpm project up for demo videos: .gitignore,
|
|
7
|
+
// package.json, the skill, the label, the demo-video.yml caller (only when
|
|
8
|
+
// missing) and a demo.config.ts skeleton. It never edits an existing
|
|
9
|
+
// workflow. Safe to re-run; with dryRun it reports what it would change.
|
|
10
|
+
|
|
11
|
+
const PKG = "@navipartner/demo-video";
|
|
12
|
+
export const IGNORE_LINE = "**/demo/out/";
|
|
13
|
+
export const LABEL = "demo video";
|
|
14
|
+
|
|
15
|
+
const engineDir = path.resolve(
|
|
16
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
17
|
+
"..",
|
|
18
|
+
);
|
|
19
|
+
const engineVersion = () =>
|
|
20
|
+
JSON.parse(fs.readFileSync(path.join(engineDir, "package.json"), "utf8"))
|
|
21
|
+
.version;
|
|
22
|
+
|
|
23
|
+
export const CALLER_WORKFLOW = `name: Demo video
|
|
24
|
+
# The "${LABEL}" label films the stories a PR adds or changes. A PR comment
|
|
25
|
+
# "/demo-video [<story>] [in <brand> branding]", or Actions → Demo video →
|
|
26
|
+
# Run workflow, films an existing story with a brand.
|
|
27
|
+
on:
|
|
28
|
+
pull_request:
|
|
29
|
+
types: [labeled, synchronize]
|
|
30
|
+
issue_comment:
|
|
31
|
+
types: [created]
|
|
32
|
+
# Actions → Demo video → Run workflow: film a story with a brand.
|
|
33
|
+
workflow_dispatch:
|
|
34
|
+
inputs:
|
|
35
|
+
app:
|
|
36
|
+
description: "App folder, e.g. apps/retail-self-service (empty: the only app)"
|
|
37
|
+
default: ""
|
|
38
|
+
story:
|
|
39
|
+
description: "Story name (empty: the app's primary story)"
|
|
40
|
+
default: ""
|
|
41
|
+
brand:
|
|
42
|
+
description: "Brand (empty: the app's default)"
|
|
43
|
+
default: ""
|
|
44
|
+
permissions: {}
|
|
45
|
+
jobs:
|
|
46
|
+
render:
|
|
47
|
+
if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, '${LABEL}')
|
|
48
|
+
permissions:
|
|
49
|
+
contents: read
|
|
50
|
+
pull-requests: write
|
|
51
|
+
uses: navipartner/demo-video/.github/workflows/render.yml@v0
|
|
52
|
+
request:
|
|
53
|
+
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/demo-video'))
|
|
54
|
+
permissions:
|
|
55
|
+
contents: read
|
|
56
|
+
pull-requests: write
|
|
57
|
+
issues: write
|
|
58
|
+
uses: navipartner/demo-video/.github/workflows/request.yml@v0
|
|
59
|
+
with:
|
|
60
|
+
app: \${{ inputs.app }}
|
|
61
|
+
story: \${{ inputs.story }}
|
|
62
|
+
brand: \${{ inputs.brand }}
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
const read = (file) =>
|
|
66
|
+
fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
|
|
67
|
+
const eolOf = (text) => (text?.includes("\r\n") ? "\r\n" : "\n");
|
|
68
|
+
|
|
69
|
+
const appendLines = (file, lines, dryRun) => {
|
|
70
|
+
const old = read(file) ?? "";
|
|
71
|
+
const eol = eolOf(old);
|
|
72
|
+
const sep = old && !old.endsWith("\n") ? eol : "";
|
|
73
|
+
if (!dryRun) {
|
|
74
|
+
fs.writeFileSync(file, old + sep + lines.join(eol) + eol);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Edits a JSON file in place, keeping its indent and line endings. */
|
|
79
|
+
const editJson = (file, edit, dryRun) => {
|
|
80
|
+
const text = fs.readFileSync(file, "utf8");
|
|
81
|
+
const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? " ";
|
|
82
|
+
const json = JSON.parse(text);
|
|
83
|
+
edit(json);
|
|
84
|
+
const eol = eolOf(text);
|
|
85
|
+
if (!dryRun) {
|
|
86
|
+
fs.writeFileSync(
|
|
87
|
+
file,
|
|
88
|
+
JSON.stringify(json, null, indent).replace(/\n/g, eol) + eol,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Adds a key in alphabetical place when the object is sorted, else last. */
|
|
94
|
+
const withKey = (object, key, value) => {
|
|
95
|
+
const keys = Object.keys(object);
|
|
96
|
+
const sorted = keys.every((k, i) => i === 0 || keys[i - 1] <= k);
|
|
97
|
+
const entries = Object.entries(object);
|
|
98
|
+
const at = sorted ? entries.findIndex(([k]) => k > key) : -1;
|
|
99
|
+
entries.splice(at === -1 ? entries.length : at, 0, [key, value]);
|
|
100
|
+
return Object.fromEntries(entries);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const sameTree = (a, b) => {
|
|
104
|
+
if (!fs.existsSync(b)) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
return fs.readdirSync(a, { withFileTypes: true }).every((entry) => {
|
|
108
|
+
const [x, y] = [path.join(a, entry.name), path.join(b, entry.name)];
|
|
109
|
+
return entry.isDirectory()
|
|
110
|
+
? sameTree(x, y)
|
|
111
|
+
: fs.existsSync(y) && fs.readFileSync(x).equals(fs.readFileSync(y));
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const gitIgnores = (root, file) =>
|
|
116
|
+
spawnSync("git", ["check-ignore", "-q", file], { cwd: root }).status === 0;
|
|
117
|
+
|
|
118
|
+
const ensureLabel = (root, dryRun) => {
|
|
119
|
+
const list = spawnSync(
|
|
120
|
+
"gh",
|
|
121
|
+
// All labels, not --search: the search index can lag and miss one.
|
|
122
|
+
["label", "list", "--limit", "1000", "--json", "name", "--jq", ".[].name"],
|
|
123
|
+
{ cwd: root, encoding: "utf8" },
|
|
124
|
+
);
|
|
125
|
+
if (list.error || list.status !== 0) {
|
|
126
|
+
return {
|
|
127
|
+
todo: `Create the label: gh label create "${LABEL}" --color 1F5494`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// GitHub label names are case-insensitive.
|
|
131
|
+
const names = list.stdout.split("\n").map((name) => name.toLowerCase());
|
|
132
|
+
if (names.includes(LABEL)) {
|
|
133
|
+
return { change: ["ok", "label", LABEL] };
|
|
134
|
+
}
|
|
135
|
+
if (!dryRun) {
|
|
136
|
+
const created = spawnSync(
|
|
137
|
+
"gh",
|
|
138
|
+
["label", "create", LABEL, "--color", "1F5494"],
|
|
139
|
+
{ cwd: root, encoding: "utf8" },
|
|
140
|
+
);
|
|
141
|
+
if (created.error || created.status !== 0) {
|
|
142
|
+
return {
|
|
143
|
+
todo: `Create the label (gh could not: ${(created.stderr ?? "").trim() || "no access?"}): gh label create "${LABEL}" --color 1F5494`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { change: ["added", "label", LABEL] };
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Once per repo, from its root. `label` (default true) checks or creates the
|
|
152
|
+
* GitHub label with gh.
|
|
153
|
+
*/
|
|
154
|
+
export const initRepo = (root, { dryRun = false, label = true } = {}) => {
|
|
155
|
+
const changes = [];
|
|
156
|
+
const todo = [];
|
|
157
|
+
|
|
158
|
+
const gitignore = path.join(root, ".gitignore");
|
|
159
|
+
const ignored = read(gitignore);
|
|
160
|
+
// Only **/ reaches apps/<x>/demo/out/: a bare demo/out/ is anchored to
|
|
161
|
+
// the repo root.
|
|
162
|
+
if (/^\*\*\/demo\/out\/?$/m.test(ignored ?? "")) {
|
|
163
|
+
changes.push(["ok", ".gitignore", IGNORE_LINE]);
|
|
164
|
+
} else {
|
|
165
|
+
const comment = "# Demo video renders (@navipartner/demo-video)";
|
|
166
|
+
appendLines(
|
|
167
|
+
gitignore,
|
|
168
|
+
ignored ? ["", comment, IGNORE_LINE] : [comment, IGNORE_LINE],
|
|
169
|
+
dryRun,
|
|
170
|
+
);
|
|
171
|
+
changes.push(["added", ".gitignore", IGNORE_LINE]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// pnpm 10 runs no install scripts unless allowed; ffmpeg-static needs its.
|
|
175
|
+
const pkgFile = path.join(root, "package.json");
|
|
176
|
+
const pkg = fs.existsSync(pkgFile)
|
|
177
|
+
? JSON.parse(fs.readFileSync(pkgFile, "utf8"))
|
|
178
|
+
: null;
|
|
179
|
+
const pnpmMajor = Number(/^pnpm@(\d+)/.exec(pkg?.packageManager ?? "")?.[1]);
|
|
180
|
+
const workspaceYaml = read(path.join(root, "pnpm-workspace.yaml")) ?? "";
|
|
181
|
+
if (pkg && pnpmMajor >= 10) {
|
|
182
|
+
if (/^onlyBuiltDependencies:/m.test(workspaceYaml)) {
|
|
183
|
+
if (!/ffmpeg-static/.test(workspaceYaml)) {
|
|
184
|
+
todo.push(
|
|
185
|
+
"Add ffmpeg-static to onlyBuiltDependencies in pnpm-workspace.yaml.",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
} else if (pkg.pnpm?.onlyBuiltDependencies?.includes("ffmpeg-static")) {
|
|
189
|
+
changes.push(["ok", "package.json", "pnpm.onlyBuiltDependencies"]);
|
|
190
|
+
} else {
|
|
191
|
+
editJson(
|
|
192
|
+
pkgFile,
|
|
193
|
+
(json) => {
|
|
194
|
+
json.pnpm ??= {};
|
|
195
|
+
json.pnpm.onlyBuiltDependencies = [
|
|
196
|
+
...(json.pnpm.onlyBuiltDependencies ?? []),
|
|
197
|
+
"ffmpeg-static",
|
|
198
|
+
];
|
|
199
|
+
},
|
|
200
|
+
dryRun,
|
|
201
|
+
);
|
|
202
|
+
changes.push(["added", "package.json", "pnpm.onlyBuiltDependencies"]);
|
|
203
|
+
}
|
|
204
|
+
} else if (pkg && !pnpmMajor) {
|
|
205
|
+
todo.push(
|
|
206
|
+
'No "packageManager" in package.json: with pnpm 10 or later, allow ffmpeg-static in onlyBuiltDependencies.',
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Claude Code loads .claude/skills/<name>/SKILL.md.
|
|
211
|
+
const skillSrc = path.join(engineDir, "skills", "np-demo-video");
|
|
212
|
+
const skillDest = path.join(root, ".claude", "skills", "np-demo-video");
|
|
213
|
+
const skillRel = ".claude/skills/np-demo-video/";
|
|
214
|
+
if (sameTree(skillSrc, skillDest)) {
|
|
215
|
+
changes.push(["ok", skillRel, "the np-demo-video skill"]);
|
|
216
|
+
} else {
|
|
217
|
+
const existed = fs.existsSync(skillDest);
|
|
218
|
+
if (!dryRun) {
|
|
219
|
+
fs.cpSync(skillSrc, skillDest, { recursive: true, force: true });
|
|
220
|
+
}
|
|
221
|
+
changes.push([
|
|
222
|
+
existed ? "updated" : "added",
|
|
223
|
+
skillRel,
|
|
224
|
+
"the np-demo-video skill",
|
|
225
|
+
]);
|
|
226
|
+
}
|
|
227
|
+
const stray = read(path.join(root, ".claude", "skills", "SKILL.md"));
|
|
228
|
+
if (stray?.includes("name: np-demo-video")) {
|
|
229
|
+
todo.push(
|
|
230
|
+
"Delete .claude/skills/SKILL.md and look.md: a copy of the skill in the wrong folder.",
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (gitIgnores(root, skillRel)) {
|
|
234
|
+
todo.push(
|
|
235
|
+
"Git ignores .claude/ here, so the skill stays on this machine: each developer runs init, or add !.claude/skills/ to .gitignore to share it.",
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (label) {
|
|
240
|
+
const result = ensureLabel(root, dryRun);
|
|
241
|
+
if (result.change) {
|
|
242
|
+
changes.push(result.change);
|
|
243
|
+
} else {
|
|
244
|
+
todo.push(result.todo);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// The caller workflow is fixed, so it is written when missing. Existing
|
|
249
|
+
// workflows are never touched.
|
|
250
|
+
const callerFile = path.join(root, ".github", "workflows", "demo-video.yml");
|
|
251
|
+
if (
|
|
252
|
+
["demo-video.yml", "demo-video.yaml"].some((f) =>
|
|
253
|
+
fs.existsSync(path.join(root, ".github", "workflows", f)),
|
|
254
|
+
)
|
|
255
|
+
) {
|
|
256
|
+
changes.push(["ok", ".github/workflows/demo-video.yml", "exists"]);
|
|
257
|
+
// Written by an older init: it has the label job only.
|
|
258
|
+
const existing = ["demo-video.yml", "demo-video.yaml"]
|
|
259
|
+
.map((f) => read(path.join(root, ".github", "workflows", f)))
|
|
260
|
+
.find(Boolean);
|
|
261
|
+
if (existing && !existing.includes("request.yml@")) {
|
|
262
|
+
todo.push(
|
|
263
|
+
'.github/workflows/demo-video.yml predates the "request" job (/demo-video comments, the Run workflow form): delete it and re-run init to get the current one.',
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
} else {
|
|
267
|
+
if (!dryRun) {
|
|
268
|
+
fs.mkdirSync(path.dirname(callerFile), { recursive: true });
|
|
269
|
+
fs.writeFileSync(callerFile, CALLER_WORKFLOW);
|
|
270
|
+
}
|
|
271
|
+
changes.push([
|
|
272
|
+
"added",
|
|
273
|
+
".github/workflows/demo-video.yml",
|
|
274
|
+
"render.yml@v0",
|
|
275
|
+
]);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return { changes, todo };
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/** The webServer block of a Playwright config, if one can be read. */
|
|
282
|
+
const webServerOf = (configText) => {
|
|
283
|
+
const start = configText.indexOf("webServer");
|
|
284
|
+
const open = configText.indexOf("{", start);
|
|
285
|
+
if (start === -1 || open === -1) {
|
|
286
|
+
return {};
|
|
287
|
+
}
|
|
288
|
+
let depth = 0;
|
|
289
|
+
let end = open;
|
|
290
|
+
for (; end < configText.length; end++) {
|
|
291
|
+
depth += configText[end] === "{" ? 1 : configText[end] === "}" ? -1 : 0;
|
|
292
|
+
if (depth === 0) {
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const block = configText.slice(open, end + 1);
|
|
297
|
+
const str = (key) =>
|
|
298
|
+
new RegExp(`\\b${key}:\\s*["'\`]([^"'\`]+)["'\`]`).exec(block)?.[1];
|
|
299
|
+
const envBlock = /\benv:\s*\{([^}]*)\}/.exec(block)?.[1] ?? "";
|
|
300
|
+
const env = Object.fromEntries(
|
|
301
|
+
[...envBlock.matchAll(/(\w+):\s*["'`]([^"'`]*)["'`]/g)].map((m) => [
|
|
302
|
+
m[1],
|
|
303
|
+
m[2],
|
|
304
|
+
]),
|
|
305
|
+
);
|
|
306
|
+
return { command: str("command"), url: str("url"), env };
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* e2e helpers that look like the app's mocks: in a file named *mock*, its
|
|
311
|
+
* exported class or function (`class ApiMocks`, `setupApiMocks`), not data.
|
|
312
|
+
*/
|
|
313
|
+
const findMocks = (appDir) => {
|
|
314
|
+
const found = [];
|
|
315
|
+
const walk = (dir, depth) => {
|
|
316
|
+
if (depth > 3 || !fs.existsSync(dir)) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
320
|
+
const file = path.join(dir, entry.name);
|
|
321
|
+
if (entry.isDirectory() && entry.name !== "node_modules") {
|
|
322
|
+
walk(file, depth + 1);
|
|
323
|
+
} else if (/mock/i.test(entry.name) && /\.[cm]?[jt]s$/.test(entry.name)) {
|
|
324
|
+
const name = /^export\s+(?:class|(?:async\s+)?function)\s+(\w+)/m.exec(
|
|
325
|
+
read(file),
|
|
326
|
+
)?.[1];
|
|
327
|
+
if (name) {
|
|
328
|
+
found.push({ name, file });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
walk(path.join(appDir, "e2e"), 0);
|
|
334
|
+
return found;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
export const adapterSkeleton = ({ app, command, url, env, mocks, demoDir }) => {
|
|
338
|
+
const envLine = Object.keys(env).length
|
|
339
|
+
? `\n env: { ${Object.entries(env)
|
|
340
|
+
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
|
|
341
|
+
.join(", ")} },`
|
|
342
|
+
: "";
|
|
343
|
+
const mockHint = mocks.length
|
|
344
|
+
? mocks
|
|
345
|
+
.map(({ name, file }) => {
|
|
346
|
+
const rel = path
|
|
347
|
+
.relative(demoDir, file)
|
|
348
|
+
.replace(/\\/g, "/")
|
|
349
|
+
.replace(/\.[cm]?[jt]s$/, "");
|
|
350
|
+
return `\n // import { ${name} } from "${rel.startsWith(".") ? rel : `./${rel}`}";`;
|
|
351
|
+
})
|
|
352
|
+
.join("")
|
|
353
|
+
: "";
|
|
354
|
+
return `import { defineDemo } from "@navipartner/demo-video";
|
|
355
|
+
|
|
356
|
+
// Written by \`demo-video init\`. Finish it with Claude (the np-demo-video
|
|
357
|
+
// skill) from the app's e2e tests: their mocks, unchanged, never new ones.
|
|
358
|
+
|
|
359
|
+
export default defineDemo({
|
|
360
|
+
app: ${JSON.stringify(app)},${command && url ? "" : "\n // TODO: the dev server the e2e tests use (playwright.config's webServer)."}
|
|
361
|
+
webServer: {
|
|
362
|
+
command: ${JSON.stringify(command ?? "pnpm dev")},
|
|
363
|
+
url: ${JSON.stringify(url ?? "http://localhost:5173")},${envLine}
|
|
364
|
+
},
|
|
365
|
+
// TODO: the path the e2e tests open, e.g. "/?tenant=test".
|
|
366
|
+
appPath: "/",
|
|
367
|
+
// TODO: a testid that is visible once the mocks have booted the app.
|
|
368
|
+
ready: "TODO",
|
|
369
|
+
|
|
370
|
+
async setup() {
|
|
371
|
+
// TODO: install the e2e mocks unchanged, then lay the brand over the
|
|
372
|
+
// response that carries the app's branding (overlayResponse).${mocks.length ? `\n // Mocks found:${mockHint}` : ""}
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
`;
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
/** Once per app: the dependency and a demo.config.ts skeleton. */
|
|
379
|
+
export const initApp = (appDir, { dryRun = false } = {}) => {
|
|
380
|
+
const changes = [];
|
|
381
|
+
const todo = [];
|
|
382
|
+
const pkgFile = path.join(appDir, "package.json");
|
|
383
|
+
if (!fs.existsSync(pkgFile)) {
|
|
384
|
+
throw new Error(`No package.json in ${appDir}`);
|
|
385
|
+
}
|
|
386
|
+
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
|
|
387
|
+
const version = engineVersion();
|
|
388
|
+
|
|
389
|
+
if (pkg.devDependencies?.[PKG] || pkg.dependencies?.[PKG]) {
|
|
390
|
+
changes.push(["ok", "package.json", PKG]);
|
|
391
|
+
} else {
|
|
392
|
+
// A prerelease is pinned exactly. A release gets a caret range, which on
|
|
393
|
+
// 0.x takes patch updates only (^0.2.0 is <0.3.0).
|
|
394
|
+
const range = version.includes("-") ? version : `^${version}`;
|
|
395
|
+
editJson(
|
|
396
|
+
pkgFile,
|
|
397
|
+
(json) => {
|
|
398
|
+
json.devDependencies = withKey(json.devDependencies ?? {}, PKG, range);
|
|
399
|
+
},
|
|
400
|
+
dryRun,
|
|
401
|
+
);
|
|
402
|
+
changes.push(["added", "package.json", `devDependencies ${PKG} ${range}`]);
|
|
403
|
+
todo.push("Run pnpm install to update the lockfile.");
|
|
404
|
+
}
|
|
405
|
+
if (
|
|
406
|
+
!pkg.devDependencies?.["@playwright/test"] &&
|
|
407
|
+
!pkg.dependencies?.["@playwright/test"]
|
|
408
|
+
) {
|
|
409
|
+
todo.push(
|
|
410
|
+
"The app needs @playwright/test (1.59 or later): the demo runs on its Playwright.",
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const demoDir = path.join(appDir, "demo");
|
|
415
|
+
const adapter = path.join(demoDir, "demo.config.ts");
|
|
416
|
+
if (fs.existsSync(adapter)) {
|
|
417
|
+
changes.push(["ok", "demo/demo.config.ts", "exists"]);
|
|
418
|
+
} else {
|
|
419
|
+
const configFile = ["ts", "mts", "js", "mjs"]
|
|
420
|
+
.map((ext) => path.join(appDir, `playwright.config.${ext}`))
|
|
421
|
+
.find((f) => fs.existsSync(f));
|
|
422
|
+
const webServer = configFile ? webServerOf(read(configFile)) : {};
|
|
423
|
+
const skeleton = adapterSkeleton({
|
|
424
|
+
app: (pkg.name ?? path.basename(appDir)).replace(/^@[^/]+\//, ""),
|
|
425
|
+
command: webServer.command,
|
|
426
|
+
url: webServer.url,
|
|
427
|
+
env: webServer.env ?? {},
|
|
428
|
+
mocks: findMocks(appDir),
|
|
429
|
+
demoDir,
|
|
430
|
+
});
|
|
431
|
+
if (!dryRun) {
|
|
432
|
+
fs.mkdirSync(demoDir, { recursive: true });
|
|
433
|
+
fs.writeFileSync(adapter, skeleton);
|
|
434
|
+
}
|
|
435
|
+
changes.push([
|
|
436
|
+
"added",
|
|
437
|
+
"demo/demo.config.ts",
|
|
438
|
+
webServer.command && webServer.url
|
|
439
|
+
? `skeleton; webServer from ${path.basename(configFile)}`
|
|
440
|
+
: "skeleton; webServer to fill in (TODO)",
|
|
441
|
+
]);
|
|
442
|
+
}
|
|
443
|
+
todo.push(
|
|
444
|
+
"Ask Claude for a demo video of this app: it finishes demo.config.ts and writes the primary story, then runs demo-video check.",
|
|
445
|
+
);
|
|
446
|
+
return { changes, todo };
|
|
447
|
+
};
|
package/bin/pick.d.mts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { StoryEntry } from "./args.mjs";
|
|
2
|
+
|
|
3
|
+
export interface AppMenu {
|
|
4
|
+
dir: string;
|
|
5
|
+
name: string;
|
|
6
|
+
stories: StoryEntry[];
|
|
7
|
+
brands: string[];
|
|
8
|
+
}
|
|
9
|
+
export type Pick =
|
|
10
|
+
| { app: string; story: string; brand: string | null; menu?: undefined }
|
|
11
|
+
| { menu: true; reason: string; apps?: AppMenu[] };
|
|
12
|
+
export function appMenu(appDir: string): AppMenu;
|
|
13
|
+
export function pick(request: string, apps: AppMenu[]): Pick;
|
|
14
|
+
export function menuMarkdown(apps: AppMenu[], reason: string): string;
|
|
15
|
+
export function pickExact(
|
|
16
|
+
apps: AppMenu[],
|
|
17
|
+
names: { app?: string; story?: string; brand?: string },
|
|
18
|
+
): Pick;
|