@navipartner/demo-video 0.0.0-stage → 0.5.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 +134 -2
- package/bin/args.d.mts +39 -0
- package/bin/args.mjs +158 -0
- package/bin/demo-video.mjs +425 -0
- package/bin/encode.mjs +187 -0
- package/bin/init.d.mts +30 -0
- package/bin/init.mjs +521 -0
- package/bin/pick.d.mts +23 -0
- package/bin/pick.mjs +265 -0
- package/bin/pitch.d.mts +17 -0
- package/bin/pitch.mjs +261 -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-D_iU2dh2.d.ts +176 -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 +1316 -0
- package/package.json +67 -3
- package/skills/np-demo-video/SKILL.md +364 -0
- package/skills/np-demo-video/look.md +158 -0
- package/skills/np-demo-video/pitch.md +99 -0
- package/skills/np-demo-video/reference.md +92 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* eslint-disable no-console -- a CLI; the console is its output */
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
import { listStories, parseArgs } from "./args.mjs";
|
|
10
|
+
import { encodeRecording, extractStillsFrom } from "./encode.mjs";
|
|
11
|
+
import { initApp, initProject } from "./init.mjs";
|
|
12
|
+
import { appMenu, menuMarkdown, pick, pickExact } from "./pick.mjs";
|
|
13
|
+
import { buildPitchBrand } from "./pitch.mjs";
|
|
14
|
+
import { writeRunner } from "./runner.mjs";
|
|
15
|
+
|
|
16
|
+
// Runs the app's own Playwright (resolved from the app, never from this
|
|
17
|
+
// package) against the config this CLI writes into demo/out/.work/runner/.
|
|
18
|
+
|
|
19
|
+
// A run's work folder goes when the command ends. A failure first keeps
|
|
20
|
+
// Playwright's results (its error context) in demo/out/failed/; a success
|
|
21
|
+
// clears an older failure, so what's there is always the latest.
|
|
22
|
+
const finish = (args, ok) => {
|
|
23
|
+
const failed = path.join(args.outDir, "failed");
|
|
24
|
+
fs.rmSync(failed, { recursive: true, force: true });
|
|
25
|
+
const results = path.join(args.workDir, "test-results");
|
|
26
|
+
if (!ok && fs.existsSync(results)) {
|
|
27
|
+
fs.cpSync(results, failed, { recursive: true });
|
|
28
|
+
console.error(`Playwright's output for this failure: ${failed}`);
|
|
29
|
+
}
|
|
30
|
+
fs.rmSync(args.workDir, { recursive: true, force: true });
|
|
31
|
+
const work = path.dirname(args.workDir);
|
|
32
|
+
if (fs.existsSync(work) && fs.readdirSync(work).length === 0) {
|
|
33
|
+
fs.rmdirSync(work);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const runPlaywright = (args, mode) => {
|
|
38
|
+
const require = createRequire(path.join(args.appDir, "package.json"));
|
|
39
|
+
const cli = require.resolve("@playwright/test/cli");
|
|
40
|
+
fs.rmSync(args.workDir, { recursive: true, force: true });
|
|
41
|
+
fs.mkdirSync(args.workDir, { recursive: true });
|
|
42
|
+
writeRunner(args);
|
|
43
|
+
const result = spawnSync(
|
|
44
|
+
process.execPath,
|
|
45
|
+
[cli, "test", "-c", args.configPath],
|
|
46
|
+
{
|
|
47
|
+
cwd: args.appDir,
|
|
48
|
+
// With --json, Playwright's own output goes to stderr so stdout is pure JSON.
|
|
49
|
+
stdio: ["inherit", args.json ? 2 : "inherit", "inherit"],
|
|
50
|
+
env: {
|
|
51
|
+
...process.env,
|
|
52
|
+
DEMO_MODE: mode,
|
|
53
|
+
DEMO_STORYBOARD: args.storyboardPath ?? "",
|
|
54
|
+
DEMO_BRAND: args.brand ?? "",
|
|
55
|
+
DEMO_BRANDS: args.brandsDir ?? "",
|
|
56
|
+
DEMO_OUT: args.outDir,
|
|
57
|
+
DEMO_WORK: args.workDir,
|
|
58
|
+
DEMO_DRAFT: args.draft ? "1" : "",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
return result.status ?? 1;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const printReport = (report) => {
|
|
66
|
+
if (report.ok) {
|
|
67
|
+
console.log(`✓ story passes (${report.steps} steps)`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const where = report.step
|
|
71
|
+
? `step ${report.step} (${report.do}${report.target ? ` ${report.target}` : ""})`
|
|
72
|
+
: "before the first step";
|
|
73
|
+
console.log(`✗ ${where}: ${report.error}`);
|
|
74
|
+
if (report.visible?.length) {
|
|
75
|
+
console.log(` visible testids: ${report.visible.join(", ")}`);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// demo-video init [--app <dir>] [--dry-run]: needs no demo/ yet, so it skips
|
|
80
|
+
// parseArgs. Without --app: the repo, plus its app if it has exactly one.
|
|
81
|
+
const runInit = (argv) => {
|
|
82
|
+
const dryRun = argv.includes("--dry-run");
|
|
83
|
+
const at = argv.indexOf("--app");
|
|
84
|
+
const value = at === -1 ? null : argv[at + 1];
|
|
85
|
+
// A flag after --app (--app --dry-run) is not its directory.
|
|
86
|
+
const appMissing = at !== -1 && (!value || value.startsWith("-"));
|
|
87
|
+
const app = appMissing ? null : value;
|
|
88
|
+
const unknown = argv.filter(
|
|
89
|
+
(a, i) =>
|
|
90
|
+
a !== "--dry-run" && a !== "--app" && !(app && argv[i - 1] === "--app"),
|
|
91
|
+
);
|
|
92
|
+
if (appMissing || unknown.length) {
|
|
93
|
+
// Name what was wrong: "-app" for "--app", or a second command on the line.
|
|
94
|
+
const problem = appMissing
|
|
95
|
+
? "--app needs a directory right after it"
|
|
96
|
+
: `Unknown argument${unknown.length > 1 ? "s" : ""}: ${unknown.join(" ")}`;
|
|
97
|
+
console.error(
|
|
98
|
+
`${problem}\nUsage: demo-video init [--app <dir>] [--dry-run]`,
|
|
99
|
+
);
|
|
100
|
+
return 2;
|
|
101
|
+
}
|
|
102
|
+
const root = process.cwd();
|
|
103
|
+
const result = app
|
|
104
|
+
? initApp(path.resolve(root, app), { dryRun })
|
|
105
|
+
: initProject(root, { dryRun });
|
|
106
|
+
const what = app
|
|
107
|
+
? `app ${app}`
|
|
108
|
+
: result.apps.length === 1
|
|
109
|
+
? `repo and its app${result.apps[0] === "." ? " (the repo root)" : ` ${result.apps[0]}`}`
|
|
110
|
+
: "repo";
|
|
111
|
+
console.log(
|
|
112
|
+
`demo-video init${dryRun ? " (dry run: nothing written)" : ""}: ${what}`,
|
|
113
|
+
);
|
|
114
|
+
const width = Math.max(...result.changes.map(([, what]) => what.length));
|
|
115
|
+
for (const [status, what, detail] of result.changes) {
|
|
116
|
+
console.log(` ${status.padEnd(8)} ${what.padEnd(width)} ${detail}`);
|
|
117
|
+
}
|
|
118
|
+
if (result.todo.length) {
|
|
119
|
+
console.log("\nBy hand:");
|
|
120
|
+
for (const item of result.todo) {
|
|
121
|
+
console.log(` - ${item}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return 0;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// demo-video pick [--request "<text>" | --app <a> --story <s> --brand <b>]
|
|
128
|
+
// [--json] <app dir>...: which existing story and brand a request means
|
|
129
|
+
// (see pick.mjs). --request reads a sentence (a /demo-video comment); the
|
|
130
|
+
// flags are exact names (the Run workflow form).
|
|
131
|
+
const runPick = (argv) => {
|
|
132
|
+
const flags = {};
|
|
133
|
+
const dirs = [];
|
|
134
|
+
let json = false;
|
|
135
|
+
for (let i = 0; i < argv.length; i++) {
|
|
136
|
+
const arg = argv[i];
|
|
137
|
+
if (arg === "--json") {
|
|
138
|
+
json = true;
|
|
139
|
+
} else if (["--request", "--app", "--story", "--brand"].includes(arg)) {
|
|
140
|
+
flags[arg.slice(2)] = argv[++i] ?? "";
|
|
141
|
+
} else {
|
|
142
|
+
dirs.push(arg);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (dirs.length === 0) {
|
|
146
|
+
console.error(
|
|
147
|
+
'Usage: demo-video pick [--request "<text>" | --app <a> --story <s> --brand <b>] [--json] <app dir>...',
|
|
148
|
+
);
|
|
149
|
+
return 2;
|
|
150
|
+
}
|
|
151
|
+
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);
|
|
156
|
+
const out = result.menu
|
|
157
|
+
? {
|
|
158
|
+
menu: true,
|
|
159
|
+
reason: result.reason,
|
|
160
|
+
markdown: menuMarkdown(result.apps ?? apps, result.reason),
|
|
161
|
+
}
|
|
162
|
+
: result;
|
|
163
|
+
if (json) {
|
|
164
|
+
console.log(JSON.stringify(out));
|
|
165
|
+
} else if (out.menu) {
|
|
166
|
+
console.log(out.markdown);
|
|
167
|
+
} else {
|
|
168
|
+
console.log(
|
|
169
|
+
`${out.app}: story ${out.story}, brand ${out.brand ?? "(the app's default)"}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return 0;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// demo-video pitch <proposal.json> [--app <dir>] [--story <name>] [--out <dir>]
|
|
176
|
+
// [--json]: a pitch draft from a researched proposal (the skill's pitch.md).
|
|
177
|
+
// It builds the brand in a temp folder (pitch.mjs), checks the story with
|
|
178
|
+
// it, films it at 30 fps and moves the video and its contact sheet to --out
|
|
179
|
+
// (default: the proposal's folder). The brand and the repo's copies go.
|
|
180
|
+
const runPitch = async (argv) => {
|
|
181
|
+
const flags = { app: ".", story: null, out: null };
|
|
182
|
+
let file = null;
|
|
183
|
+
for (let i = 0; i < argv.length; i++) {
|
|
184
|
+
const arg = argv[i];
|
|
185
|
+
if (["--app", "--story", "--out"].includes(arg)) {
|
|
186
|
+
flags[arg.slice(2)] = argv[++i] ?? null;
|
|
187
|
+
} else if (arg !== "--json") {
|
|
188
|
+
file = arg;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!file) {
|
|
192
|
+
console.error(
|
|
193
|
+
"Usage: demo-video pitch <proposal.json> [--app <dir>] [--story <name>] [--out <dir>] [--json]",
|
|
194
|
+
);
|
|
195
|
+
return 2;
|
|
196
|
+
}
|
|
197
|
+
const proposalPath = path.resolve(file);
|
|
198
|
+
const out = path.resolve(flags.out ?? path.dirname(proposalPath));
|
|
199
|
+
const brandsDir = fs.mkdtempSync(path.join(os.tmpdir(), "demo-video-pitch-"));
|
|
200
|
+
try {
|
|
201
|
+
const built = await buildPitchBrand(
|
|
202
|
+
JSON.parse(fs.readFileSync(proposalPath, "utf8")),
|
|
203
|
+
brandsDir,
|
|
204
|
+
);
|
|
205
|
+
// --json keeps Playwright's output off stdout.
|
|
206
|
+
const common = [
|
|
207
|
+
"--app",
|
|
208
|
+
flags.app,
|
|
209
|
+
...(flags.story ? ["--story", flags.story] : []),
|
|
210
|
+
"--brand",
|
|
211
|
+
built.brand,
|
|
212
|
+
"--brands",
|
|
213
|
+
brandsDir,
|
|
214
|
+
"--json",
|
|
215
|
+
];
|
|
216
|
+
const check = parseArgs(["check", ...common], process.cwd());
|
|
217
|
+
const checked = runPlaywright(check, "check");
|
|
218
|
+
const reportPath = path.join(check.workDir, "check.json");
|
|
219
|
+
const report = fs.existsSync(reportPath)
|
|
220
|
+
? JSON.parse(fs.readFileSync(reportPath, "utf8"))
|
|
221
|
+
: { ok: false, error: "the dry run produced no report" };
|
|
222
|
+
finish(check, report.ok && checked === 0);
|
|
223
|
+
if (!report.ok || checked !== 0) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
`The researched brand doesn't film with this story: ${report.error}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
// A pitch is a draft for one customer: 30 fps, about half the time.
|
|
229
|
+
const render = parseArgs(["render", ...common, "--draft"], process.cwd());
|
|
230
|
+
if (runPlaywright(render, "record") !== 0) {
|
|
231
|
+
finish(render, false);
|
|
232
|
+
throw new Error("Filming the pitch draft failed (see above)");
|
|
233
|
+
}
|
|
234
|
+
fs.mkdirSync(render.outDir, { recursive: true });
|
|
235
|
+
const result = encodeRecording(render.workDir, render.outDir);
|
|
236
|
+
finish(render, true);
|
|
237
|
+
// The video leaves the repo; its stills and sheet there go.
|
|
238
|
+
fs.mkdirSync(out, { recursive: true });
|
|
239
|
+
const video = path.join(out, path.basename(result.output));
|
|
240
|
+
const sheet = path.join(
|
|
241
|
+
out,
|
|
242
|
+
`${path.basename(result.output, ".mp4")}-sheet.jpg`,
|
|
243
|
+
);
|
|
244
|
+
fs.copyFileSync(result.output, video);
|
|
245
|
+
fs.copyFileSync(result.sheet, sheet);
|
|
246
|
+
for (const f of [result.output, result.sheet, ...result.stills]) {
|
|
247
|
+
fs.rmSync(f, { force: true });
|
|
248
|
+
}
|
|
249
|
+
const review = path.dirname(result.sheet);
|
|
250
|
+
if (fs.existsSync(review) && fs.readdirSync(review).length === 0) {
|
|
251
|
+
fs.rmdirSync(review);
|
|
252
|
+
}
|
|
253
|
+
const summary = { ...built, video, sheet, duration: result.duration };
|
|
254
|
+
if (argv.includes("--json")) {
|
|
255
|
+
console.log(JSON.stringify(summary));
|
|
256
|
+
} else {
|
|
257
|
+
console.log(
|
|
258
|
+
`Wrote ${video} (${result.duration.toFixed(1)} s) and ${sheet}`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return 0;
|
|
262
|
+
} finally {
|
|
263
|
+
fs.rmSync(brandsDir, { recursive: true, force: true });
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const run = () => {
|
|
268
|
+
if (process.argv[2] === "pitch") {
|
|
269
|
+
return runPitch(process.argv.slice(3));
|
|
270
|
+
}
|
|
271
|
+
if (process.argv[2] === "init") {
|
|
272
|
+
return runInit(process.argv.slice(3));
|
|
273
|
+
}
|
|
274
|
+
if (process.argv[2] === "pick") {
|
|
275
|
+
return runPick(process.argv.slice(3));
|
|
276
|
+
}
|
|
277
|
+
let args;
|
|
278
|
+
try {
|
|
279
|
+
args = parseArgs(process.argv.slice(2), process.cwd());
|
|
280
|
+
} catch (error) {
|
|
281
|
+
console.error(error.message);
|
|
282
|
+
return 2;
|
|
283
|
+
}
|
|
284
|
+
switch (args.command) {
|
|
285
|
+
case "check": {
|
|
286
|
+
const status = runPlaywright(args, "check");
|
|
287
|
+
// The report comes back through the work folder and goes with it.
|
|
288
|
+
const reportPath = path.join(args.workDir, "check.json");
|
|
289
|
+
const report = fs.existsSync(reportPath)
|
|
290
|
+
? JSON.parse(fs.readFileSync(reportPath, "utf8"))
|
|
291
|
+
: {
|
|
292
|
+
ok: false,
|
|
293
|
+
error:
|
|
294
|
+
"the dry run produced no report (Playwright did not run the demo test — see its output above)",
|
|
295
|
+
};
|
|
296
|
+
const ok = report.ok && status === 0;
|
|
297
|
+
finish(args, ok);
|
|
298
|
+
if (args.json) {
|
|
299
|
+
console.log(JSON.stringify(report, null, 2));
|
|
300
|
+
} else {
|
|
301
|
+
printReport(report);
|
|
302
|
+
}
|
|
303
|
+
return ok ? 0 : 1;
|
|
304
|
+
}
|
|
305
|
+
case "record": {
|
|
306
|
+
// The recording stays in the work folder for `encode`.
|
|
307
|
+
const status = runPlaywright(args, "record");
|
|
308
|
+
if (status !== 0) {
|
|
309
|
+
finish(args, false);
|
|
310
|
+
}
|
|
311
|
+
return status;
|
|
312
|
+
}
|
|
313
|
+
case "render": {
|
|
314
|
+
const status = runPlaywright(args, "record");
|
|
315
|
+
if (status !== 0) {
|
|
316
|
+
finish(args, false);
|
|
317
|
+
return status;
|
|
318
|
+
}
|
|
319
|
+
fs.mkdirSync(args.outDir, { recursive: true });
|
|
320
|
+
const result = encodeRecording(args.workDir, args.outDir);
|
|
321
|
+
finish(args, true);
|
|
322
|
+
if (args.json) {
|
|
323
|
+
// For CI: { output, duration, stills, sheet } on stdout, nothing else.
|
|
324
|
+
console.log(JSON.stringify(result));
|
|
325
|
+
} else {
|
|
326
|
+
console.log(`Wrote ${result.output} (${result.duration.toFixed(1)} s)`);
|
|
327
|
+
console.log(`Review: ${result.sheet} (one frame a second)`);
|
|
328
|
+
}
|
|
329
|
+
return 0;
|
|
330
|
+
}
|
|
331
|
+
case "encode": {
|
|
332
|
+
fs.mkdirSync(args.outDir, { recursive: true });
|
|
333
|
+
const { output, duration } = encodeRecording(args.workDir, args.outDir);
|
|
334
|
+
finish(args, true);
|
|
335
|
+
console.log(`Wrote ${output} (${duration.toFixed(1)} s)`);
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
case "list": {
|
|
339
|
+
// For people and the workflows: which stories exist, which is primary.
|
|
340
|
+
const stories = listStories(args.demoDir);
|
|
341
|
+
if (args.json) {
|
|
342
|
+
console.log(JSON.stringify(stories));
|
|
343
|
+
} else if (stories.length === 0) {
|
|
344
|
+
console.log("No stories yet in demo/stories/.");
|
|
345
|
+
} else {
|
|
346
|
+
for (const s of stories) {
|
|
347
|
+
console.log(
|
|
348
|
+
`${s.primary ? "* " : " "}${s.name}${s.description ? `: ${s.description}` : ""}${s.extends ? ` (${s.extends}, for ${s.brand})` : ""}`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return 0;
|
|
353
|
+
}
|
|
354
|
+
case "stills":
|
|
355
|
+
console.log(`Stills from ${extractStillsFrom(args.outDir)}`);
|
|
356
|
+
return 0;
|
|
357
|
+
case "brand-needs": {
|
|
358
|
+
const status = runPlaywright(args, "needs");
|
|
359
|
+
const needsPath = path.join(args.workDir, "needs.json");
|
|
360
|
+
const needs =
|
|
361
|
+
status === 0 && fs.existsSync(needsPath)
|
|
362
|
+
? JSON.parse(fs.readFileSync(needsPath, "utf8"))
|
|
363
|
+
: null;
|
|
364
|
+
finish(args, Boolean(needs));
|
|
365
|
+
if (!needs) {
|
|
366
|
+
throw new Error("Couldn't read the adapter's brand needs (see above)");
|
|
367
|
+
}
|
|
368
|
+
if (args.json) {
|
|
369
|
+
console.log(JSON.stringify(needs, null, 2));
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
const lines = (entries) =>
|
|
373
|
+
Object.entries(entries).map(
|
|
374
|
+
([key, need]) =>
|
|
375
|
+
` ${key}${need.optional ? " (optional)" : ""}: ${need.what}`,
|
|
376
|
+
);
|
|
377
|
+
console.log(`Every brand, for the video:`);
|
|
378
|
+
for (const [key, what] of Object.entries(needs.video)) {
|
|
379
|
+
console.log(` ${key}: ${what}`);
|
|
380
|
+
}
|
|
381
|
+
const own = [
|
|
382
|
+
...lines(
|
|
383
|
+
Object.fromEntries(
|
|
384
|
+
Object.entries(needs.appNeeds.config).map(([k, v]) => [
|
|
385
|
+
`config.${k}`,
|
|
386
|
+
v,
|
|
387
|
+
]),
|
|
388
|
+
),
|
|
389
|
+
),
|
|
390
|
+
...lines(
|
|
391
|
+
Object.fromEntries(
|
|
392
|
+
Object.entries(needs.appNeeds.assets).map(([k, v]) => [
|
|
393
|
+
`assets.${k}`,
|
|
394
|
+
v,
|
|
395
|
+
]),
|
|
396
|
+
),
|
|
397
|
+
),
|
|
398
|
+
];
|
|
399
|
+
console.log(`\n${needs.app} itself:`);
|
|
400
|
+
console.log(
|
|
401
|
+
own.length
|
|
402
|
+
? own.join("\n")
|
|
403
|
+
: " (nothing listed: the adapter has no brandNeeds)",
|
|
404
|
+
);
|
|
405
|
+
return 0;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return 2;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const main = async () => {
|
|
412
|
+
try {
|
|
413
|
+
return await run();
|
|
414
|
+
} catch (error) {
|
|
415
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
416
|
+
if (process.argv.includes("--json")) {
|
|
417
|
+
console.log(JSON.stringify({ ok: false, error: message }));
|
|
418
|
+
} else {
|
|
419
|
+
console.error(message);
|
|
420
|
+
}
|
|
421
|
+
return 1;
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
process.exitCode = await main();
|
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,30 @@
|
|
|
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
|
+
/** The repo's apps (folders with a playwright.config), "." for the root. */
|
|
14
|
+
export function findApps(root: string): string[];
|
|
15
|
+
export function initProject(
|
|
16
|
+
root: string,
|
|
17
|
+
options?: { dryRun?: boolean; label?: boolean },
|
|
18
|
+
): InitResult & { apps: string[] };
|
|
19
|
+
export function initApp(
|
|
20
|
+
appDir: string,
|
|
21
|
+
options?: { dryRun?: boolean },
|
|
22
|
+
): InitResult;
|
|
23
|
+
export function adapterSkeleton(options: {
|
|
24
|
+
app: string;
|
|
25
|
+
command?: string;
|
|
26
|
+
url?: string;
|
|
27
|
+
env: Record<string, string>;
|
|
28
|
+
mocks: { name: string; file: string }[];
|
|
29
|
+
demoDir: string;
|
|
30
|
+
}): string;
|