@foldspace_npm/harness 0.1.30 → 0.1.32
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/CLAUDE.md +42 -11
- package/README.md +25 -2
- package/bin/ask.mjs +16 -145
- package/bin/attach.mjs +2 -0
- package/bin/cli.mjs +22 -0
- package/bin/inject.mjs +2 -14
- package/bin/record.mjs +412 -0
- package/package.json +1 -1
- package/src/attach-daemon.mjs +18 -0
- package/src/badge-auto.mjs +38 -1
- package/src/cdp-client.mjs +7 -1
- package/src/chrome-binary.mjs +28 -0
- package/src/cli-help.mjs +1 -0
- package/src/cli-registry.mjs +53 -3
- package/src/init.mjs +170 -16
- package/src/panel-driver.mjs +283 -0
- package/src/session-events.mjs +1 -0
- package/src/upgrade.mjs +87 -1
- package/src/video/README.md +29 -0
- package/src/video/assets/foldspace-mark.svg +27 -0
- package/src/video/assets/logo-dark.svg +68 -0
- package/src/video/assets/logo-light.svg +56 -0
- package/src/video/brand.mjs +46 -0
- package/src/video/compose.mjs +476 -0
- package/src/video/composer/composer.mjs +554 -0
- package/src/video/composer/index.html +13 -0
- package/src/video/fonts/OFL.txt +93 -0
- package/src/video/fonts/Poppins-ExtraBold.woff2 +0 -0
- package/src/video/fonts/Poppins-Regular.woff2 +0 -0
- package/src/video/fonts/Poppins-SemiBold.woff2 +0 -0
- package/src/video/recorder.mjs +331 -0
- package/src/video/scenes.mjs +253 -0
- package/src/video/timeline.mjs +726 -0
- package/src/video/vendor/mp4-muxer.LICENSE +21 -0
- package/src/video/vendor/mp4-muxer.min.mjs +5 -0
- package/templates/agent-starter/AGENTS.md +24 -0
- package/templates/agent-starter/README.md +5 -0
- package/templates/agent-starter/claude-settings.json +1 -0
- package/templates/agent-starter/gitignore +3 -0
- package/templates/agent-starter/skills/share-a-video/SKILL.md +235 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
// Making the MP4 from a recording, in a hidden Chrome.
|
|
2
|
+
//
|
|
3
|
+
// No ffmpeg and nothing to install: a separate headless instance of the same
|
|
4
|
+
// Chrome `inject` uses loads the composer page from a short-lived server on
|
|
5
|
+
// 127.0.0.1, draws the video on a canvas, encodes H.264 with WebCodecs and
|
|
6
|
+
// posts the MP4 back. The headless Chrome gets a throwaway profile, and
|
|
7
|
+
// --use-mock-keychain --password-store=basic: without them macOS asks for the
|
|
8
|
+
// keychain password the moment Chrome starts.
|
|
9
|
+
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import crypto from "node:crypto";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import http from "node:http";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
import { findChrome } from "../chrome-binary.mjs";
|
|
19
|
+
import { scenePlace } from "./scenes.mjs";
|
|
20
|
+
import { TIMING, fitToLength, layoutClips, sceneTimeMap } from "./timeline.mjs";
|
|
21
|
+
|
|
22
|
+
const videoRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
|
|
24
|
+
export const PRESETS = Object.freeze({
|
|
25
|
+
landscape: { width: 1920, height: 1080, bitrate: 8_000_000 },
|
|
26
|
+
square: { width: 1080, height: 1080, bitrate: 6_000_000 },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export class VideoError extends Error {
|
|
30
|
+
constructor(code, message) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.code = code;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ------------------------------------------------------------ recording on disk
|
|
37
|
+
|
|
38
|
+
const RECORD_AGAIN = "Record again: `npx foldspace record`.";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A recording folder: record.json plus scene-<n>/{events.json, frames.json,
|
|
42
|
+
* frames/}. Only a finished one makes a video: record.json says `complete`
|
|
43
|
+
* once every scene's answer settled. A scene that failed or was cut off would
|
|
44
|
+
* put a caption over an unanswered "Thinking...".
|
|
45
|
+
*/
|
|
46
|
+
export function loadRecording(recordDir) {
|
|
47
|
+
const manifestPath = path.join(recordDir, "record.json");
|
|
48
|
+
let manifest;
|
|
49
|
+
try {
|
|
50
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
51
|
+
} catch {
|
|
52
|
+
throw new VideoError("no_recording", `No recording in ${recordDir} (record.json is missing).`);
|
|
53
|
+
}
|
|
54
|
+
if (manifest.complete !== true) {
|
|
55
|
+
const stopped = (manifest.scenes || []).find((scene) => scene.ok !== true);
|
|
56
|
+
throw new VideoError(
|
|
57
|
+
"recording_incomplete",
|
|
58
|
+
stopped
|
|
59
|
+
? `Scene ${stopped.n} of this recording never finished${stopped.reason ? ` (${stopped.reason})` : ""}, so it makes no video. ${RECORD_AGAIN}`
|
|
60
|
+
: `This recording stopped before it finished, so it makes no video. ${RECORD_AGAIN}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const scenes = (manifest.scenes || []).map((scene) => {
|
|
64
|
+
const dir = path.join(recordDir, scene.dir);
|
|
65
|
+
try {
|
|
66
|
+
const events = JSON.parse(fs.readFileSync(path.join(dir, "events.json"), "utf8"));
|
|
67
|
+
const frames = JSON.parse(fs.readFileSync(path.join(dir, "frames.json"), "utf8"));
|
|
68
|
+
return { ...scene, events, frames };
|
|
69
|
+
} catch {
|
|
70
|
+
throw new VideoError("recording_incomplete", `Scene ${scene.n} of this recording is missing its files. ${RECORD_AGAIN}`);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
return { dir: recordDir, manifest, scenes };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A scene's events and picture times in seconds, for the timeline. */
|
|
77
|
+
export function sceneTimes(scene) {
|
|
78
|
+
const seconds = (ms) => (ms == null ? null : ms / 1000);
|
|
79
|
+
const { events } = scene;
|
|
80
|
+
return {
|
|
81
|
+
events: {
|
|
82
|
+
typingStart: seconds(events.typingStart),
|
|
83
|
+
sent: seconds(events.sent),
|
|
84
|
+
firstReply: seconds(events.firstReply),
|
|
85
|
+
settled: seconds(events.settledAt),
|
|
86
|
+
},
|
|
87
|
+
frameTimes: scene.frames.map((frame) => frame.t / 1000),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const CAPTIONS_ONLY =
|
|
92
|
+
"Only the product name, the capabilities, the lines and the end card can change without recording again.";
|
|
93
|
+
|
|
94
|
+
/** The recording must be of the questions in the scenes file now; the rest of each caption may change. */
|
|
95
|
+
export function checkRecordingMatches(recording, config) {
|
|
96
|
+
const recorded = recording.scenes.map((scene) => scene.question);
|
|
97
|
+
const wanted = config.scenes.map((scene) => scene.question);
|
|
98
|
+
if (recorded.length !== wanted.length) {
|
|
99
|
+
return `The recording has ${recorded.length} scene(s) and the scenes file ${wanted.length}. ${CAPTIONS_ONLY}`;
|
|
100
|
+
}
|
|
101
|
+
const changed = wanted.findIndex((question, index) => question !== recorded[index]);
|
|
102
|
+
if (changed >= 0) {
|
|
103
|
+
return `Scene ${changed + 1}'s question is not the one recorded. ${CAPTIONS_ONLY}`;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ------------------------------------------------------------ plan
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Everything the composer page needs, as JSON: the frame size, the clips in
|
|
112
|
+
* order with their time maps and captions, and where the poster is taken.
|
|
113
|
+
* The opening card meets the product's agent; each caption shows the scene's
|
|
114
|
+
* capabilities, where the video is ("2 of 4"), the question and its line.
|
|
115
|
+
* The colours are Foldspace's own (brand.mjs), never the app's.
|
|
116
|
+
*/
|
|
117
|
+
export function buildPlan({ recording, config, preset = "landscape", endCard = true, timing = TIMING }) {
|
|
118
|
+
const size = PRESETS[preset];
|
|
119
|
+
if (!size) throw new VideoError("bad_preset", `Unknown preset '${preset}': landscape or square.`);
|
|
120
|
+
const timed = recording.scenes.map(sceneTimes);
|
|
121
|
+
const fit = fitToLength(timed, { titleCard: true, endCard, timing });
|
|
122
|
+
const product = config.product;
|
|
123
|
+
const total = config.scenes.length;
|
|
124
|
+
const clips = [{ kind: "opening", product, fadeInS: 0.5, durationS: timing.openingS }];
|
|
125
|
+
recording.scenes.forEach((scene, index) => {
|
|
126
|
+
const map = sceneTimeMap(timed[index].events, timed[index].frameTimes, { k: fit.k, timing });
|
|
127
|
+
const { question, capabilities, line } = config.scenes[index];
|
|
128
|
+
clips.push({
|
|
129
|
+
kind: "scene",
|
|
130
|
+
n: index + 1,
|
|
131
|
+
dir: scene.dir,
|
|
132
|
+
frames: scene.frames.map((frame) => frame.file),
|
|
133
|
+
frameTimes: timed[index].frameTimes,
|
|
134
|
+
segments: map.segments,
|
|
135
|
+
caption: map.caption,
|
|
136
|
+
captionText: { capabilities, place: scenePlace(index + 1, total), question, line },
|
|
137
|
+
viewport: scene.events.viewport || null,
|
|
138
|
+
panel: (scene.events.panel || []).map(({ x, y, width, height }) => ({ x, y, width, height })),
|
|
139
|
+
durationS: map.durationS,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
if (endCard) {
|
|
143
|
+
clips.push({
|
|
144
|
+
kind: "end",
|
|
145
|
+
eyebrow: `${product} × Foldspace`,
|
|
146
|
+
title: "An agent that does the work.",
|
|
147
|
+
sub: "Built with Foldspace. foldspace.ai",
|
|
148
|
+
mark: true,
|
|
149
|
+
durationS: timing.endS,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
const layout = layoutClips(
|
|
153
|
+
clips.map((clip) => clip.durationS),
|
|
154
|
+
timing.dissolveS,
|
|
155
|
+
);
|
|
156
|
+
// The poster: the first scene's answer with its caption fully in.
|
|
157
|
+
const first = clips.findIndex((clip) => clip.kind === "scene");
|
|
158
|
+
const posterS = first >= 0 ? layout.starts[first] + (clips[first].caption.inEnd + clips[first].caption.outStart) / 2 : 1;
|
|
159
|
+
return {
|
|
160
|
+
...size,
|
|
161
|
+
preset,
|
|
162
|
+
product,
|
|
163
|
+
clips,
|
|
164
|
+
layout,
|
|
165
|
+
posterS,
|
|
166
|
+
fit,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ------------------------------------------------------------ hidden Chrome
|
|
171
|
+
|
|
172
|
+
const HEADLESS_ARGS = [
|
|
173
|
+
"--headless=new",
|
|
174
|
+
"--use-mock-keychain",
|
|
175
|
+
"--password-store=basic",
|
|
176
|
+
"--no-first-run",
|
|
177
|
+
"--no-default-browser-check",
|
|
178
|
+
"--disable-extensions",
|
|
179
|
+
"--disable-sync",
|
|
180
|
+
"--disable-default-apps",
|
|
181
|
+
"--disable-background-networking",
|
|
182
|
+
"--disable-component-update",
|
|
183
|
+
"--disable-background-timer-throttling",
|
|
184
|
+
"--disable-renderer-backgrounding",
|
|
185
|
+
"--mute-audio",
|
|
186
|
+
"--hide-scrollbars",
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
// Every hidden Chrome this process has started and not yet closed. A run that
|
|
190
|
+
// is stopped (Ctrl-C, a timeout) closes them through closeAllHeadless(); one
|
|
191
|
+
// that exits any other way kills them on the way out. Left behind, each would
|
|
192
|
+
// keep running with a profile in the temp folder.
|
|
193
|
+
const launched = new Set();
|
|
194
|
+
let exitHookSet = false;
|
|
195
|
+
|
|
196
|
+
function killLeftovers() {
|
|
197
|
+
for (const chrome of launched) chrome.killNow();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Close every hidden Chrome this process started (for an interrupted run). */
|
|
201
|
+
export async function closeAllHeadless() {
|
|
202
|
+
await Promise.allSettled([...launched].map((chrome) => chrome.close()));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Start a hidden Chrome on `url` with a throwaway profile. `extraArgs` lets a
|
|
207
|
+
* test add a debugging port. Returns { child, profileDir, close() }.
|
|
208
|
+
*/
|
|
209
|
+
export function launchHeadless(chromePath, url, { extraArgs = [] } = {}) {
|
|
210
|
+
const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), "foldspace-video-chrome-"));
|
|
211
|
+
const child = spawn(chromePath, [...HEADLESS_ARGS, `--user-data-dir=${profileDir}`, ...extraArgs, url], {
|
|
212
|
+
stdio: "ignore",
|
|
213
|
+
});
|
|
214
|
+
let exited = false;
|
|
215
|
+
child.on("exit", () => {
|
|
216
|
+
exited = true;
|
|
217
|
+
});
|
|
218
|
+
child.on("error", () => {
|
|
219
|
+
exited = true;
|
|
220
|
+
});
|
|
221
|
+
const chrome = {
|
|
222
|
+
child,
|
|
223
|
+
profileDir,
|
|
224
|
+
get exited() {
|
|
225
|
+
return exited;
|
|
226
|
+
},
|
|
227
|
+
// Synchronous, for process exit: no time to wait for a clean close.
|
|
228
|
+
killNow() {
|
|
229
|
+
launched.delete(chrome);
|
|
230
|
+
if (!exited) {
|
|
231
|
+
try {
|
|
232
|
+
child.kill("SIGKILL");
|
|
233
|
+
} catch {
|
|
234
|
+
// already gone
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
fs.rmSync(profileDir, { recursive: true, force: true });
|
|
239
|
+
} catch {
|
|
240
|
+
// Chrome may still hold it for a moment; the temp folder is the OS's to clear
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
async close() {
|
|
244
|
+
launched.delete(chrome);
|
|
245
|
+
if (!exited) {
|
|
246
|
+
child.kill("SIGTERM");
|
|
247
|
+
for (let tick = 0; tick < 30 && !exited; tick++) await new Promise((resolve) => setTimeout(resolve, 100));
|
|
248
|
+
if (!exited) child.kill("SIGKILL");
|
|
249
|
+
}
|
|
250
|
+
// Chrome can still be letting go of its profile for a moment.
|
|
251
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
252
|
+
try {
|
|
253
|
+
fs.rmSync(profileDir, { recursive: true, force: true });
|
|
254
|
+
break;
|
|
255
|
+
} catch {
|
|
256
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
launched.add(chrome);
|
|
262
|
+
if (!exitHookSet) {
|
|
263
|
+
exitHookSet = true;
|
|
264
|
+
process.on("exit", killLeftovers);
|
|
265
|
+
}
|
|
266
|
+
return chrome;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const TYPES = {
|
|
270
|
+
".html": "text/html; charset=utf-8",
|
|
271
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
272
|
+
".js": "text/javascript; charset=utf-8",
|
|
273
|
+
".json": "application/json",
|
|
274
|
+
".woff2": "font/woff2",
|
|
275
|
+
".svg": "image/svg+xml",
|
|
276
|
+
".jpg": "image/jpeg",
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// What the composer page may read from this package.
|
|
280
|
+
const STATIC_ROOTS = ["composer", "vendor", "fonts", "assets"];
|
|
281
|
+
const STATIC_FILES = ["timeline.mjs", "brand.mjs"];
|
|
282
|
+
|
|
283
|
+
function inside(root, relative) {
|
|
284
|
+
const full = path.resolve(root, relative);
|
|
285
|
+
return full === root || full.startsWith(`${root}${path.sep}`) ? full : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The page's server: 127.0.0.1 only, every path under a random token, so no
|
|
290
|
+
* other page or process on the machine can guess its way to the recorded
|
|
291
|
+
* pictures (they show the customer's real data). Resolves `done` when the
|
|
292
|
+
* page has posted the video, or fails with what the page reported.
|
|
293
|
+
*/
|
|
294
|
+
function startServer({ plan, recordDir: givenDir, onProgress }) {
|
|
295
|
+
const recordDir = path.resolve(givenDir);
|
|
296
|
+
const token = crypto.randomBytes(16).toString("hex");
|
|
297
|
+
const posted = {};
|
|
298
|
+
let settle;
|
|
299
|
+
const finished = new Promise((resolve, reject) => {
|
|
300
|
+
settle = { resolve, reject };
|
|
301
|
+
});
|
|
302
|
+
const server = http.createServer((request, response) => {
|
|
303
|
+
const url = new URL(request.url, "http://127.0.0.1");
|
|
304
|
+
const prefix = `/${token}/`;
|
|
305
|
+
if (!url.pathname.startsWith(prefix)) {
|
|
306
|
+
response.writeHead(404).end();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const rest = decodeURIComponent(url.pathname.slice(prefix.length));
|
|
310
|
+
if (request.method === "POST") {
|
|
311
|
+
const chunks = [];
|
|
312
|
+
request.on("data", (chunk) => chunks.push(chunk));
|
|
313
|
+
request.on("end", () => {
|
|
314
|
+
const body = Buffer.concat(chunks);
|
|
315
|
+
response.writeHead(204).end();
|
|
316
|
+
if (rest === "progress") {
|
|
317
|
+
try {
|
|
318
|
+
onProgress?.(JSON.parse(body.toString("utf8")));
|
|
319
|
+
} catch {
|
|
320
|
+
// progress is only for the log
|
|
321
|
+
}
|
|
322
|
+
} else if (rest === "fail") {
|
|
323
|
+
let report = {};
|
|
324
|
+
try {
|
|
325
|
+
report = JSON.parse(body.toString("utf8"));
|
|
326
|
+
} catch {
|
|
327
|
+
// keep the generic message
|
|
328
|
+
}
|
|
329
|
+
settle.reject(new VideoError(report.code || "page_error", report.message || "The composer page failed."));
|
|
330
|
+
} else if (rest === "done") {
|
|
331
|
+
try {
|
|
332
|
+
settle.resolve({ ...posted, done: JSON.parse(body.toString("utf8")) });
|
|
333
|
+
} catch {
|
|
334
|
+
settle.resolve({ ...posted, done: {} });
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
posted[rest] = body;
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
let file = null;
|
|
343
|
+
if (rest === "plan.json") {
|
|
344
|
+
response.writeHead(200, { "content-type": TYPES[".json"], "cache-control": "no-store" });
|
|
345
|
+
response.end(JSON.stringify(plan));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (rest.startsWith("rec/")) file = inside(recordDir, rest.slice(4));
|
|
349
|
+
else if (STATIC_FILES.includes(rest) || STATIC_ROOTS.includes(rest.split("/")[0])) file = inside(videoRoot, rest);
|
|
350
|
+
if (!file || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
351
|
+
response.writeHead(404).end();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
response.writeHead(200, {
|
|
355
|
+
"content-type": TYPES[path.extname(file)] || "application/octet-stream",
|
|
356
|
+
"cache-control": "no-store",
|
|
357
|
+
});
|
|
358
|
+
fs.createReadStream(file).pipe(response);
|
|
359
|
+
});
|
|
360
|
+
return new Promise((resolve) => {
|
|
361
|
+
server.listen(0, "127.0.0.1", () => {
|
|
362
|
+
const { port } = server.address();
|
|
363
|
+
resolve({
|
|
364
|
+
url: `http://127.0.0.1:${port}/${token}/composer/index.html`,
|
|
365
|
+
finished,
|
|
366
|
+
close: () => new Promise((done) => server.close(() => done())),
|
|
367
|
+
fail: (error) => settle.reject(error),
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Run the composer page in a hidden Chrome; returns { mp4, poster, done }. */
|
|
374
|
+
export async function renderInChrome({ plan, recordDir, chromePath, onProgress, timeoutMs = 15 * 60 * 1000 }) {
|
|
375
|
+
const server = await startServer({ plan, recordDir, onProgress });
|
|
376
|
+
const chrome = launchHeadless(chromePath, server.url);
|
|
377
|
+
chrome.child.on("exit", (code, signal) =>
|
|
378
|
+
server.fail(
|
|
379
|
+
new VideoError(
|
|
380
|
+
"chrome_exited",
|
|
381
|
+
`The hidden Chrome closed before the video was made (${signal ? `stopped by ${signal}` : `exit code ${code}`}).`,
|
|
382
|
+
),
|
|
383
|
+
),
|
|
384
|
+
);
|
|
385
|
+
chrome.child.on("error", (error) =>
|
|
386
|
+
server.fail(new VideoError("chrome_failed", `Could not start Chrome at ${chromePath}: ${error.message}`)),
|
|
387
|
+
);
|
|
388
|
+
const timer = setTimeout(
|
|
389
|
+
() => server.fail(new VideoError("timeout", `The video was not made within ${Math.round(timeoutMs / 60000)} minutes.`)),
|
|
390
|
+
timeoutMs,
|
|
391
|
+
);
|
|
392
|
+
try {
|
|
393
|
+
const result = await server.finished;
|
|
394
|
+
if (!result.out) throw new VideoError("page_error", "The composer page finished without a video.");
|
|
395
|
+
return { mp4: result.out, poster: result.poster || null, done: result.done };
|
|
396
|
+
} finally {
|
|
397
|
+
clearTimeout(timer);
|
|
398
|
+
await chrome.close();
|
|
399
|
+
await server.close();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const PROBE_PAGE = `<!doctype html><meta charset="utf-8"><script type="module">
|
|
404
|
+
const configs = ["avc1.640028", "avc1.4d0028", "avc1.42e028"].map((codec) => ({
|
|
405
|
+
codec, width: 1920, height: 1080, bitrate: 8000000, framerate: 30, avc: { format: "avc" } }));
|
|
406
|
+
let codec = null;
|
|
407
|
+
if (typeof VideoEncoder === "function") {
|
|
408
|
+
for (const config of configs) {
|
|
409
|
+
try { if ((await VideoEncoder.isConfigSupported(config)).supported) { codec = config.codec; break; } } catch {}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
await fetch("result", { method: "POST", body: JSON.stringify({ codec }) });
|
|
413
|
+
</script>`;
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Whether this Chrome can encode H.264, asked in a hidden Chrome the same way
|
|
417
|
+
* the composer starts one: { ok, codec } or { ok: false, code, message }.
|
|
418
|
+
* Run before recording, so a Chrome that cannot make the video says so before
|
|
419
|
+
* minutes of recording rather than after.
|
|
420
|
+
*/
|
|
421
|
+
export async function probeEncoder({ chromePath = findChrome(), timeoutMs = 60_000 } = {}) {
|
|
422
|
+
if (!chromePath) return { ok: false, code: "no_chrome", message: needsChrome(null) };
|
|
423
|
+
const token = crypto.randomBytes(16).toString("hex");
|
|
424
|
+
let answer;
|
|
425
|
+
const answered = new Promise((resolve) => {
|
|
426
|
+
answer = resolve;
|
|
427
|
+
});
|
|
428
|
+
const server = http.createServer((request, response) => {
|
|
429
|
+
if (request.url === `/${token}/` && request.method === "GET") {
|
|
430
|
+
response.writeHead(200, { "content-type": TYPES[".html"] }).end(PROBE_PAGE);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (request.url === `/${token}/result` && request.method === "POST") {
|
|
434
|
+
const chunks = [];
|
|
435
|
+
request.on("data", (chunk) => chunks.push(chunk));
|
|
436
|
+
request.on("end", () => {
|
|
437
|
+
response.writeHead(204).end();
|
|
438
|
+
try {
|
|
439
|
+
answer(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
440
|
+
} catch {
|
|
441
|
+
answer({ codec: null });
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
response.writeHead(404).end();
|
|
447
|
+
});
|
|
448
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
449
|
+
const chrome = launchHeadless(chromePath, `http://127.0.0.1:${server.address().port}/${token}/`);
|
|
450
|
+
chrome.child.on("error", () => answer({ failed: true }));
|
|
451
|
+
chrome.child.on("exit", () => answer({ failed: true }));
|
|
452
|
+
const timer = setTimeout(() => answer({ failed: true, timeout: true }), timeoutMs);
|
|
453
|
+
try {
|
|
454
|
+
const result = await answered;
|
|
455
|
+
if (result.failed) {
|
|
456
|
+
return {
|
|
457
|
+
ok: false,
|
|
458
|
+
code: "chrome_failed",
|
|
459
|
+
message: `Chrome at ${chromePath} did not start in the background${result.timeout ? " in time" : ""}, so the video cannot be made.`,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
if (!result.codec) return { ok: false, code: "no_h264", message: needsChrome(chromePath) };
|
|
463
|
+
return { ok: true, codec: result.codec, chromePath };
|
|
464
|
+
} finally {
|
|
465
|
+
clearTimeout(timer);
|
|
466
|
+
await chrome.close();
|
|
467
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** The message for a Chrome that cannot make the video. */
|
|
472
|
+
export function needsChrome(chromePath) {
|
|
473
|
+
return chromePath
|
|
474
|
+
? `The video needs Google Chrome: this Chrome (${chromePath}) cannot encode H.264 video, as some Chromium builds cannot. Install Google Chrome, or set CHROME_PATH to it.`
|
|
475
|
+
: "The video needs Google Chrome, and none was found. Install Google Chrome, or set CHROME_PATH to it.";
|
|
476
|
+
}
|