@navipartner/demo-video 0.4.0 → 0.5.1

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/bin/encode.mjs CHANGED
@@ -1,187 +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
- };
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 CHANGED
@@ -1,24 +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
- 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;
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;