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.
Files changed (80) hide show
  1. package/LICENSE +202 -202
  2. package/README.en.md +3 -0
  3. package/README.md +3 -0
  4. package/THIRD_PARTY_NOTICES.md +30 -30
  5. package/bin/multiplane +24 -14
  6. package/examples/alpha.html +18 -18
  7. package/examples/animation.html +17 -17
  8. package/examples/assets/OFL.txt +93 -93
  9. package/examples/assets/card.svg +6 -6
  10. package/examples/three-cards.html +17 -17
  11. package/examples/typography.html +17 -17
  12. package/licenses/GPL-3.0.txt +232 -232
  13. package/licenses/LGPL-3.0.txt +304 -304
  14. package/licenses/acorn-LICENSE +21 -21
  15. package/licenses/colour-LICENSE.md +82 -82
  16. package/licenses/detect-libc-LICENSE +201 -201
  17. package/licenses/entities-LICENSE +11 -11
  18. package/licenses/nanoid-LICENSE +20 -20
  19. package/licenses/parse5-LICENSE +19 -19
  20. package/licenses/picocolors-LICENSE +15 -15
  21. package/licenses/playwright-NOTICE +5 -5
  22. package/licenses/playwright-ThirdPartyNotices.txt +14 -14
  23. package/licenses/playwright-core-ThirdPartyNotices.txt +13 -13
  24. package/licenses/postcss-LICENSE +20 -20
  25. package/licenses/postcss-value-parser-LICENSE +22 -22
  26. package/licenses/semver-LICENSE +15 -15
  27. package/licenses/sharp-LICENSE +191 -191
  28. package/licenses/sharp-win32-x64-NOTICES.md +45 -45
  29. package/licenses/source-map-js-LICENSE +28 -28
  30. package/licenses/three-LICENSE +21 -21
  31. package/package.json +92 -92
  32. package/skills/multiplane/SKILL.md +3 -0
  33. package/src/alpha.mjs +69 -69
  34. package/src/artifacts.mjs +61 -61
  35. package/src/browser-recovery.mjs +26 -26
  36. package/src/capture-watchdog.mjs +29 -0
  37. package/src/capture.mjs +34 -18
  38. package/src/encoder.mjs +16 -16
  39. package/src/environment.mjs +56 -56
  40. package/src/errors.mjs +1 -0
  41. package/src/events.mjs +12 -10
  42. package/src/ffmpeg.mjs +32 -32
  43. package/src/format.mjs +27 -27
  44. package/src/frame-rate.mjs +28 -28
  45. package/src/home-migration.mjs +54 -54
  46. package/src/hosted.mjs +3 -3
  47. package/src/index.mjs +1 -0
  48. package/src/input-records.mjs +33 -0
  49. package/src/jobs.mjs +3 -1
  50. package/src/lint.mjs +248 -248
  51. package/src/output-alpha.mjs +36 -36
  52. package/src/output-check.mjs +110 -110
  53. package/src/platform.mjs +114 -114
  54. package/src/png-native.mjs +12 -12
  55. package/src/png.mjs +74 -74
  56. package/src/post.mjs +56 -39
  57. package/src/preview.mjs +23 -23
  58. package/src/process.mjs +9 -9
  59. package/src/render-pool.mjs +41 -14
  60. package/src/render-worker.mjs +14 -3
  61. package/src/render.mjs +148 -52
  62. package/src/resource-path.mjs +29 -29
  63. package/src/resources.mjs +27 -27
  64. package/src/resume.mjs +23 -0
  65. package/src/runtime/camera.js +37 -37
  66. package/src/runtime/shot.css +9 -9
  67. package/src/segment-encoder.mjs +91 -0
  68. package/src/segments.mjs +123 -0
  69. package/src/server.mjs +11 -7
  70. package/src/setup.mjs +51 -51
  71. package/src/skills.mjs +128 -128
  72. package/src/stills.mjs +1 -1
  73. package/src/three/index.js +39 -31
  74. package/src/verify.mjs +2 -2
  75. package/src/video-pipe.mjs +6 -6
  76. package/tools/bench/shots/canvas-2d.html +1 -1
  77. package/tools/bench/shots/dom-light.html +1 -1
  78. package/tools/bench/shots/scene.js +44 -44
  79. package/tools/bench/shots/webgl.html +1 -1
  80. 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
- await sampleMany([...endpoints, t], true);
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
- const still = endpoints.every(time => cache.get(time).image.data.equals(centerProbe.image.data));
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
- if (motionProbeUsed && !finalCapture) {
403
- finalCapture = true;
404
- dofMotionSamples = new Set(selectedTimes).size;
405
- for (const [time, item] of cache) if (item.probe) cache.delete(time);
406
- observedTime = undefined;
407
- await sampleMany(selectedTimes, true);
408
- const images = selectedTimes.map(time => cache.get(time).image);
409
- png = selectedTimes.length === 1 ? cache.get(selectedTimes[0]).png : undefined;
410
- result = selectedTimes.length === 1 ? images[0] : timed('averageMs', () => average(images));
411
- }
412
- usedDofSamples = [...new Set(selectedTimes)].reduce((sum, time) => sum + (cache.get(time)?.dofSamples ?? 0), 0);
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
+ }
@@ -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
- async close() {
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
- for (const slot of slots) if (!slot.closed) slot.worker.postMessage({ type: 'cancel' });
95
- await Promise.all(slots.map(s => s.exited));
121
+ fail(new MultiplaneError('cancelled'));
122
+ await close();
96
123
  },
97
124
  };
98
125
  const frames = api.frames.bind(api);
@@ -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
- let capture, closing = false, running = false;
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
  }