multiplane 0.3.6 → 0.3.8
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/LICENSE +202 -202
- package/README.en.md +3 -0
- package/README.md +3 -0
- package/THIRD_PARTY_NOTICES.md +30 -30
- package/bin/multiplane +24 -14
- package/examples/alpha.html +18 -18
- package/examples/animation.html +17 -17
- package/examples/assets/OFL.txt +93 -93
- package/examples/assets/card.svg +6 -6
- package/examples/three-cards.html +17 -17
- package/examples/typography.html +17 -17
- package/licenses/GPL-3.0.txt +232 -232
- package/licenses/LGPL-3.0.txt +304 -304
- package/licenses/acorn-LICENSE +21 -21
- package/licenses/colour-LICENSE.md +82 -82
- package/licenses/detect-libc-LICENSE +201 -201
- package/licenses/entities-LICENSE +11 -11
- package/licenses/nanoid-LICENSE +20 -20
- package/licenses/parse5-LICENSE +19 -19
- package/licenses/picocolors-LICENSE +15 -15
- package/licenses/playwright-NOTICE +5 -5
- package/licenses/playwright-ThirdPartyNotices.txt +14 -14
- package/licenses/playwright-core-ThirdPartyNotices.txt +13 -13
- package/licenses/postcss-LICENSE +20 -20
- package/licenses/postcss-value-parser-LICENSE +22 -22
- package/licenses/semver-LICENSE +15 -15
- package/licenses/sharp-LICENSE +191 -191
- package/licenses/sharp-win32-x64-NOTICES.md +45 -45
- package/licenses/source-map-js-LICENSE +28 -28
- package/licenses/three-LICENSE +21 -21
- package/package.json +92 -92
- package/skills/multiplane/SKILL.md +3 -0
- package/src/alpha.mjs +69 -69
- package/src/artifacts.mjs +61 -61
- package/src/browser-recovery.mjs +26 -26
- package/src/capture-watchdog.mjs +29 -0
- package/src/capture.mjs +34 -18
- package/src/encoder.mjs +16 -16
- package/src/environment.mjs +56 -56
- package/src/errors.mjs +1 -0
- package/src/events.mjs +12 -10
- package/src/ffmpeg.mjs +32 -32
- package/src/format.mjs +27 -27
- package/src/frame-rate.mjs +28 -28
- package/src/home-migration.mjs +54 -54
- package/src/hosted.mjs +3 -3
- package/src/index.mjs +1 -0
- package/src/input-records.mjs +33 -0
- package/src/jobs.mjs +3 -1
- package/src/lint.mjs +248 -248
- package/src/output-alpha.mjs +36 -36
- package/src/output-check.mjs +110 -110
- package/src/platform.mjs +114 -114
- package/src/png-native.mjs +12 -12
- package/src/png.mjs +74 -74
- package/src/post.mjs +56 -39
- package/src/preview.mjs +23 -23
- package/src/process.mjs +9 -9
- package/src/render-pool.mjs +41 -14
- package/src/render-worker.mjs +14 -3
- package/src/render.mjs +148 -52
- package/src/resource-path.mjs +29 -29
- package/src/resources.mjs +27 -27
- package/src/resume.mjs +23 -0
- package/src/runtime/camera.js +37 -37
- package/src/runtime/shot.css +9 -9
- package/src/segment-encoder.mjs +91 -0
- package/src/segments.mjs +123 -0
- package/src/server.mjs +11 -7
- package/src/setup.mjs +51 -51
- package/src/skills.mjs +128 -128
- package/src/stills.mjs +1 -1
- package/src/three/index.js +39 -31
- package/src/verify.mjs +2 -2
- package/src/video-pipe.mjs +6 -6
- package/tools/bench/shots/canvas-2d.html +1 -1
- package/tools/bench/shots/dom-light.html +1 -1
- package/tools/bench/shots/scene.js +44 -44
- package/tools/bench/shots/webgl.html +1 -1
- package/tools/install-browser.mjs +18 -18
package/src/post.mjs
CHANGED
|
@@ -54,7 +54,7 @@ export function resolvePost(options = {}, post = {}) {
|
|
|
54
54
|
if (options['no-post']) return { motionBlur: 1, maxMotionBlur: 1, shutter: 0.5, grain: 0 };
|
|
55
55
|
let motionBlur = number(options['motion-blur'] ?? post.motionBlur ?? 1, 'motionBlur', { positive: true });
|
|
56
56
|
if (!Number.isSafeInteger(motionBlur)) throw invalidOption('motionBlur', 'motionBlur must be a positive integer.');
|
|
57
|
-
const maxMotionBlur = number(quality === 'draft' ? 1 : options['max-motion-blur'] ?? post.maxMotionBlur ?? (quality === 'high' ? 256 : quality === 'fast' ? 16 : 32), 'maxMotionBlur', { positive: true });
|
|
57
|
+
const maxMotionBlur = number(quality === 'draft' ? 1 : options['max-motion-blur'] ?? post.maxMotionBlur ?? (quality === 'high' ? 256 : quality === 'fast' ? 16 : 32), 'maxMotionBlur', { positive: true });
|
|
58
58
|
if (!Number.isSafeInteger(maxMotionBlur)) throw invalidOption('maxMotionBlur', 'maxMotionBlur must be a positive integer.');
|
|
59
59
|
// Preserve explicit bases and fixed sampling before applying the preset cap.
|
|
60
60
|
if ((quality === 'standard' || quality === 'fast') && options['motion-blur'] == null && maxMotionBlur > motionBlur) motionBlur = Math.min(motionBlur, 4);
|
|
@@ -276,19 +276,20 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
|
|
|
276
276
|
}
|
|
277
277
|
const verifyReuse = candidate && (audit || previousRaw.run === 0 || frame % REUSE_INTERVAL === 0);
|
|
278
278
|
const cache = new Map();
|
|
279
|
-
let contextUsed = false, motionProbeUsed = false;
|
|
280
|
-
let finalCapture = hooked || motionBlur === 1;
|
|
281
|
-
let selectedTimes = times;
|
|
282
|
-
let dofMotionSamples = new Set(times).size;
|
|
283
|
-
const context = observed => ({ motionBlur, motionBlurBase, observed,
|
|
284
|
-
motionProbeTime: finalCapture ? undefined : t,
|
|
285
|
-
dofMotionSamples: finalCapture ? dofMotionSamples : undefined
|
|
279
|
+
let contextUsed = false, motionProbeUsed = false;
|
|
280
|
+
let finalCapture = hooked || motionBlur === 1;
|
|
281
|
+
let selectedTimes = times;
|
|
282
|
+
let dofMotionSamples = new Set(times).size;
|
|
283
|
+
const context = (observed, time) => ({ motionBlur, motionBlurBase, observed,
|
|
284
|
+
motionProbeTime: finalCapture ? undefined : t,
|
|
285
|
+
dofMotionSamples: finalCapture ? dofMotionSamples : undefined,
|
|
286
|
+
dofMotionIndex: finalCapture ? [...new Set(selectedTimes)].sort((a, b) => a - b).indexOf(time) : undefined });
|
|
286
287
|
const sample = async (time, reuse = false) => {
|
|
287
288
|
if (!cache.has(time)) {
|
|
288
289
|
const start = performance.now();
|
|
289
|
-
const observed = time === observedTime && !(motionBlur > 1 && capture.motionProbeUsed);
|
|
290
|
+
const observed = time === observedTime && !(motionBlur > 1 && capture.motionProbeUsed);
|
|
290
291
|
observedTime = undefined;
|
|
291
|
-
const png = await capture.frame(time, context(observed));
|
|
292
|
+
const png = await capture.frame(time, context(observed, time));
|
|
292
293
|
contextUsed ||= capture.sampleContextUsed === true;
|
|
293
294
|
motionProbeUsed ||= capture.motionProbeUsed === true;
|
|
294
295
|
stats.captureMs += performance.now() - start;
|
|
@@ -305,9 +306,9 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
|
|
|
305
306
|
return;
|
|
306
307
|
}
|
|
307
308
|
const begin = async time => {
|
|
308
|
-
const start = performance.now(), observed = time === observedTime && !(motionBlur > 1 && capture.motionProbeUsed);
|
|
309
|
+
const start = performance.now(), observed = time === observedTime && !(motionBlur > 1 && capture.motionProbeUsed);
|
|
309
310
|
observedTime = undefined;
|
|
310
|
-
const pending = await capture.beginFrame(time, context(observed));
|
|
311
|
+
const pending = await capture.beginFrame(time, context(observed, time));
|
|
311
312
|
contextUsed ||= capture.sampleContextUsed === true;
|
|
312
313
|
motionProbeUsed ||= capture.motionProbeUsed === true;
|
|
313
314
|
const png = pending.png.then(buffer => { stats.captureMs += performance.now() - start; return buffer; });
|
|
@@ -315,9 +316,9 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
|
|
|
315
316
|
return { png, dofSamples: capture.dofSamples ?? 0, probe: capture.motionProbeUsed === true && !finalCapture };
|
|
316
317
|
};
|
|
317
318
|
let pending = await begin(missing[0]);
|
|
318
|
-
for (let i = 0; i < missing.length; i++) {
|
|
319
|
-
const png = await pending.png;
|
|
320
|
-
const { dofSamples, probe } = pending;
|
|
319
|
+
for (let i = 0; i < missing.length; i++) {
|
|
320
|
+
const png = await pending.png;
|
|
321
|
+
const { dofSamples, probe } = pending;
|
|
321
322
|
const next = i + 1 < missing.length ? await begin(missing[i + 1]) : null;
|
|
322
323
|
stats.samples++;
|
|
323
324
|
cache.set(missing[i], { png, dofSamples, probe, image: await decode(png, reuse) });
|
|
@@ -328,11 +329,25 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
|
|
|
328
329
|
let png, result, usedSamples = motionBlur;
|
|
329
330
|
if (candidate && !verifyReuse) { ({ png, image: result, usedSamples } = previousRaw); }
|
|
330
331
|
else if (upward) {
|
|
332
|
+
// TODO(design): Half-size probes need a compatible decision policy;
|
|
333
|
+
// halving the raster and displacement threshold changes refinement.
|
|
331
334
|
// Calibrated in capture raster pixels: about 2px for fast/standard, 1px for high.
|
|
332
335
|
const threshold = resolveQuality(options) === 'high' ? 1 : 2;
|
|
333
|
-
|
|
336
|
+
// Stop as soon as motion is established. Retain exact full-size still
|
|
337
|
+
// checks, and reuse every matching time without changing the final grid.
|
|
338
|
+
await sampleMany([t, endpoints[0]], true);
|
|
334
339
|
const centerProbe = cache.get(t);
|
|
335
|
-
|
|
340
|
+
let still = cache.get(endpoints[0]).image.data.equals(centerProbe.image.data);
|
|
341
|
+
if (still) {
|
|
342
|
+
await sampleMany([endpoints[1]], true);
|
|
343
|
+
still = cache.get(endpoints[1]).image.data.equals(centerProbe.image.data);
|
|
344
|
+
} else if (capture._inspectSample) {
|
|
345
|
+
// Preserve conditional sampleContext reads at the skipped endpoint.
|
|
346
|
+
const start = performance.now();
|
|
347
|
+
await capture._inspectSample(endpoints[1], context(false, endpoints[1]));
|
|
348
|
+
stats.captureMs += performance.now() - start;
|
|
349
|
+
contextUsed ||= capture.sampleContextUsed === true;
|
|
350
|
+
}
|
|
336
351
|
let legacy = contextUsed;
|
|
337
352
|
let grid = legacy ? times : grids.get(motionBlur);
|
|
338
353
|
if (still) {
|
|
@@ -356,15 +371,15 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
|
|
|
356
371
|
}
|
|
357
372
|
let difference = 0;
|
|
358
373
|
for (let i = 1; i < images.length && difference <= threshold; i++) {
|
|
359
|
-
// All decision images share the output frame's fixed lens phase.
|
|
360
|
-
difference = Math.max(difference, localMotionDifference(images[i - 1], images[i], alpha, threshold));
|
|
374
|
+
// All decision images share the output frame's fixed lens phase.
|
|
375
|
+
difference = Math.max(difference, localMotionDifference(images[i - 1], images[i], alpha, threshold));
|
|
361
376
|
}
|
|
362
|
-
if (difference <= threshold || motionBlur >= post.maxMotionBlur) {
|
|
363
|
-
usedSamples = motionBlur; selectedTimes = grid;
|
|
377
|
+
if (difference <= threshold || motionBlur >= post.maxMotionBlur) {
|
|
378
|
+
usedSamples = motionBlur; selectedTimes = grid;
|
|
364
379
|
result = timed('averageMs', () => average(images)); break;
|
|
365
380
|
}
|
|
366
381
|
motionBlur = Math.min(post.maxMotionBlur, motionBlur * 2);
|
|
367
|
-
if (legacy) cache.clear();
|
|
382
|
+
if (legacy) cache.clear();
|
|
368
383
|
observedTime = undefined;
|
|
369
384
|
grid = legacy ? sampleTimes(t, fps, capture.duration, capture.cuts, { ...post, motionBlur }) : grids.get(motionBlur);
|
|
370
385
|
}
|
|
@@ -395,22 +410,24 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
|
|
|
395
410
|
result = timed('averageMs', () => average(images));
|
|
396
411
|
}
|
|
397
412
|
}
|
|
398
|
-
let usedDofSamples = previousRaw?.usedDofSamples ?? 0;
|
|
399
|
-
if (!(candidate && !verifyReuse)) {
|
|
400
|
-
// Only lens-dependent probe captures need replacing. DOM captures and
|
|
401
|
-
// final hook captures remain reusable at the same time and context.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
413
|
+
let usedDofSamples = previousRaw?.usedDofSamples ?? 0;
|
|
414
|
+
if (!(candidate && !verifyReuse)) {
|
|
415
|
+
// Only lens-dependent probe captures need replacing. DOM captures and
|
|
416
|
+
// final hook captures remain reusable at the same time and context.
|
|
417
|
+
// TODO(design): Zero aperture still has interleaved projection jitter;
|
|
418
|
+
// reusing its probes requires an approved change to that pattern.
|
|
419
|
+
if (motionProbeUsed && !finalCapture) {
|
|
420
|
+
finalCapture = true;
|
|
421
|
+
dofMotionSamples = new Set(selectedTimes).size;
|
|
422
|
+
for (const [time, item] of cache) if (item.probe) cache.delete(time);
|
|
423
|
+
observedTime = undefined;
|
|
424
|
+
await sampleMany(selectedTimes, true);
|
|
425
|
+
const images = selectedTimes.map(time => cache.get(time).image);
|
|
426
|
+
png = selectedTimes.length === 1 ? cache.get(selectedTimes[0]).png : undefined;
|
|
427
|
+
result = selectedTimes.length === 1 ? images[0] : timed('averageMs', () => average(images));
|
|
428
|
+
}
|
|
429
|
+
usedDofSamples = [...new Set(selectedTimes)].reduce((sum, time) => sum + (cache.get(time)?.dofSamples ?? 0), 0);
|
|
430
|
+
}
|
|
414
431
|
if (verifyReuse) {
|
|
415
432
|
stats.auditMs += performance.now() - auditStart;
|
|
416
433
|
Atomics.add(state, 2, 1);
|
|
@@ -428,7 +445,7 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
|
|
|
428
445
|
if (enabled && options.transport === 'raw') result ??= await decode(png);
|
|
429
446
|
previousRaw = { t, frame, motionBlur, uniform, png, image: result, usedSamples, usedDofSamples, run: candidate ? previousRaw.run + 1 : 0 };
|
|
430
447
|
// Contributing temporal samples, excluding probes and failed attempts.
|
|
431
|
-
api.lastBlurSamples = usedSamples;
|
|
448
|
+
api.lastBlurSamples = usedSamples;
|
|
432
449
|
api.lastDofSamples = usedDofSamples;
|
|
433
450
|
stats.frames++;
|
|
434
451
|
if (sizes.renderScale !== sizes.scale) {
|
package/src/preview.mjs
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import { writeFile, rename, rm } from 'node:fs/promises';
|
|
3
|
-
import sharp from 'sharp';
|
|
4
|
-
|
|
5
|
-
export function createPreview(events, width, height, { enabled = true, now = () => performance.now() } = {}) {
|
|
6
|
-
let last = -Infinity, failed = false;
|
|
7
|
-
return async (pixels, frame) => {
|
|
8
|
-
if (!enabled || failed || now() - last < 2000) return;
|
|
9
|
-
const file = path.join(events.directory, 'preview.png'), temporary = file + '.tmp';
|
|
10
|
-
try {
|
|
11
|
-
const png = await sharp(pixels, { raw: { width, height, channels: 4 } })
|
|
12
|
-
.resize({ width: 480, height: 480, fit: 'inside', withoutEnlargement: true }).png().toBuffer();
|
|
13
|
-
await writeFile(temporary, png);
|
|
14
|
-
await rename(temporary, file);
|
|
15
|
-
events.preview(frame);
|
|
16
|
-
last = now();
|
|
17
|
-
} catch {
|
|
18
|
-
failed = true;
|
|
19
|
-
await rm(temporary, { force: true }).catch(() => {});
|
|
20
|
-
events.warning({ code: 'preview_failed', message: 'Preview could not be written; further preview updates are disabled.' });
|
|
21
|
-
}
|
|
22
|
-
};
|
|
23
|
-
}
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { writeFile, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
|
|
5
|
+
export function createPreview(events, width, height, { enabled = true, now = () => performance.now() } = {}) {
|
|
6
|
+
let last = -Infinity, failed = false;
|
|
7
|
+
return async (pixels, frame) => {
|
|
8
|
+
if (!enabled || failed || now() - last < 2000) return;
|
|
9
|
+
const file = path.join(events.directory, 'preview.png'), temporary = file + '.tmp';
|
|
10
|
+
try {
|
|
11
|
+
const png = await sharp(pixels, { raw: { width, height, channels: 4 } })
|
|
12
|
+
.resize({ width: 480, height: 480, fit: 'inside', withoutEnlargement: true }).png().toBuffer();
|
|
13
|
+
await writeFile(temporary, png);
|
|
14
|
+
await rename(temporary, file);
|
|
15
|
+
events.preview(frame);
|
|
16
|
+
last = now();
|
|
17
|
+
} catch {
|
|
18
|
+
failed = true;
|
|
19
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
20
|
+
events.warning({ code: 'preview_failed', message: 'Preview could not be written; further preview updates are disabled.' });
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
package/src/process.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// FFmpeg has no descendants; wait for its streams to close before cleaning files.
|
|
2
|
-
export function stopChild(child, graceMs = 2000) {
|
|
3
|
-
return new Promise(resolve => {
|
|
4
|
-
if (child.exitCode !== null || child.signalCode !== null) { resolve(); return; }
|
|
5
|
-
const timer = setTimeout(() => child.kill('SIGKILL'), graceMs);
|
|
6
|
-
child.once('close', () => { clearTimeout(timer); resolve(); });
|
|
7
|
-
child.kill('SIGTERM');
|
|
8
|
-
});
|
|
9
|
-
}
|
|
1
|
+
// FFmpeg has no descendants; wait for its streams to close before cleaning files.
|
|
2
|
+
export function stopChild(child, graceMs = 2000) {
|
|
3
|
+
return new Promise(resolve => {
|
|
4
|
+
if (child.exitCode !== null || child.signalCode !== null) { resolve(); return; }
|
|
5
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), graceMs);
|
|
6
|
+
child.once('close', () => { clearTimeout(timer); resolve(); });
|
|
7
|
+
child.kill('SIGTERM');
|
|
8
|
+
});
|
|
9
|
+
}
|
package/src/render-pool.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { MultiplaneError } from './errors.mjs';
|
|
|
4
4
|
import { availableParallelism, freemem, totalmem } from 'node:os';
|
|
5
5
|
import { Worker } from 'node:worker_threads';
|
|
6
6
|
import { blurSampleReport } from './post.mjs';
|
|
7
|
+
import { createCaptureWatchdog } from './capture-watchdog.mjs';
|
|
7
8
|
|
|
8
9
|
export function defaultWorkers(scale = 1, hardware = {}) {
|
|
9
10
|
const cores = hardware.cores ?? availableParallelism();
|
|
@@ -12,24 +13,50 @@ export function defaultWorkers(scale = 1, hardware = {}) {
|
|
|
12
13
|
return Math.max(1, Math.min(12, Math.floor(cores / 2), Math.floor((free - 2 * 1024 ** 3) / workerBytes)));
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
export function createRenderPool(shot, options, fps, workers, size, onWarning, onCaptured) {
|
|
16
|
+
export function createRenderPool(shot, options, fps, workers, size, onWarning, onCaptured, onInput) {
|
|
16
17
|
options = { ...options, _browserRecovery: options._browserRecovery ?? recoveryBudget() };
|
|
17
18
|
reuseState(options);
|
|
18
19
|
const slots = [], stats = { frames: 0, samples: 0, staticFrames: 0 };
|
|
19
20
|
const histogram = {}, dofHistogram = {};
|
|
20
21
|
const captured = new Set();
|
|
21
|
-
let failure;
|
|
22
|
+
let failure, stopping = false, closing;
|
|
23
|
+
function close() {
|
|
24
|
+
if (closing) return closing;
|
|
25
|
+
stopping = true; watchdog.stop();
|
|
26
|
+
for (const slot of slots) if (!slot.closed) slot.worker.postMessage({ type: 'close' });
|
|
27
|
+
// A blocked page cannot finish its frame. Close only browsers owned by
|
|
28
|
+
// these workers after the grace period, letting their normal cleanup run.
|
|
29
|
+
const timer = setTimeout(() => {
|
|
30
|
+
for (const slot of slots) if (!slot.closed) slot.worker.postMessage({ type: 'cancel' });
|
|
31
|
+
}, options._stopGraceMs ?? 5000); // Internal override for short fault tests.
|
|
32
|
+
closing = Promise.all(slots.map(s => s.exited)).finally(() => clearTimeout(timer));
|
|
33
|
+
return closing;
|
|
34
|
+
}
|
|
35
|
+
// Internal deadline override for short fault-injection tests; no CLI option.
|
|
36
|
+
const watchdog = createCaptureWatchdog(slots, (slot, details) => {
|
|
37
|
+
if (failure || stopping) return;
|
|
38
|
+
try {
|
|
39
|
+
onWarning?.({ code: 'worker_stalled', severity: 'warning', message: 'Rendering worker stalled.', details });
|
|
40
|
+
slot.worker.postMessage({ type: 'stalled', details });
|
|
41
|
+
} catch (error) { fail(error); }
|
|
42
|
+
}, { minimumMs: options._stallTimeoutMs });
|
|
43
|
+
const fail = error => {
|
|
44
|
+
failure ??= error;
|
|
45
|
+
watchdog.stop();
|
|
46
|
+
for (const s of slots) { s.pending?.reject(failure); s.pending = null; }
|
|
47
|
+
void close();
|
|
48
|
+
};
|
|
22
49
|
for (let i = 0; i < workers; i++) {
|
|
23
50
|
const worker = new Worker(new URL('./render-worker.mjs', import.meta.url), { workerData: { shot, options, fps, size } });
|
|
24
51
|
const slot = { worker, pending: null, closed: false };
|
|
25
|
-
const fail = error => {
|
|
26
|
-
failure ??= error;
|
|
27
|
-
for (const s of slots) { s.pending?.reject(error); s.pending = null; }
|
|
28
|
-
};
|
|
29
52
|
slot.ready = new Promise((resolve, reject) => { slot.pending = { resolve, reject }; });
|
|
30
53
|
slot.ready.catch(() => {});
|
|
31
54
|
worker.on('message', message => {
|
|
55
|
+
if (message.type === 'frame-start') { slot.frame = message.frame; slot.frameStarted = performance.now(); return; }
|
|
56
|
+
if (message.type === 'recovered') { watchdog.recovered(slot); return; }
|
|
57
|
+
if (message.type === 'input') { try { onInput?.(message.file, message.record); } catch (error) { fail(error); } return; }
|
|
32
58
|
if (message.type === 'captured') {
|
|
59
|
+
watchdog.completed(slot);
|
|
33
60
|
if (!captured.has(message.frame)) {
|
|
34
61
|
captured.add(message.frame);
|
|
35
62
|
try { onCaptured?.(captured.size); } catch (error) { fail(error); }
|
|
@@ -39,6 +66,7 @@ export function createRenderPool(shot, options, fps, workers, size, onWarning, o
|
|
|
39
66
|
if (message.type === 'warning') { onWarning?.(message.warning); return; }
|
|
40
67
|
if (message.type === 'failure') { fail(new MultiplaneError(message.code ?? 'worker_crashed', message.error, { details: message.details, hint: message.hint })); return; }
|
|
41
68
|
const pending = slot.pending; slot.pending = null;
|
|
69
|
+
slot.job = null;
|
|
42
70
|
pending?.resolve(message);
|
|
43
71
|
});
|
|
44
72
|
worker.on('error', error => fail(new MultiplaneError('worker_crashed', undefined, { cause: error })));
|
|
@@ -51,7 +79,7 @@ export function createRenderPool(shot, options, fps, workers, size, onWarning, o
|
|
|
51
79
|
}
|
|
52
80
|
const api = {
|
|
53
81
|
stats,
|
|
54
|
-
get blurSamples() { return blurSampleReport(histogram); },
|
|
82
|
+
get blurSamples() { return blurSampleReport(histogram); },
|
|
55
83
|
get dofSamples() { return blurSampleReport(dofHistogram); },
|
|
56
84
|
get staticReuse() { return reuseReport(options); },
|
|
57
85
|
progress: () => `Samples: ${stats.samples}; static: ${stats.staticFrames}; adaptive: ${stats.adaptiveFrames ?? 0}; hooks: ${stats.hookFrames ?? 0}`,
|
|
@@ -66,6 +94,8 @@ export function createRenderPool(shot, options, fps, workers, size, onWarning, o
|
|
|
66
94
|
next = Math.min(end, next + chunk, reuseEnabled(options) ? (Math.floor(begin / REUSE_INTERVAL) + 1) * REUSE_INTERVAL + 1 : end);
|
|
67
95
|
const promise = new Promise((resolve, reject) => { slot.pending = { resolve, reject }; });
|
|
68
96
|
promise.catch(() => {});
|
|
97
|
+
slot.job = { first: begin, end: next }; slot.frame = begin;
|
|
98
|
+
slot.lastDelivery = performance.now(); slot.frameStarted = undefined;
|
|
69
99
|
slot.worker.postMessage({ type: 'render', first: begin, end: next });
|
|
70
100
|
queue.push({ slot, promise });
|
|
71
101
|
};
|
|
@@ -78,7 +108,7 @@ export function createRenderPool(shot, options, fps, workers, size, onWarning, o
|
|
|
78
108
|
if (options.transport === 'raw') schedule(slot);
|
|
79
109
|
// Worker audits have already replaced invalid frames in this batch.
|
|
80
110
|
for (const [i, png] of batch.frames.entries()) {
|
|
81
|
-
const count = batch.blurSamples[i], dofCount = batch.dofSamples[i];
|
|
111
|
+
const count = batch.blurSamples[i], dofCount = batch.dofSamples[i];
|
|
82
112
|
dofHistogram[dofCount] = (dofHistogram[dofCount] ?? 0) + 1;
|
|
83
113
|
histogram[count] = (histogram[count] ?? 0) + 1;
|
|
84
114
|
yield { frame: batch.first + i, png, hash: batch.hashes?.[i], blurSamples: count, dofSamples: dofCount };
|
|
@@ -86,13 +116,10 @@ export function createRenderPool(shot, options, fps, workers, size, onWarning, o
|
|
|
86
116
|
if (options.transport !== 'raw') schedule(slot);
|
|
87
117
|
}
|
|
88
118
|
},
|
|
89
|
-
|
|
90
|
-
for (const slot of slots) if (!slot.closed) slot.worker.postMessage({ type: 'close' });
|
|
91
|
-
await Promise.all(slots.map(s => s.exited));
|
|
92
|
-
},
|
|
119
|
+
close,
|
|
93
120
|
async cancel() {
|
|
94
|
-
|
|
95
|
-
await
|
|
121
|
+
fail(new MultiplaneError('cancelled'));
|
|
122
|
+
await close();
|
|
96
123
|
},
|
|
97
124
|
};
|
|
98
125
|
const frames = api.frames.bind(api);
|
package/src/render-worker.mjs
CHANGED
|
@@ -3,8 +3,11 @@ import { MultiplaneError, asError } from './errors.mjs';
|
|
|
3
3
|
import { parentPort, workerData, isMarkedAsUntransferable } from 'node:worker_threads';
|
|
4
4
|
import { openShot } from './capture.mjs';
|
|
5
5
|
import { createPostCapture, resolveScale } from './post.mjs';
|
|
6
|
+
import { inputRecords } from './input-records.mjs';
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
if (workerData.options._recordInputs) inputRecords.enterWith((file, record) => parentPort.postMessage({ type: 'input', file, record }));
|
|
9
|
+
|
|
10
|
+
let capture, closing = false, running = false, activeFrame;
|
|
8
11
|
const sendFailure = error => {
|
|
9
12
|
const failure = asError(error);
|
|
10
13
|
parentPort.postMessage({ type: 'failure', error: failure.message, code: failure.code, details: failure.details, hint: failure.hint });
|
|
@@ -16,12 +19,17 @@ async function close() {
|
|
|
16
19
|
parentPort.close();
|
|
17
20
|
}
|
|
18
21
|
try {
|
|
19
|
-
capture = await openShot(workerData.shot, resolveScale(workerData.options).renderScale, { ...workerData.options, onWarning: warning => parentPort.postMessage({ type: 'warning', warning }), check: () => { if (closing) throw new MultiplaneError('cancelled'); } });
|
|
22
|
+
capture = await openShot(workerData.shot, resolveScale(workerData.options).renderScale, { ...workerData.options, onRecovered: () => parentPort.postMessage({ type: 'recovered' }), onWarning: warning => parentPort.postMessage({ type: 'warning', warning }), check: () => { if (closing) throw new MultiplaneError('cancelled'); } });
|
|
20
23
|
if (workerData.size && (capture.width !== workerData.size.width || capture.height !== workerData.size.height)) throw new MultiplaneError('internal_error', 'Worker stage dimensions differ from the main process.');
|
|
21
24
|
const processed = createPostCapture(capture, { ...workerData.options, onWarning: warning => parentPort.postMessage({ type: 'warning', warning }) }, workerData.fps);
|
|
22
25
|
parentPort.on('message', async message => {
|
|
23
26
|
if (message.type === 'cancel') { closing = true; await capture?.close(); if (!running) parentPort.close(); return; }
|
|
24
27
|
if (message.type === 'close') { await close(); return; }
|
|
28
|
+
if (message.type === 'stalled') {
|
|
29
|
+
if (!closing && activeFrame === message.details.frame) await capture.stall(message.details).catch(sendFailure);
|
|
30
|
+
else parentPort.postMessage({ type: 'recovered' });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
25
33
|
if (closing) return;
|
|
26
34
|
running = true;
|
|
27
35
|
try {
|
|
@@ -29,7 +37,10 @@ try {
|
|
|
29
37
|
const source = async function* (first, end) {
|
|
30
38
|
for (let index = first; index < end && !closing; index++) {
|
|
31
39
|
const frame = message.first + index;
|
|
40
|
+
activeFrame = frame;
|
|
41
|
+
parentPort.postMessage({ type: 'frame-start', frame });
|
|
32
42
|
const png = await processed.frame(frame / workerData.fps, frame, index === end - 1);
|
|
43
|
+
activeFrame = undefined;
|
|
33
44
|
parentPort.postMessage({ type: 'captured', frame });
|
|
34
45
|
yield { png, hash: processed.lastHash, blurSamples: processed.lastBlurSamples, dofSamples: processed.lastDofSamples };
|
|
35
46
|
}
|
|
@@ -41,7 +52,7 @@ try {
|
|
|
41
52
|
const png = result.byteOffset === 0 && result.byteLength === result.buffer.byteLength && !isMarkedAsUntransferable(result.buffer)
|
|
42
53
|
? new Uint8Array(result.buffer) : Uint8Array.from(result);
|
|
43
54
|
frames.push(png);
|
|
44
|
-
blurSamples.push(item.blurSamples);
|
|
55
|
+
blurSamples.push(item.blurSamples);
|
|
45
56
|
dofSamples.push(item.dofSamples);
|
|
46
57
|
if (workerData.options.verify) hashes.push(item.hash);
|
|
47
58
|
}
|