@alpha2-sh/node 0.0.0 → 0.1.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 +81 -1
- package/dist/index.d.ts +814 -0
- package/dist/index.js +1054 -0
- package/dist/protocol.d.ts +64 -0
- package/dist/protocol.js +97 -0
- package/package.json +30 -7
- package/index.js +0 -1
package/dist/index.js
ADDED
|
@@ -0,0 +1,1054 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { constants, existsSync, statSync } from "node:fs";
|
|
3
|
+
import { access } from "node:fs/promises";
|
|
4
|
+
import { delimiter, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { failureForCode } from "alpha2/export-failure";
|
|
8
|
+
import { MAX_PROJECT_JSON_BYTES, validateProject, validateProjectJson, } from "alpha2/project";
|
|
9
|
+
import { createRequest, loaderRefused, parseHostMessage, } from "./protocol.js";
|
|
10
|
+
export { PROTOCOL_VERSION } from "./protocol.js";
|
|
11
|
+
const VIDEO_DECODE_MODES = [
|
|
12
|
+
"software",
|
|
13
|
+
"auto",
|
|
14
|
+
"hardware-required",
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* Refuses a `field` that is set and not a {@link VideoDecodeMode}: the host would refuse it at its
|
|
18
|
+
* start, after the process was made.
|
|
19
|
+
*/
|
|
20
|
+
function checkVideoDecode(field, value) {
|
|
21
|
+
if (value !== undefined &&
|
|
22
|
+
!VIDEO_DECODE_MODES.includes(value))
|
|
23
|
+
throw new Alpha2HostError("INVALID_INPUT", `${field} must be software, auto, or hardware-required`);
|
|
24
|
+
}
|
|
25
|
+
/** Every {@link QualityLevel}, lowest first. */
|
|
26
|
+
export const QUALITY_LEVELS = [
|
|
27
|
+
"very-low",
|
|
28
|
+
"low",
|
|
29
|
+
"medium",
|
|
30
|
+
"high",
|
|
31
|
+
"very-high",
|
|
32
|
+
];
|
|
33
|
+
/** Bytes of the host's standard error an error keeps. */
|
|
34
|
+
export const STDERR_TAIL_BYTES = 16_384;
|
|
35
|
+
/**
|
|
36
|
+
* Every error this client rejects with. Its `failure` classifies it for a retry policy
|
|
37
|
+
* (`alpha2/export-retry`): the host's own report when the host sent one, and otherwise the class
|
|
38
|
+
* its code is evidence of (`failureForCode`), as the host would have classified the same code, so a
|
|
39
|
+
* refusal made here or before an export started is as final as the host's.
|
|
40
|
+
*/
|
|
41
|
+
export class Alpha2HostError extends Error {
|
|
42
|
+
/** A machine-readable code, such as `HOST_NOT_FOUND`, `INVALID_INPUT` or `DECODE_STALLED`. */
|
|
43
|
+
code;
|
|
44
|
+
failure;
|
|
45
|
+
stderr;
|
|
46
|
+
exitCode;
|
|
47
|
+
signal;
|
|
48
|
+
constructor(code, message, options) {
|
|
49
|
+
super(message, options);
|
|
50
|
+
this.name = "Alpha2HostError";
|
|
51
|
+
this.code = code;
|
|
52
|
+
this.failure =
|
|
53
|
+
options?.failure ??
|
|
54
|
+
failureForCode(code, {
|
|
55
|
+
message,
|
|
56
|
+
origin: "node",
|
|
57
|
+
backend: "native",
|
|
58
|
+
phase: options?.phase ?? "prepare",
|
|
59
|
+
...(options?.stderr === undefined ? {} : { stderr: options.stderr }),
|
|
60
|
+
...(options?.exitCode === undefined
|
|
61
|
+
? {}
|
|
62
|
+
: { exitCode: options.exitCode }),
|
|
63
|
+
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
|
64
|
+
});
|
|
65
|
+
if (options?.stderr !== undefined)
|
|
66
|
+
this.stderr = options.stderr;
|
|
67
|
+
if (options?.exitCode !== undefined)
|
|
68
|
+
this.exitCode = options.exitCode;
|
|
69
|
+
if (options?.signal !== undefined)
|
|
70
|
+
this.signal = options.signal;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** An export refused before it rendered: the project's media is not ready. */
|
|
74
|
+
export class Alpha2ConformError extends Alpha2HostError {
|
|
75
|
+
/** What each source would read and what blocks it. */
|
|
76
|
+
report;
|
|
77
|
+
constructor(message, report, options) {
|
|
78
|
+
super("CONFORM_FAILED", message, options);
|
|
79
|
+
this.name = "Alpha2ConformError";
|
|
80
|
+
this.report = report;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** The host could not create its GPU device (`GPU_INIT_FAILED`). */
|
|
84
|
+
export class Alpha2GpuInitializationError extends Alpha2HostError {
|
|
85
|
+
constructor(message, options) {
|
|
86
|
+
super("GPU_INIT_FAILED", message, options);
|
|
87
|
+
this.name = "Alpha2GpuInitializationError";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** The project failed validation before a host started (`INVALID_INPUT`); `problems` lists why. */
|
|
91
|
+
export class Alpha2ProjectValidationError extends Alpha2HostError {
|
|
92
|
+
problems;
|
|
93
|
+
constructor(problems) {
|
|
94
|
+
super("INVALID_INPUT", `invalid alpha2 project:\n${problems.join("\n")}`);
|
|
95
|
+
this.name = "Alpha2ProjectValidationError";
|
|
96
|
+
this.problems = problems;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** The request's `signal` aborted it (`ABORTED`). */
|
|
100
|
+
export class Alpha2AbortError extends Alpha2HostError {
|
|
101
|
+
constructor() {
|
|
102
|
+
super("ABORTED", "alpha2 export was aborted");
|
|
103
|
+
this.name = "Alpha2AbortError";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The file name `alpha2-host` has on `platform`: Windows executables carry `.exe`, and nothing
|
|
108
|
+
* there adds it to a path that lacks it.
|
|
109
|
+
*/
|
|
110
|
+
export function hostExecutableName(platform = process.platform) {
|
|
111
|
+
return platform === "win32" ? "alpha2-host.exe" : "alpha2-host";
|
|
112
|
+
}
|
|
113
|
+
/** Where the native archives that carry `alpha2-host` are published. */
|
|
114
|
+
const HOST_RELEASES_URL = "https://github.com/alpha2-sh/alpha2/releases";
|
|
115
|
+
/**
|
|
116
|
+
* Locates `alpha2-host` without loading Electron. The package does not ship the binary: it comes
|
|
117
|
+
* in the platform's native archive on https://github.com/alpha2-sh/alpha2/releases, as
|
|
118
|
+
* `bin/alpha2-host` beside the `lib/` it loads. Native archives and Electron applications should
|
|
119
|
+
* keep `bin/` and `lib/` together and unpack both from ASAR. Resolution order is explicit path,
|
|
120
|
+
* `ALPHA2_HOST_PATH`, Electron resources (`bin/`, then the directory itself), this package's own
|
|
121
|
+
* `bin/`, then `PATH`.
|
|
122
|
+
*
|
|
123
|
+
* Throws an {@link Alpha2HostError}: `INVALID_INPUT` for an empty `hostPath`, `HOST_NOT_FOUND` when
|
|
124
|
+
* an explicit path is not a file or no candidate exists; the latter message names the native
|
|
125
|
+
* archive. An empty `ALPHA2_HOST_PATH` is unset.
|
|
126
|
+
*/
|
|
127
|
+
export function resolveHostBinary(options = {}) {
|
|
128
|
+
const cwd = options.cwd ?? process.cwd();
|
|
129
|
+
const executable = hostExecutableName();
|
|
130
|
+
if (options.hostPath !== undefined && options.hostPath.trim() === "")
|
|
131
|
+
throw new Alpha2HostError("INVALID_INPUT", "hostPath must not be empty");
|
|
132
|
+
// An empty variable is an unset one, as a shell or Compose leaves a variable it was given blank.
|
|
133
|
+
const fromEnvironment = process.env.ALPHA2_HOST_PATH;
|
|
134
|
+
const explicit = options.hostPath ?? (fromEnvironment?.trim() ? fromEnvironment : undefined);
|
|
135
|
+
if (explicit !== undefined) {
|
|
136
|
+
const candidate = asarUnpacked(resolve(cwd, explicit));
|
|
137
|
+
if (isFile(candidate))
|
|
138
|
+
return candidate;
|
|
139
|
+
throw new Alpha2HostError("HOST_NOT_FOUND", `alpha2-host does not exist: ${candidate}`);
|
|
140
|
+
}
|
|
141
|
+
const processResources = process.resourcesPath;
|
|
142
|
+
const resourcesPath = options.resourcesPath ?? processResources;
|
|
143
|
+
const moduleDirectory = dirname(fileURLToPath(import.meta.url));
|
|
144
|
+
const candidates = [
|
|
145
|
+
resourcesPath && join(resourcesPath, "bin", executable),
|
|
146
|
+
resourcesPath && join(resourcesPath, executable),
|
|
147
|
+
join(moduleDirectory, "..", "bin", executable),
|
|
148
|
+
];
|
|
149
|
+
for (const raw of candidates) {
|
|
150
|
+
if (!raw)
|
|
151
|
+
continue;
|
|
152
|
+
const candidate = asarUnpacked(raw);
|
|
153
|
+
if (isFile(candidate))
|
|
154
|
+
return candidate;
|
|
155
|
+
}
|
|
156
|
+
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
|
|
157
|
+
if (!directory)
|
|
158
|
+
continue;
|
|
159
|
+
const candidate = join(directory, executable);
|
|
160
|
+
if (isFile(candidate))
|
|
161
|
+
return candidate;
|
|
162
|
+
}
|
|
163
|
+
throw new Alpha2HostError("HOST_NOT_FOUND", `could not locate ${executable}; download the native archive for this platform from ` +
|
|
164
|
+
`${HOST_RELEASES_URL} and set hostPath or ALPHA2_HOST_PATH to its bin/${executable}, ` +
|
|
165
|
+
"or put that bin/ on PATH");
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Runs requests on `alpha2-host` sidecar processes, one process per request, at most
|
|
169
|
+
* `concurrency` at a time; further requests wait for a slot. Every rejection is an
|
|
170
|
+
* {@link Alpha2HostError}.
|
|
171
|
+
*/
|
|
172
|
+
export class Alpha2NodeClient {
|
|
173
|
+
#binary;
|
|
174
|
+
#slots;
|
|
175
|
+
#videoDecode;
|
|
176
|
+
/** Capability probes by decode mode: running, or answered. */
|
|
177
|
+
#probes = new Map();
|
|
178
|
+
/**
|
|
179
|
+
* Resolves the host binary now. Throws a `RangeError` for a `concurrency` that is not a positive
|
|
180
|
+
* integer, and an {@link Alpha2HostError} when `videoDecode` is not a mode (`INVALID_INPUT`) or
|
|
181
|
+
* the binary cannot be found.
|
|
182
|
+
*/
|
|
183
|
+
constructor(options = {}) {
|
|
184
|
+
const concurrency = options.concurrency ?? 1;
|
|
185
|
+
if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
|
|
186
|
+
throw new RangeError("concurrency must be a positive integer");
|
|
187
|
+
}
|
|
188
|
+
checkVideoDecode("videoDecode", options.videoDecode);
|
|
189
|
+
this.#binary = resolveHostBinary(options);
|
|
190
|
+
this.#slots = new Semaphore(concurrency);
|
|
191
|
+
this.#videoDecode = options.videoDecode;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* What the host can decode, render and encode, for the client's decode mode or
|
|
195
|
+
* `options.videoDecode`. The first call for a mode starts a host, in a slot like any request;
|
|
196
|
+
* calls made while it runs share it, and its report answers every later call for that mode on
|
|
197
|
+
* this client, since the same binary and mode report the same. A probe that fails is not kept:
|
|
198
|
+
* the next call starts another.
|
|
199
|
+
*
|
|
200
|
+
* `options.signal` withdraws this call (`Alpha2AbortError`); the probe goes on while another call
|
|
201
|
+
* still waits for it, and is withdrawn, from its slot or its host, when none does.
|
|
202
|
+
*/
|
|
203
|
+
async getCapabilities(options = {}) {
|
|
204
|
+
checkVideoDecode("videoDecode", options.videoDecode);
|
|
205
|
+
const { signal } = options;
|
|
206
|
+
if (signal?.aborted)
|
|
207
|
+
throw new Alpha2AbortError();
|
|
208
|
+
const videoDecode = options.videoDecode ?? this.#videoDecode;
|
|
209
|
+
// Unset, the mode is the host's default or the one this process's environment names.
|
|
210
|
+
const key = videoDecode ?? `environment:${process.env.ALPHA2_VIDEO_DECODE ?? ""}`;
|
|
211
|
+
let probe = this.#probes.get(key);
|
|
212
|
+
if (probe === undefined) {
|
|
213
|
+
const controller = new AbortController();
|
|
214
|
+
const result = (async () => {
|
|
215
|
+
const release = await this.#slots.acquire(controller.signal);
|
|
216
|
+
try {
|
|
217
|
+
return (await requestHost(this.#binary, "capabilities", {}, { videoDecode }, controller.signal));
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
release();
|
|
221
|
+
}
|
|
222
|
+
})();
|
|
223
|
+
const started = {
|
|
224
|
+
result,
|
|
225
|
+
controller,
|
|
226
|
+
waiting: 0,
|
|
227
|
+
settled: false,
|
|
228
|
+
};
|
|
229
|
+
probe = started;
|
|
230
|
+
this.#probes.set(key, started);
|
|
231
|
+
result.then(() => {
|
|
232
|
+
started.settled = true;
|
|
233
|
+
}, () => {
|
|
234
|
+
started.settled = true;
|
|
235
|
+
if (this.#probes.get(key) === started)
|
|
236
|
+
this.#probes.delete(key);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (probe.settled)
|
|
240
|
+
return probe.result;
|
|
241
|
+
// Every call waiting counts, a call without a signal too: it holds the probe until it settles.
|
|
242
|
+
const waited = probe;
|
|
243
|
+
waited.waiting += 1;
|
|
244
|
+
return new Promise((resolveCall, rejectCall) => {
|
|
245
|
+
const leave = () => {
|
|
246
|
+
signal?.removeEventListener("abort", withdraw);
|
|
247
|
+
waited.waiting -= 1;
|
|
248
|
+
};
|
|
249
|
+
const withdraw = () => {
|
|
250
|
+
leave();
|
|
251
|
+
if (waited.waiting === 0 && !waited.settled) {
|
|
252
|
+
if (this.#probes.get(key) === waited)
|
|
253
|
+
this.#probes.delete(key);
|
|
254
|
+
waited.controller.abort();
|
|
255
|
+
}
|
|
256
|
+
rejectCall(new Alpha2AbortError());
|
|
257
|
+
};
|
|
258
|
+
signal?.addEventListener("abort", withdraw, { once: true });
|
|
259
|
+
waited.result.then((report) => {
|
|
260
|
+
leave();
|
|
261
|
+
resolveCall(report);
|
|
262
|
+
}, (error) => {
|
|
263
|
+
leave();
|
|
264
|
+
rejectCall(error);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Which media a purpose would read for each source, and what blocks it, without rendering. The
|
|
270
|
+
* host hashes an original only when the project recorded a hash to compare. Rejects with an
|
|
271
|
+
* {@link Alpha2ProjectValidationError} for an invalid project.
|
|
272
|
+
*/
|
|
273
|
+
async conform(request) {
|
|
274
|
+
const problems = projectProblems(request.project);
|
|
275
|
+
if (problems.length > 0)
|
|
276
|
+
throw new Alpha2ProjectValidationError(problems);
|
|
277
|
+
validateMediaPaths(request, false);
|
|
278
|
+
const release = await this.#slots.acquire(request.signal);
|
|
279
|
+
try {
|
|
280
|
+
return (await requestHost(this.#binary, "conform", {
|
|
281
|
+
project: request.project,
|
|
282
|
+
assets: request.assets ?? {},
|
|
283
|
+
proxies: proxiesPayload(request.proxies),
|
|
284
|
+
frames: request.frames ?? {},
|
|
285
|
+
purpose: (request.purpose ?? "export_original").replace(/_/g, "-"),
|
|
286
|
+
}, { videoDecode: this.#videoDecode }, request.signal));
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
release();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Renders and encodes `request.project` to `request.output` and resolves with that path.
|
|
294
|
+
*
|
|
295
|
+
* Rejects with an {@link Alpha2ProjectValidationError} or an `INVALID_INPUT` error before a host
|
|
296
|
+
* starts when the project or request is invalid, an {@link Alpha2ConformError} when the media is
|
|
297
|
+
* not ready, an {@link Alpha2AbortError} when `signal` aborts, and an {@link Alpha2HostError}
|
|
298
|
+
* otherwise. Under the `auto` decode mode, an export whose hardware decoder stalls
|
|
299
|
+
* (`DECODE_STALLED`) runs once more decoding in software; its progress starts over. When that
|
|
300
|
+
* run fails too, its error is the export's, with the stall as its `cause` and in
|
|
301
|
+
* `failure.diagnostics.decodeStall`.
|
|
302
|
+
*/
|
|
303
|
+
async exportProject(request) {
|
|
304
|
+
const problems = projectProblems(request.project);
|
|
305
|
+
if (problems.length > 0)
|
|
306
|
+
throw new Alpha2ProjectValidationError(problems);
|
|
307
|
+
validateExportRequest(request);
|
|
308
|
+
const release = await this.#slots.acquire(request.signal);
|
|
309
|
+
const videoDecode = request.videoDecode ?? this.#videoDecode;
|
|
310
|
+
try {
|
|
311
|
+
const run = (mode) => requestHost(this.#binary, "export", {
|
|
312
|
+
project: request.project,
|
|
313
|
+
assets: request.assets,
|
|
314
|
+
proxies: proxiesPayload(request.proxies),
|
|
315
|
+
frames: request.frames ?? {},
|
|
316
|
+
resources: request.resources ?? {},
|
|
317
|
+
media: request.media ?? "original",
|
|
318
|
+
effectAssets: request.effectAssets ?? {},
|
|
319
|
+
fonts: request.fonts ?? [],
|
|
320
|
+
effects: request.effects ?? [],
|
|
321
|
+
startTicks: request.startTicks,
|
|
322
|
+
endTicks: request.endTicks,
|
|
323
|
+
frameRate: request.frameRate,
|
|
324
|
+
includeAudio: request.includeAudio ?? true,
|
|
325
|
+
videoCodec: request.videoCodec ?? "h264",
|
|
326
|
+
videoEncoder: request.videoEncoder,
|
|
327
|
+
inputPolicy: request.inputPolicy ?? "auto",
|
|
328
|
+
strategy: request.strategy ?? "render",
|
|
329
|
+
videoQuality: request.videoQuality,
|
|
330
|
+
audioCodec: request.audioCodec ?? "aac",
|
|
331
|
+
audioQuality: request.audioQuality,
|
|
332
|
+
loudness: request.loudness,
|
|
333
|
+
renderCache: request.renderCache,
|
|
334
|
+
...(request.memory === undefined ? {} : { memory: request.memory }),
|
|
335
|
+
output: request.output,
|
|
336
|
+
}, { videoDecode: mode }, request.signal, request.onProgress);
|
|
337
|
+
let result;
|
|
338
|
+
try {
|
|
339
|
+
result = (await run(videoDecode));
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
// A hardware decoder stuck inside the driver cannot be recovered by its host, so the export
|
|
343
|
+
// reruns in a new host decoding on the CPU. Only a stall with a hardware session (resource
|
|
344
|
+
// "gpu") is retried: a software stall would recur, and `hardware-required` rules it out.
|
|
345
|
+
if (!(error instanceof Alpha2HostError) ||
|
|
346
|
+
error.code !== "DECODE_STALLED" ||
|
|
347
|
+
error.failure?.resource !== "gpu" ||
|
|
348
|
+
(videoDecode ?? process.env.ALPHA2_VIDEO_DECODE) ===
|
|
349
|
+
"hardware-required")
|
|
350
|
+
throw error;
|
|
351
|
+
try {
|
|
352
|
+
result = (await run("software"));
|
|
353
|
+
}
|
|
354
|
+
catch (retryError) {
|
|
355
|
+
throw recordDecodeStall(retryError, error);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
await request.onResult?.(result);
|
|
359
|
+
return request.output;
|
|
360
|
+
}
|
|
361
|
+
finally {
|
|
362
|
+
release();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* `retryError`, the failure of the software run a hardware decode stall led to, with the stall
|
|
368
|
+
* kept: as its `cause` unless it has one, and in its failure's diagnostics as `decodeStall`, which
|
|
369
|
+
* crosses process boundaries with the failure. The retry's failure stays the export's: a retry
|
|
370
|
+
* policy judges what the last run did.
|
|
371
|
+
*/
|
|
372
|
+
function recordDecodeStall(retryError, stall) {
|
|
373
|
+
if (!(retryError instanceof Alpha2HostError))
|
|
374
|
+
return retryError;
|
|
375
|
+
if (retryError.cause === undefined)
|
|
376
|
+
Object.defineProperty(retryError, "cause", {
|
|
377
|
+
value: stall,
|
|
378
|
+
writable: true,
|
|
379
|
+
configurable: true,
|
|
380
|
+
});
|
|
381
|
+
retryError.failure = {
|
|
382
|
+
...retryError.failure,
|
|
383
|
+
diagnostics: {
|
|
384
|
+
...retryError.failure.diagnostics,
|
|
385
|
+
decodeStall: {
|
|
386
|
+
hostCode: stall.code,
|
|
387
|
+
code: stall.failure.code,
|
|
388
|
+
phase: stall.failure.phase,
|
|
389
|
+
message: stall.message,
|
|
390
|
+
...(stall.stderr === undefined ? {} : { stderr: stall.stderr }),
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
return retryError;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* What the host's engine would refuse in `project`, found before a host starts: the shape the SDK
|
|
398
|
+
* validator checks, and the size the engine checks in the text it is sent
|
|
399
|
+
* (`MAX_PROJECT_JSON_BYTES`), which an object cannot show.
|
|
400
|
+
*/
|
|
401
|
+
function projectProblems(project) {
|
|
402
|
+
// `undefined` and the like have no text; the object validator says what they lack.
|
|
403
|
+
const json = JSON.stringify(project);
|
|
404
|
+
return json === undefined
|
|
405
|
+
? validateProject(project)
|
|
406
|
+
: validateProjectJson(json);
|
|
407
|
+
}
|
|
408
|
+
const convenienceClients = new Map();
|
|
409
|
+
/**
|
|
410
|
+
* {@link Alpha2NodeClient.getCapabilities} on a client shared by every module-level call with the
|
|
411
|
+
* same `concurrency` and resolved binary, for `options.videoDecode`.
|
|
412
|
+
*/
|
|
413
|
+
export async function getCapabilities(options = {}) {
|
|
414
|
+
return sharedClient(options).getCapabilities({
|
|
415
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
416
|
+
...(options.videoDecode === undefined
|
|
417
|
+
? {}
|
|
418
|
+
: { videoDecode: options.videoDecode }),
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* {@link Alpha2NodeClient.exportProject} on a client shared by every module-level call with the
|
|
423
|
+
* same `concurrency` and resolved binary, so `concurrency` holds across calls whatever decode mode
|
|
424
|
+
* each asks for. `options.videoDecode` applies where the request names none.
|
|
425
|
+
*/
|
|
426
|
+
export async function exportProject(request, options = {}) {
|
|
427
|
+
return sharedClient(options).exportProject(request.videoDecode === undefined && options.videoDecode !== undefined
|
|
428
|
+
? { ...request, videoDecode: options.videoDecode }
|
|
429
|
+
: request);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* The pool is the host binary and `concurrency`: the decode mode travels with each request, so
|
|
433
|
+
* calls that differ only in it still share the bound.
|
|
434
|
+
*/
|
|
435
|
+
function sharedClient(options) {
|
|
436
|
+
const concurrency = options.concurrency ?? 1;
|
|
437
|
+
if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
|
|
438
|
+
throw new RangeError("concurrency must be a positive integer");
|
|
439
|
+
}
|
|
440
|
+
checkVideoDecode("videoDecode", options.videoDecode);
|
|
441
|
+
const binary = resolveHostBinary(options);
|
|
442
|
+
const key = `${concurrency}\0${binary}`;
|
|
443
|
+
let client = convenienceClients.get(key);
|
|
444
|
+
if (client === undefined) {
|
|
445
|
+
client = new Alpha2NodeClient({ hostPath: binary, concurrency });
|
|
446
|
+
convenienceClients.set(key, client);
|
|
447
|
+
}
|
|
448
|
+
return client;
|
|
449
|
+
}
|
|
450
|
+
function validateExportRequest(request) {
|
|
451
|
+
const integer = (value) => Number.isSafeInteger(value);
|
|
452
|
+
if (!integer(request.startTicks) ||
|
|
453
|
+
!integer(request.endTicks) ||
|
|
454
|
+
request.endTicks <= request.startTicks) {
|
|
455
|
+
throw new Alpha2HostError("INVALID_INPUT", "export range must be a non-empty integer tick range");
|
|
456
|
+
}
|
|
457
|
+
if (!Number.isSafeInteger(request.frameRate.num) ||
|
|
458
|
+
request.frameRate.num <= 0 ||
|
|
459
|
+
!Number.isSafeInteger(request.frameRate.den) ||
|
|
460
|
+
request.frameRate.den <= 0) {
|
|
461
|
+
throw new Alpha2HostError("INVALID_INPUT", "frameRate must be a positive rational");
|
|
462
|
+
}
|
|
463
|
+
for (const [field, value] of Object.entries(request.memory ?? {})) {
|
|
464
|
+
if (field === "scale") {
|
|
465
|
+
if (value !== undefined &&
|
|
466
|
+
(typeof value !== "number" || !(value > 0 && value <= 1)))
|
|
467
|
+
throw new Alpha2HostError("INVALID_INPUT", "memory.scale must be above 0 and at most 1");
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (![
|
|
471
|
+
"inFlightBytes",
|
|
472
|
+
"decodeQueueBytes",
|
|
473
|
+
"reverseCacheBytes",
|
|
474
|
+
"audioWindowBytes",
|
|
475
|
+
"providerSourceBytes",
|
|
476
|
+
].includes(field))
|
|
477
|
+
throw new Alpha2HostError("INVALID_INPUT", `memory has an unknown field "${field}"`);
|
|
478
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0))
|
|
479
|
+
throw new Alpha2HostError("INVALID_INPUT", `memory.${field} must be a positive byte count`);
|
|
480
|
+
}
|
|
481
|
+
// Media usability is the host's conform check, which answers with a structured report.
|
|
482
|
+
validateMediaPaths(request, true);
|
|
483
|
+
checkPathRecord("effectAssets", request.effectAssets, "effect asset path");
|
|
484
|
+
if (request.resources !== undefined) {
|
|
485
|
+
if (!isRecord(request.resources))
|
|
486
|
+
invalidShape("resources must map source ids to their files");
|
|
487
|
+
for (const [id, files] of Object.entries(request.resources))
|
|
488
|
+
checkPathRecord(`resources.${id}`, files, "resource path");
|
|
489
|
+
}
|
|
490
|
+
if (request.effects !== undefined && !Array.isArray(request.effects))
|
|
491
|
+
invalidShape("effects must be a list of effect definitions");
|
|
492
|
+
if (request.media !== undefined &&
|
|
493
|
+
!["original", "proxies"].includes(request.media)) {
|
|
494
|
+
throw new Alpha2HostError("INVALID_INPUT", 'media must be "original" or "proxies"');
|
|
495
|
+
}
|
|
496
|
+
if (request.fonts !== undefined &&
|
|
497
|
+
(!Array.isArray(request.fonts) || !request.fonts.every(nonEmptyString)))
|
|
498
|
+
invalidShape("fonts must be a list of font file paths");
|
|
499
|
+
for (const resource of request.project.effect_resources ?? []) {
|
|
500
|
+
const path = request.effectAssets?.[resource.id];
|
|
501
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
502
|
+
throw new Alpha2HostError("MISSING_INPUT", `missing effect asset path for resource ${resource.id}`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (typeof request.output !== "string" || request.output.length === 0) {
|
|
506
|
+
throw new Alpha2HostError("INVALID_INPUT", "output must not be empty");
|
|
507
|
+
}
|
|
508
|
+
checkVideoDecode("videoDecode", request.videoDecode);
|
|
509
|
+
if (request.videoEncoder !== undefined &&
|
|
510
|
+
(typeof request.videoEncoder !== "string" ||
|
|
511
|
+
request.videoEncoder.length === 0)) {
|
|
512
|
+
throw new Alpha2HostError("INVALID_INPUT", "videoEncoder must be a non-empty FFmpeg encoder name");
|
|
513
|
+
}
|
|
514
|
+
if (request.strategy !== undefined &&
|
|
515
|
+
!["render", "prefer-copy", "require-copy"].includes(request.strategy)) {
|
|
516
|
+
throw new Alpha2HostError("INVALID_INPUT", "strategy must be render, prefer-copy, or require-copy");
|
|
517
|
+
}
|
|
518
|
+
if (request.inputPolicy !== undefined &&
|
|
519
|
+
!["auto", "gpu-required", "cpu-readback"].includes(request.inputPolicy)) {
|
|
520
|
+
throw new Alpha2HostError("INVALID_INPUT", "inputPolicy must be auto, gpu-required, or cpu-readback");
|
|
521
|
+
}
|
|
522
|
+
validateQuality("videoQuality", request.videoQuality, [
|
|
523
|
+
"bitrate",
|
|
524
|
+
"mode",
|
|
525
|
+
"quantizer",
|
|
526
|
+
]);
|
|
527
|
+
validateQuality("audioQuality", request.audioQuality, ["bitrate"]);
|
|
528
|
+
validateLoudness(request);
|
|
529
|
+
const cache = request.renderCache;
|
|
530
|
+
if (cache !== undefined &&
|
|
531
|
+
(typeof cache.directory !== "string" ||
|
|
532
|
+
cache.directory.length === 0 ||
|
|
533
|
+
(cache.quotaBytes !== undefined &&
|
|
534
|
+
(!Number.isSafeInteger(cache.quotaBytes) || cache.quotaBytes <= 0)) ||
|
|
535
|
+
(cache.backlog !== undefined &&
|
|
536
|
+
cache.backlog !== "skip" &&
|
|
537
|
+
cache.backlog !== "wait")))
|
|
538
|
+
throw new Alpha2HostError("INVALID_INPUT", 'renderCache takes a non-empty directory, an optional positive integer quotaBytes, and an optional backlog "skip" or "wait"');
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Refuses `assets`, `proxies` and `frames` not shaped as the host reads them (paths per source id)
|
|
542
|
+
* before a host starts; the host would refuse them as `INVALID_REQUEST`.
|
|
543
|
+
*/
|
|
544
|
+
function validateMediaPaths(request, assetsRequired) {
|
|
545
|
+
if (assetsRequired && request.assets === undefined)
|
|
546
|
+
invalidShape("assets must map source ids to paths");
|
|
547
|
+
checkPathRecord("assets", request.assets, "asset path");
|
|
548
|
+
if (request.proxies !== undefined) {
|
|
549
|
+
if (!isRecord(request.proxies))
|
|
550
|
+
invalidShape("proxies must map source ids to lists of proxy files");
|
|
551
|
+
for (const [id, files] of Object.entries(request.proxies))
|
|
552
|
+
if (!Array.isArray(files) ||
|
|
553
|
+
!files.every((file) => isRecord(file) && nonEmptyString(file.path)))
|
|
554
|
+
invalidShape(`proxies for source ${id} must be a list of { path } proxy files`);
|
|
555
|
+
}
|
|
556
|
+
if (request.frames !== undefined) {
|
|
557
|
+
if (!isRecord(request.frames))
|
|
558
|
+
invalidShape("frames must map source ids to lists of frame files");
|
|
559
|
+
for (const [id, files] of Object.entries(request.frames))
|
|
560
|
+
if (!Array.isArray(files) ||
|
|
561
|
+
!files.every((file) => file === null || nonEmptyString(file)))
|
|
562
|
+
invalidShape(`frames for source ${id} must be a list of frame file paths, null for a gap`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/** Refuses a `field` that is not a record of non-empty path strings. */
|
|
566
|
+
function checkPathRecord(field, value, what) {
|
|
567
|
+
if (value === undefined)
|
|
568
|
+
return;
|
|
569
|
+
if (!isRecord(value))
|
|
570
|
+
invalidShape(`${field} must map ids to paths`);
|
|
571
|
+
for (const [id, path] of Object.entries(value))
|
|
572
|
+
if (!nonEmptyString(path))
|
|
573
|
+
invalidShape(`${what} for ${id} must be a non-empty string`);
|
|
574
|
+
}
|
|
575
|
+
function invalidShape(message) {
|
|
576
|
+
throw new Alpha2HostError("INVALID_INPUT", message);
|
|
577
|
+
}
|
|
578
|
+
function isRecord(value) {
|
|
579
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
580
|
+
}
|
|
581
|
+
function nonEmptyString(value) {
|
|
582
|
+
return typeof value === "string" && value.length > 0;
|
|
583
|
+
}
|
|
584
|
+
/** The host's `loudness` checks, with its messages. */
|
|
585
|
+
function validateLoudness(request) {
|
|
586
|
+
const loudness = request.loudness;
|
|
587
|
+
if (loudness === undefined)
|
|
588
|
+
return;
|
|
589
|
+
const invalid = (message) => {
|
|
590
|
+
throw new Alpha2HostError("INVALID_INPUT", message);
|
|
591
|
+
};
|
|
592
|
+
if (typeof loudness !== "object" || loudness === null)
|
|
593
|
+
invalid("loudness must be an object { targetLufs, truePeakCeilingDbtp }");
|
|
594
|
+
const unknown = Object.keys(loudness).find((key) => key !== "targetLufs" && key !== "truePeakCeilingDbtp");
|
|
595
|
+
if (unknown !== undefined)
|
|
596
|
+
invalid(`loudness has an unknown key "${unknown}"; it takes targetLufs and truePeakCeilingDbtp`);
|
|
597
|
+
const within = (key, low) => {
|
|
598
|
+
const value = loudness[key];
|
|
599
|
+
if (typeof value !== "number" || !(value >= low && value <= 0))
|
|
600
|
+
invalid(`loudness.${key} must be within ${low}..=0, received ${value}`);
|
|
601
|
+
};
|
|
602
|
+
within("targetLufs", -60);
|
|
603
|
+
within("truePeakCeilingDbtp", -20);
|
|
604
|
+
if (request.includeAudio === false)
|
|
605
|
+
invalid("loudness normalizes the export's audio and needs includeAudio: true");
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Rejects what the host would reject, with the same field names, before a process is started. The
|
|
609
|
+
* host checks again; this only moves the error earlier.
|
|
610
|
+
*/
|
|
611
|
+
function validateQuality(field, quality, keys) {
|
|
612
|
+
if (quality === undefined)
|
|
613
|
+
return;
|
|
614
|
+
const invalid = (message) => {
|
|
615
|
+
throw new Alpha2HostError("INVALID_INPUT", `${field}${message}`);
|
|
616
|
+
};
|
|
617
|
+
if (typeof quality === "string") {
|
|
618
|
+
if (!QUALITY_LEVELS.includes(quality))
|
|
619
|
+
invalid(` must be one of ${QUALITY_LEVELS.join(", ")}, received "${quality}"`);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (typeof quality !== "object" || quality === null || Array.isArray(quality))
|
|
623
|
+
invalid(" must be a quality level name or an explicit rate object");
|
|
624
|
+
const rate = quality;
|
|
625
|
+
const unknown = Object.keys(rate).find((key) => !keys.includes(key));
|
|
626
|
+
if (unknown !== undefined)
|
|
627
|
+
invalid(` has an unknown key "${unknown}"; an explicit rate takes ${keys.join(" and ")}`);
|
|
628
|
+
if (typeof rate.bitrate !== "number" ||
|
|
629
|
+
!Number.isSafeInteger(rate.bitrate) ||
|
|
630
|
+
rate.bitrate <= 0)
|
|
631
|
+
invalid(".bitrate must be a positive integer number of bits per second");
|
|
632
|
+
if (rate.mode !== undefined &&
|
|
633
|
+
rate.mode !== "constant" &&
|
|
634
|
+
rate.mode !== "variable")
|
|
635
|
+
invalid('.mode must be "constant" or "variable"');
|
|
636
|
+
if (rate.quantizer !== undefined &&
|
|
637
|
+
(typeof rate.quantizer !== "number" ||
|
|
638
|
+
!Number.isInteger(rate.quantizer) ||
|
|
639
|
+
rate.quantizer < 0 ||
|
|
640
|
+
rate.quantizer > 255))
|
|
641
|
+
invalid(".quantizer must be an integer from 0 to 255");
|
|
642
|
+
}
|
|
643
|
+
async function requestHost(binary, operation, payload, settings, signal, onProgress) {
|
|
644
|
+
if (signal?.aborted)
|
|
645
|
+
throw new Alpha2AbortError();
|
|
646
|
+
// Windows Node starts a batch file only through a shell, which this never asks for: the spawn
|
|
647
|
+
// would fail with EINVAL, a start that no retry mends.
|
|
648
|
+
if (process.platform === "win32" && /\.(bat|cmd)$/i.test(binary))
|
|
649
|
+
throw new Alpha2HostError("HOST_NOT_EXECUTABLE", `alpha2-host must be the executable, not a batch file: ${binary}`);
|
|
650
|
+
await access(binary, process.platform === "win32" ? constants.F_OK : constants.X_OK).catch((cause) => {
|
|
651
|
+
throw new Alpha2HostError("HOST_NOT_EXECUTABLE", `cannot execute alpha2-host: ${binary}`, {
|
|
652
|
+
cause,
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
const requestId = `node-${process.pid}-${Date.now()}-${nextRequestSequence++}`;
|
|
656
|
+
// The request becomes its line before a host starts: a value JSON cannot carry (a BigInt, a
|
|
657
|
+
// cycle, a throwing `toJSON`) refuses the request and starts nothing.
|
|
658
|
+
let requestLine;
|
|
659
|
+
try {
|
|
660
|
+
requestLine = `${JSON.stringify(createRequest(operation, requestId, payload))}\n`;
|
|
661
|
+
}
|
|
662
|
+
catch (cause) {
|
|
663
|
+
throw new Alpha2HostError("INVALID_INPUT", `the ${operation} request cannot be written as JSON: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
664
|
+
}
|
|
665
|
+
// The host would make its GPU device before refusing a line this long, unread.
|
|
666
|
+
const lineBytes = Buffer.byteLength(requestLine) - 1;
|
|
667
|
+
if (lineBytes > MAX_REQUEST_LINE_BYTES)
|
|
668
|
+
throw new Alpha2HostError("INVALID_INPUT", `the ${operation} request is ${lineBytes} bytes of JSON; the host reads at most ${MAX_REQUEST_LINE_BYTES}`);
|
|
669
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
670
|
+
const startFailed = (cause) => new Alpha2HostError("HOST_START_FAILED", `could not start alpha2-host: ${String(cause)}`, { cause });
|
|
671
|
+
let child;
|
|
672
|
+
try {
|
|
673
|
+
child = spawn(binary, ["serve"], {
|
|
674
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
675
|
+
windowsHide: true,
|
|
676
|
+
// The host chooses its decode path while it creates the renderer device, before any request
|
|
677
|
+
// arrives, so the mode travels in the environment rather than in the request.
|
|
678
|
+
env: settings.videoDecode === undefined
|
|
679
|
+
? process.env
|
|
680
|
+
: { ...process.env, ALPHA2_VIDEO_DECODE: settings.videoDecode },
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
catch (cause) {
|
|
684
|
+
rejectPromise(startFailed(cause));
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
let settled = false;
|
|
688
|
+
let outcome;
|
|
689
|
+
let forceKillTimer;
|
|
690
|
+
let writeFailedTimer;
|
|
691
|
+
// The last bytes of the host's standard error, cut to a character when read.
|
|
692
|
+
let stderr = Buffer.alloc(0);
|
|
693
|
+
let lastPhase;
|
|
694
|
+
let reader;
|
|
695
|
+
let requestPipe;
|
|
696
|
+
// What an async onProgress returned and has not settled: the request settles after them, so a
|
|
697
|
+
// callback never runs past its promise.
|
|
698
|
+
const pendingCallbacks = new Set();
|
|
699
|
+
let callbackFailure;
|
|
700
|
+
const stderrTail = () => utf8Tail(stderr, STDERR_TAIL_BYTES);
|
|
701
|
+
const phaseNow = () => lastPhase === "finalizing"
|
|
702
|
+
? "mux"
|
|
703
|
+
: lastPhase === "frames"
|
|
704
|
+
? "render"
|
|
705
|
+
: "prepare";
|
|
706
|
+
const finish = (nextOutcome) => {
|
|
707
|
+
if (settled || outcome !== undefined)
|
|
708
|
+
return;
|
|
709
|
+
outcome = nextOutcome;
|
|
710
|
+
signal?.removeEventListener("abort", abort);
|
|
711
|
+
reader?.close();
|
|
712
|
+
requestPipe?.end();
|
|
713
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
714
|
+
child.kill("SIGTERM");
|
|
715
|
+
forceKillTimer = setTimeout(() => {
|
|
716
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
717
|
+
child.kill("SIGKILL");
|
|
718
|
+
}, HOST_SHUTDOWN_TIMEOUT_MS);
|
|
719
|
+
};
|
|
720
|
+
const fail = (error) => finish({ ok: false, error });
|
|
721
|
+
// A host error becomes the call's error once the host has ended: stderr written before the
|
|
722
|
+
// answer may be read after it (often on macOS).
|
|
723
|
+
const failFromHost = (error) => {
|
|
724
|
+
const phase = phaseNow();
|
|
725
|
+
finish({
|
|
726
|
+
ok: false,
|
|
727
|
+
error: () => errorFromHost(error, stderrTail(), phase),
|
|
728
|
+
});
|
|
729
|
+
};
|
|
730
|
+
const abort = () => fail(new Alpha2AbortError());
|
|
731
|
+
const callbackFailed = (error) => {
|
|
732
|
+
callbackFailure ??= error;
|
|
733
|
+
fail(error);
|
|
734
|
+
};
|
|
735
|
+
const track = (returned) => {
|
|
736
|
+
if (!isThenable(returned))
|
|
737
|
+
return;
|
|
738
|
+
const pending = Promise.resolve(returned).then(() => undefined, (cause) => callbackFailed(new Alpha2HostError("PROGRESS_CALLBACK_FAILED", "onProgress callback rejected", { cause, phase: phaseNow() })));
|
|
739
|
+
pendingCallbacks.add(pending);
|
|
740
|
+
void pending.then(() => pendingCallbacks.delete(pending));
|
|
741
|
+
};
|
|
742
|
+
const conclude = (code, exitSignal) => {
|
|
743
|
+
if (outcome?.ok === true && callbackFailure !== undefined) {
|
|
744
|
+
rejectPromise(callbackFailure);
|
|
745
|
+
}
|
|
746
|
+
else if (outcome?.ok === true) {
|
|
747
|
+
resolvePromise(outcome.result);
|
|
748
|
+
}
|
|
749
|
+
else if (outcome?.ok === false) {
|
|
750
|
+
rejectPromise(typeof outcome.error === "function" ? outcome.error() : outcome.error);
|
|
751
|
+
}
|
|
752
|
+
else {
|
|
753
|
+
const tail = stderrTail();
|
|
754
|
+
const ended = `exit ${exitSignal ?? code ?? "unknown"}`;
|
|
755
|
+
const detail = tail.trim() || ended;
|
|
756
|
+
if (code === 70 || detail.includes("GPU_INIT_FAILED")) {
|
|
757
|
+
rejectPromise(new Alpha2GpuInitializationError(detail, {
|
|
758
|
+
stderr: tail,
|
|
759
|
+
exitCode: code,
|
|
760
|
+
signal: exitSignal,
|
|
761
|
+
}));
|
|
762
|
+
}
|
|
763
|
+
else if (loaderRefused(code, tail)) {
|
|
764
|
+
// The loader refused the executable before it ran (a missing or mismatched FFmpeg):
|
|
765
|
+
// the same host on the same machine fails the same way, so no retry is asked for.
|
|
766
|
+
rejectPromise(new Alpha2HostError("HOST_LOAD_FAILED", `alpha2-host could not be loaded: ${detail}`, { stderr: tail, exitCode: code, signal: exitSignal }));
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
// Nothing the host said names a cause, so none is claimed: a SIGKILL is as consistent
|
|
770
|
+
// with an external kill as with the OOM killer.
|
|
771
|
+
const failure = {
|
|
772
|
+
code: "HOST_EXITED",
|
|
773
|
+
message: `alpha2-host exited before responding (${ended})`,
|
|
774
|
+
origin: "node",
|
|
775
|
+
operation: "export",
|
|
776
|
+
backend: "native",
|
|
777
|
+
phase: phaseNow(),
|
|
778
|
+
resource: "unknown",
|
|
779
|
+
stderr: tail,
|
|
780
|
+
exitCode: code,
|
|
781
|
+
signal: exitSignal,
|
|
782
|
+
};
|
|
783
|
+
rejectPromise(new Alpha2HostError("HOST_EXITED", `alpha2-host exited before responding: ${detail}`, {
|
|
784
|
+
failure,
|
|
785
|
+
stderr: tail,
|
|
786
|
+
exitCode: code,
|
|
787
|
+
signal: exitSignal,
|
|
788
|
+
}));
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
// A spawn without its standard streams (EMFILE, ENFILE) has no host to end and may never close:
|
|
793
|
+
// it settles as it fails.
|
|
794
|
+
const failWithoutHost = (error) => {
|
|
795
|
+
if (settled)
|
|
796
|
+
return;
|
|
797
|
+
settled = true;
|
|
798
|
+
if (child.pid !== undefined)
|
|
799
|
+
child.kill("SIGKILL");
|
|
800
|
+
rejectPromise(error);
|
|
801
|
+
};
|
|
802
|
+
const { stdin, stdout, stderr: errorPipe } = child;
|
|
803
|
+
const streamsMissing = !stdin || !stdout || !errorPipe;
|
|
804
|
+
// Before anything else: a spawn that fails reports it as this event, which would otherwise end
|
|
805
|
+
// this process unheard.
|
|
806
|
+
child.on("error", (cause) => {
|
|
807
|
+
if (streamsMissing)
|
|
808
|
+
failWithoutHost(startFailed(cause));
|
|
809
|
+
else
|
|
810
|
+
fail(startFailed(cause));
|
|
811
|
+
});
|
|
812
|
+
child.on("close", (code, exitSignal) => {
|
|
813
|
+
if (settled)
|
|
814
|
+
return;
|
|
815
|
+
settled = true;
|
|
816
|
+
signal?.removeEventListener("abort", abort);
|
|
817
|
+
reader?.close();
|
|
818
|
+
if (forceKillTimer !== undefined)
|
|
819
|
+
clearTimeout(forceKillTimer);
|
|
820
|
+
if (writeFailedTimer !== undefined)
|
|
821
|
+
clearTimeout(writeFailedTimer);
|
|
822
|
+
if (pendingCallbacks.size === 0)
|
|
823
|
+
conclude(code, exitSignal);
|
|
824
|
+
else
|
|
825
|
+
void Promise.allSettled(pendingCallbacks).then(() => conclude(code, exitSignal));
|
|
826
|
+
});
|
|
827
|
+
if (streamsMissing) {
|
|
828
|
+
// Node reports the cause on the next tick; this names the failure if it does not.
|
|
829
|
+
setImmediate(() => failWithoutHost(startFailed("its standard streams could not be created")));
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
try {
|
|
833
|
+
requestPipe = stdin;
|
|
834
|
+
// A pipe fails when the host goes away; its stream `error` event would end this process
|
|
835
|
+
// unheard, and the host's own report or exit is the outcome, so it is only noted.
|
|
836
|
+
const ignore = () => { };
|
|
837
|
+
stdin.on("error", ignore);
|
|
838
|
+
stdout.on("error", ignore);
|
|
839
|
+
errorPipe.on("error", ignore);
|
|
840
|
+
errorPipe.on("data", (chunk) => {
|
|
841
|
+
stderr = Buffer.concat([stderr, chunk]);
|
|
842
|
+
if (stderr.length > STDERR_TAIL_BYTES)
|
|
843
|
+
stderr = stderr.subarray(stderr.length - STDERR_TAIL_BYTES);
|
|
844
|
+
});
|
|
845
|
+
reader = createInterface({ input: stdout });
|
|
846
|
+
reader.on("line", (line) => {
|
|
847
|
+
// Every message is a JSON object. A line that does not start as one is a library's the host
|
|
848
|
+
// loaded (a Vulkan validation layer prints to stdout): diagnostic output, kept with stderr.
|
|
849
|
+
if (!line.startsWith("{")) {
|
|
850
|
+
stderr = Buffer.concat([stderr, Buffer.from(`${line}\n`)]);
|
|
851
|
+
if (stderr.length > STDERR_TAIL_BYTES)
|
|
852
|
+
stderr = stderr.subarray(stderr.length - STDERR_TAIL_BYTES);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
let message;
|
|
856
|
+
try {
|
|
857
|
+
message = parseHostMessage(line);
|
|
858
|
+
}
|
|
859
|
+
catch (cause) {
|
|
860
|
+
fail(new Alpha2HostError("INVALID_RESPONSE", "alpha2-host returned invalid JSON", { cause, phase: phaseNow() }));
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
if ("type" in message) {
|
|
864
|
+
if (message.type === "fatal") {
|
|
865
|
+
failFromHost(message.error);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (message.requestId === requestId) {
|
|
869
|
+
lastPhase = message.phase ?? "frames";
|
|
870
|
+
notifyProgress(message, onProgress, callbackFailed, track, phaseNow());
|
|
871
|
+
}
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
// A process serves this one request, so an answer to a line the host could not read an id
|
|
875
|
+
// from is its answer.
|
|
876
|
+
if (message.requestId !== requestId &&
|
|
877
|
+
message.requestId !== UNREAD_REQUEST_ID)
|
|
878
|
+
return;
|
|
879
|
+
if (message.ok)
|
|
880
|
+
finish({ ok: true, result: message.result });
|
|
881
|
+
else
|
|
882
|
+
failFromHost(message.error);
|
|
883
|
+
});
|
|
884
|
+
if (signal?.aborted) {
|
|
885
|
+
abort();
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
889
|
+
stdin.write(requestLine, (error) => {
|
|
890
|
+
if (!error) {
|
|
891
|
+
stdin.end();
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
// The host's own report (a fatal line) and its exit arrive after this: they name the cause.
|
|
895
|
+
// Only a host that stays up without taking its request fails as unwritten.
|
|
896
|
+
writeFailedTimer = setTimeout(() => {
|
|
897
|
+
fail(new Alpha2HostError("HOST_WRITE_FAILED", error.message, {
|
|
898
|
+
cause: error,
|
|
899
|
+
}));
|
|
900
|
+
}, HOST_SHUTDOWN_TIMEOUT_MS);
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
catch (cause) {
|
|
904
|
+
fail(startFailed(cause));
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
function isThenable(value) {
|
|
909
|
+
return ((typeof value === "object" || typeof value === "function") &&
|
|
910
|
+
value !== null &&
|
|
911
|
+
typeof value.then === "function");
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Calls `callback` with a progress report. A throw fails the request; a promise it returns is
|
|
915
|
+
* `track`ed, whose rejection fails it too.
|
|
916
|
+
*/
|
|
917
|
+
function notifyProgress(message, callback, fail, track, phase) {
|
|
918
|
+
try {
|
|
919
|
+
track(callback?.({
|
|
920
|
+
phase: message.phase ?? "frames",
|
|
921
|
+
completed: message.completed,
|
|
922
|
+
total: message.total,
|
|
923
|
+
}));
|
|
924
|
+
}
|
|
925
|
+
catch (cause) {
|
|
926
|
+
fail(new Alpha2HostError("PROGRESS_CALLBACK_FAILED", "onProgress callback threw", { cause, phase }));
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* The error the host reported, with its failure report, or, when it sent none (a request it could
|
|
931
|
+
* not read, a start that failed), the failure its code is evidence of at `phase`.
|
|
932
|
+
*/
|
|
933
|
+
function errorFromHost(error, stderr, phase) {
|
|
934
|
+
const failure = error.failure === undefined
|
|
935
|
+
? undefined
|
|
936
|
+
: {
|
|
937
|
+
...error.failure,
|
|
938
|
+
message: error.message,
|
|
939
|
+
stderr,
|
|
940
|
+
};
|
|
941
|
+
if (error.code === "GPU_INIT_FAILED")
|
|
942
|
+
return new Alpha2GpuInitializationError(error.message, {
|
|
943
|
+
failure,
|
|
944
|
+
stderr,
|
|
945
|
+
phase,
|
|
946
|
+
});
|
|
947
|
+
if (error.code === "CONFORM_FAILED" && error.details !== undefined)
|
|
948
|
+
return new Alpha2ConformError(error.message, error.details, { failure, stderr, phase });
|
|
949
|
+
return new Alpha2HostError(error.code, error.message, {
|
|
950
|
+
failure,
|
|
951
|
+
stderr,
|
|
952
|
+
phase,
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
function proxiesPayload(proxies) {
|
|
956
|
+
return Object.fromEntries(Object.entries(proxies ?? {}).map(([id, files]) => [
|
|
957
|
+
id,
|
|
958
|
+
files.map((file) => ({
|
|
959
|
+
path: file.path,
|
|
960
|
+
...(file.madeFrom === undefined ? {} : { madeFrom: file.madeFrom }),
|
|
961
|
+
...(file.colorPipeline === undefined
|
|
962
|
+
? {}
|
|
963
|
+
: { colorPipeline: file.colorPipeline }),
|
|
964
|
+
})),
|
|
965
|
+
]));
|
|
966
|
+
}
|
|
967
|
+
function asarUnpacked(path) {
|
|
968
|
+
return path.replace(/([\\/])app\.asar([\\/])/, "$1app.asar.unpacked$2");
|
|
969
|
+
}
|
|
970
|
+
function isFile(path) {
|
|
971
|
+
try {
|
|
972
|
+
return existsSync(path) && statSync(path).isFile();
|
|
973
|
+
}
|
|
974
|
+
catch {
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* The last at most `limit` bytes of `bytes` as text, from the first whole character: a byte bound,
|
|
980
|
+
* as {@link STDERR_TAIL_BYTES} says, for text that is not ASCII too.
|
|
981
|
+
*/
|
|
982
|
+
function utf8Tail(bytes, limit) {
|
|
983
|
+
let start = Math.max(0, bytes.length - limit);
|
|
984
|
+
// A continuation byte (10xxxxxx) is the middle of a character.
|
|
985
|
+
while (start < bytes.length && ((bytes[start] ?? 0) & 0xc0) === 0x80)
|
|
986
|
+
start += 1;
|
|
987
|
+
const text = bytes.subarray(start).toString("utf8");
|
|
988
|
+
// A byte that is not UTF-8 becomes U+FFFD, three bytes: drop characters from the front to fit.
|
|
989
|
+
let excess = Buffer.byteLength(text) - limit;
|
|
990
|
+
let index = 0;
|
|
991
|
+
while (excess > 0 && index < text.length) {
|
|
992
|
+
const point = text.codePointAt(index) ?? 0;
|
|
993
|
+
excess -= Buffer.byteLength(String.fromCodePoint(point));
|
|
994
|
+
index += point > 0xffff ? 2 : 1;
|
|
995
|
+
}
|
|
996
|
+
return text.slice(index);
|
|
997
|
+
}
|
|
998
|
+
const HOST_SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
999
|
+
/** The longest request line the host reads (`MAX_REQUEST_LINE_BYTES`), newline excluded. */
|
|
1000
|
+
const MAX_REQUEST_LINE_BYTES = 2 * MAX_PROJECT_JSON_BYTES;
|
|
1001
|
+
/** The `requestId` the host answers a line with when it could not read the line's own. */
|
|
1002
|
+
const UNREAD_REQUEST_ID = "unknown";
|
|
1003
|
+
class Semaphore {
|
|
1004
|
+
#available;
|
|
1005
|
+
#waiting = [];
|
|
1006
|
+
constructor(limit) {
|
|
1007
|
+
this.#available = limit;
|
|
1008
|
+
}
|
|
1009
|
+
acquire(signal) {
|
|
1010
|
+
if (signal?.aborted)
|
|
1011
|
+
return Promise.reject(new Alpha2AbortError());
|
|
1012
|
+
if (this.#available > 0) {
|
|
1013
|
+
this.#available -= 1;
|
|
1014
|
+
return Promise.resolve(this.#release());
|
|
1015
|
+
}
|
|
1016
|
+
return new Promise((resolveWaiter, reject) => {
|
|
1017
|
+
const waiter = {
|
|
1018
|
+
resolve: resolveWaiter,
|
|
1019
|
+
reject,
|
|
1020
|
+
signal,
|
|
1021
|
+
abort: undefined,
|
|
1022
|
+
};
|
|
1023
|
+
const abort = () => {
|
|
1024
|
+
const index = this.#waiting.indexOf(waiter);
|
|
1025
|
+
if (index >= 0)
|
|
1026
|
+
this.#waiting.splice(index, 1);
|
|
1027
|
+
reject(new Alpha2AbortError());
|
|
1028
|
+
};
|
|
1029
|
+
waiter.abort = abort;
|
|
1030
|
+
this.#waiting.push(waiter);
|
|
1031
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
1032
|
+
if (signal?.aborted)
|
|
1033
|
+
abort();
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
#release() {
|
|
1037
|
+
let released = false;
|
|
1038
|
+
return () => {
|
|
1039
|
+
if (released)
|
|
1040
|
+
return;
|
|
1041
|
+
released = true;
|
|
1042
|
+
const waiter = this.#waiting.shift();
|
|
1043
|
+
if (waiter) {
|
|
1044
|
+
if (waiter.abort)
|
|
1045
|
+
waiter.signal?.removeEventListener("abort", waiter.abort);
|
|
1046
|
+
waiter.resolve(this.#release());
|
|
1047
|
+
}
|
|
1048
|
+
else {
|
|
1049
|
+
this.#available += 1;
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
let nextRequestSequence = 0;
|