multiplane 0.3.3 → 0.3.7

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 (72) hide show
  1. package/LICENSE +202 -202
  2. package/README.en.md +327 -326
  3. package/README.md +327 -326
  4. package/THIRD_PARTY_NOTICES.md +30 -30
  5. package/examples/alpha.html +18 -18
  6. package/examples/animation.html +17 -17
  7. package/examples/assets/OFL.txt +93 -93
  8. package/examples/assets/card.svg +6 -6
  9. package/examples/three-cards.html +17 -17
  10. package/examples/typography.html +17 -17
  11. package/licenses/GPL-3.0.txt +232 -232
  12. package/licenses/LGPL-3.0.txt +304 -304
  13. package/licenses/acorn-LICENSE +21 -21
  14. package/licenses/colour-LICENSE.md +82 -82
  15. package/licenses/detect-libc-LICENSE +201 -201
  16. package/licenses/entities-LICENSE +11 -11
  17. package/licenses/nanoid-LICENSE +20 -20
  18. package/licenses/parse5-LICENSE +19 -19
  19. package/licenses/picocolors-LICENSE +15 -15
  20. package/licenses/playwright-NOTICE +5 -5
  21. package/licenses/playwright-ThirdPartyNotices.txt +14 -14
  22. package/licenses/playwright-core-ThirdPartyNotices.txt +13 -13
  23. package/licenses/postcss-LICENSE +20 -20
  24. package/licenses/postcss-value-parser-LICENSE +22 -22
  25. package/licenses/semver-LICENSE +15 -15
  26. package/licenses/sharp-LICENSE +191 -191
  27. package/licenses/sharp-win32-x64-NOTICES.md +45 -45
  28. package/licenses/source-map-js-LICENSE +28 -28
  29. package/licenses/three-LICENSE +21 -21
  30. package/package.json +1 -1
  31. package/skills/multiplane/SKILL.md +215 -213
  32. package/src/alpha.mjs +69 -69
  33. package/src/artifacts.mjs +61 -61
  34. package/src/browser-recovery.mjs +26 -26
  35. package/src/capture.mjs +16 -6
  36. package/src/encoder.mjs +16 -16
  37. package/src/environment.mjs +56 -56
  38. package/src/events.mjs +3 -0
  39. package/src/ffmpeg.mjs +32 -32
  40. package/src/format.mjs +27 -27
  41. package/src/frame-rate.mjs +28 -28
  42. package/src/home-migration.mjs +54 -54
  43. package/src/hosted.mjs +1 -1
  44. package/src/lint.mjs +248 -248
  45. package/src/output-alpha.mjs +36 -36
  46. package/src/output-check.mjs +110 -110
  47. package/src/platform.mjs +114 -114
  48. package/src/png-native.mjs +12 -12
  49. package/src/png.mjs +74 -74
  50. package/src/post.mjs +55 -21
  51. package/src/preview.mjs +23 -23
  52. package/src/process.mjs +9 -9
  53. package/src/render-pool.mjs +16 -6
  54. package/src/render-worker.mjs +5 -3
  55. package/src/render.mjs +5 -5
  56. package/src/resource-path.mjs +29 -29
  57. package/src/resources.mjs +27 -27
  58. package/src/runtime/camera.js +37 -37
  59. package/src/runtime/shot.css +9 -9
  60. package/src/server.mjs +7 -1
  61. package/src/setup.mjs +51 -51
  62. package/src/skills.mjs +128 -128
  63. package/src/stills.mjs +5 -4
  64. package/src/three/index.js +44 -14
  65. package/src/verify.mjs +3 -1
  66. package/src/video-pipe.mjs +6 -6
  67. package/src/workers.mjs +5 -1
  68. package/tools/bench/shots/canvas-2d.html +1 -1
  69. package/tools/bench/shots/dom-light.html +1 -1
  70. package/tools/bench/shots/scene.js +44 -44
  71. package/tools/bench/shots/webgl.html +1 -1
  72. package/tools/install-browser.mjs +18 -18
package/src/post.mjs CHANGED
@@ -45,17 +45,19 @@ export async function resizeImage(image, width, height, upscale) {
45
45
  }
46
46
  export function resolveQuality(options = {}) {
47
47
  const quality = options.quality ?? (options.draft ? 'draft' : 'standard');
48
- if (!['draft', 'standard', 'high'].includes(quality) || (options.draft && quality !== 'draft')) throw invalidOption('--quality', 'Quality must be draft, standard or high, and agree with --draft.');
48
+ if (!['draft', 'fast', 'standard', 'high'].includes(quality) || (options.draft && quality !== 'draft')) throw invalidOption('--quality', 'Quality must be draft, fast, standard or high, and agree with --draft.');
49
49
  return quality;
50
50
  }
51
51
 
52
52
  export function resolvePost(options = {}, post = {}) {
53
53
  const quality = resolveQuality(options);
54
54
  if (options['no-post']) return { motionBlur: 1, maxMotionBlur: 1, shutter: 0.5, grain: 0 };
55
- const motionBlur = number(options['motion-blur'] ?? post.motionBlur ?? 1, 'motionBlur', { positive: true });
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 : 64), '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
+ // Preserve explicit bases and fixed sampling before applying the preset cap.
60
+ if ((quality === 'standard' || quality === 'fast') && options['motion-blur'] == null && maxMotionBlur > motionBlur) motionBlur = Math.min(motionBlur, 4);
59
61
  // TODO(design): Document max <= base as disabling upward adaptation, including draft overrides.
60
62
  return { motionBlur, maxMotionBlur, shutter: number(options.shutter ?? post.shutter ?? 0.5, 'shutter'), grain: number(options.grain ?? post.grain ?? 0, 'grain') };
61
63
  }
@@ -254,13 +256,15 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
254
256
  const grids = upward ? nestedSampleGrids(t, fps, capture.duration, capture.cuts, post) : null;
255
257
  const endpoints = sampleTimes(t, fps, capture.duration, capture.cuts, { motionBlur: 2, shutter: post.shutter * 2 });
256
258
  const times = sampleTimes(t, fps, capture.duration, capture.cuts, { ...post, motionBlur: upward ? Math.max(2, motionBlur) : motionBlur });
259
+ // Clipped or zero-width shutters may contain fewer unique temporal samples.
260
+ const motionBlurBase = new Set(upward ? grids.get(motionBlur) : times).size;
257
261
  let candidate = false, uniform = motionBlur === 1, observedTime;
258
262
  if (enabled && !Atomics.load(state, 0)) {
259
263
  const probes = [...new Set(motionBlur === 1 && !upward ? [t] : [...endpoints, ...times, t])].sort((a, b) => a - b);
260
264
  const changes = [];
261
265
  const observeStart = performance.now();
262
266
  for (const time of probes) {
263
- changes.push(await capture.observe(time, { motionBlur }));
267
+ changes.push(await capture.observe(time, { motionBlur, motionBlurBase }));
264
268
  observedTime = time;
265
269
  if (changes.at(-1)) break;
266
270
  }
@@ -272,17 +276,25 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
272
276
  }
273
277
  const verifyReuse = candidate && (audit || previousRaw.run === 0 || frame % REUSE_INTERVAL === 0);
274
278
  const cache = new Map();
275
- let contextUsed = false;
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 });
276
287
  const sample = async (time, reuse = false) => {
277
288
  if (!cache.has(time)) {
278
289
  const start = performance.now();
279
- const observed = time === observedTime;
290
+ const observed = time === observedTime && !(motionBlur > 1 && capture.motionProbeUsed);
280
291
  observedTime = undefined;
281
- const png = await capture.frame(time, { motionBlur, observed });
292
+ const png = await capture.frame(time, context(observed, time));
282
293
  contextUsed ||= capture.sampleContextUsed === true;
294
+ motionProbeUsed ||= capture.motionProbeUsed === true;
283
295
  stats.captureMs += performance.now() - start;
284
296
  stats.samples++;
285
- cache.set(time, { png, image: post.motionBlur > 1 || upward || post.grain > 0 ? await decode(png, reuse) : null });
297
+ cache.set(time, { png, dofSamples: capture.dofSamples ?? 0, probe: capture.motionProbeUsed === true && !finalCapture, image: post.motionBlur > 1 || upward || post.grain > 0 ? await decode(png, reuse) : null });
286
298
  }
287
299
  return cache.get(time);
288
300
  };
@@ -294,20 +306,22 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
294
306
  return;
295
307
  }
296
308
  const begin = async time => {
297
- const start = performance.now(), observed = time === observedTime;
309
+ const start = performance.now(), observed = time === observedTime && !(motionBlur > 1 && capture.motionProbeUsed);
298
310
  observedTime = undefined;
299
- const pending = await capture.beginFrame(time, { motionBlur, observed });
311
+ const pending = await capture.beginFrame(time, context(observed, time));
300
312
  contextUsed ||= capture.sampleContextUsed === true;
313
+ motionProbeUsed ||= capture.motionProbeUsed === true;
301
314
  const png = pending.png.then(buffer => { stats.captureMs += performance.now() - start; return buffer; });
302
315
  png.catch(() => {});
303
- return { png };
316
+ return { png, dofSamples: capture.dofSamples ?? 0, probe: capture.motionProbeUsed === true && !finalCapture };
304
317
  };
305
318
  let pending = await begin(missing[0]);
306
319
  for (let i = 0; i < missing.length; i++) {
307
320
  const png = await pending.png;
321
+ const { dofSamples, probe } = pending;
308
322
  const next = i + 1 < missing.length ? await begin(missing[i + 1]) : null;
309
323
  stats.samples++;
310
- cache.set(missing[i], { png, image: await decode(png, reuse) });
324
+ cache.set(missing[i], { png, dofSamples, probe, image: await decode(png, reuse) });
311
325
  pending = next;
312
326
  }
313
327
  };
@@ -315,7 +329,7 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
315
329
  let png, result, usedSamples = motionBlur;
316
330
  if (candidate && !verifyReuse) { ({ png, image: result, usedSamples } = previousRaw); }
317
331
  else if (upward) {
318
- // Calibrated in capture raster pixels: about 2px for standard, 1px for high.
332
+ // Calibrated in capture raster pixels: about 2px for fast/standard, 1px for high.
319
333
  const threshold = resolveQuality(options) === 'high' ? 1 : 2;
320
334
  await sampleMany([...endpoints, t], true);
321
335
  const centerProbe = cache.get(t);
@@ -323,7 +337,7 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
323
337
  let legacy = contextUsed;
324
338
  let grid = legacy ? times : grids.get(motionBlur);
325
339
  if (still) {
326
- png = centerProbe.png; result = centerProbe.image; usedSamples = 1;
340
+ png = centerProbe.png; result = centerProbe.image; usedSamples = 1; selectedTimes = [t];
327
341
  stats.staticFrames++; stats.adaptiveFrames++;
328
342
  } else {
329
343
  if (legacy) cache.clear();
@@ -338,13 +352,16 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
338
352
  const images = grid.map(time => cache.get(time).image), center = cache.get(t);
339
353
  const identical = images.every(image => image.data.equals(center.image.data));
340
354
  if (identical) {
341
- png = center.png; result = center.image; usedSamples = 1;
355
+ png = center.png; result = center.image; usedSamples = 1; selectedTimes = [t];
342
356
  stats.staticFrames++; stats.adaptiveFrames++; break;
343
357
  }
344
358
  let difference = 0;
345
- for (let i = 1; i < images.length && difference <= threshold; i++) difference = Math.max(difference, localMotionDifference(images[i - 1], images[i], alpha, threshold));
359
+ for (let i = 1; i < images.length && difference <= threshold; i++) {
360
+ // All decision images share the output frame's fixed lens phase.
361
+ difference = Math.max(difference, localMotionDifference(images[i - 1], images[i], alpha, threshold));
362
+ }
346
363
  if (difference <= threshold || motionBlur >= post.maxMotionBlur) {
347
- usedSamples = motionBlur;
364
+ usedSamples = motionBlur; selectedTimes = grid;
348
365
  result = timed('averageMs', () => average(images)); break;
349
366
  }
350
367
  motionBlur = Math.min(post.maxMotionBlur, motionBlur * 2);
@@ -353,7 +370,7 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
353
370
  grid = legacy ? sampleTimes(t, fps, capture.duration, capture.cuts, { ...post, motionBlur }) : grids.get(motionBlur);
354
371
  }
355
372
  }
356
- } else if (motionBlur === 1) ({ png, image: result } = await sample(t));
373
+ } else if (motionBlur === 1) { selectedTimes = [t]; ({ png, image: result } = await sample(t)); }
357
374
  else if (hooked) {
358
375
  await sampleMany(times);
359
376
  result = timed('averageMs', () => average(times.map(time => cache.get(time).image)));
@@ -362,7 +379,7 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
362
379
  const first = cache.get(times[0]), last = cache.get(times.at(-1));
363
380
  const identical = first.image.data === last.image.data || first.image.data.equals(last.image.data);
364
381
  if (identical || (!alpha && imageDifference(first.image, last.image) < adaptive)) {
365
- png = first.png; result = first.image; usedSamples = 1; stats.adaptiveFrames++;
382
+ png = first.png; result = first.image; usedSamples = 1; selectedTimes = [times[0]]; stats.adaptiveFrames++;
366
383
  if (identical) stats.staticFrames++;
367
384
  } else {
368
385
  await sampleMany(times);
@@ -372,13 +389,29 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
372
389
  await sampleMany([times[0], times.at(-1), t]);
373
390
  const first = cache.get(times[0]), last = cache.get(times.at(-1)), center = cache.get(t);
374
391
  if (first.image.data.equals(last.image.data) && first.image.data.equals(center.image.data)) {
375
- png = center.png; result = center.image; usedSamples = 1; stats.staticFrames++;
392
+ png = center.png; result = center.image; usedSamples = 1; selectedTimes = [t]; stats.staticFrames++;
376
393
  } else {
377
394
  await sampleMany(times);
378
395
  const images = times.map(time => cache.get(time).image);
379
396
  result = timed('averageMs', () => average(images));
380
397
  }
381
398
  }
399
+ let usedDofSamples = previousRaw?.usedDofSamples ?? 0;
400
+ if (!(candidate && !verifyReuse)) {
401
+ // Only lens-dependent probe captures need replacing. DOM captures and
402
+ // final hook captures remain reusable at the same time and context.
403
+ if (motionProbeUsed && !finalCapture) {
404
+ finalCapture = true;
405
+ dofMotionSamples = new Set(selectedTimes).size;
406
+ for (const [time, item] of cache) if (item.probe) cache.delete(time);
407
+ observedTime = undefined;
408
+ await sampleMany(selectedTimes, true);
409
+ const images = selectedTimes.map(time => cache.get(time).image);
410
+ png = selectedTimes.length === 1 ? cache.get(selectedTimes[0]).png : undefined;
411
+ result = selectedTimes.length === 1 ? images[0] : timed('averageMs', () => average(images));
412
+ }
413
+ usedDofSamples = [...new Set(selectedTimes)].reduce((sum, time) => sum + (cache.get(time)?.dofSamples ?? 0), 0);
414
+ }
382
415
  if (verifyReuse) {
383
416
  stats.auditMs += performance.now() - auditStart;
384
417
  Atomics.add(state, 2, 1);
@@ -394,9 +427,10 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
394
427
  if (candidate && !verifyReuse) Atomics.add(state, 1, 1);
395
428
  // Keep a decoded raw reference as well; never retain a transferable output buffer.
396
429
  if (enabled && options.transport === 'raw') result ??= await decode(png);
397
- previousRaw = { t, frame, motionBlur, uniform, png, image: result, usedSamples, run: candidate ? previousRaw.run + 1 : 0 };
430
+ previousRaw = { t, frame, motionBlur, uniform, png, image: result, usedSamples, usedDofSamples, run: candidate ? previousRaw.run + 1 : 0 };
398
431
  // Contributing temporal samples, excluding probes and failed attempts.
399
432
  api.lastBlurSamples = usedSamples;
433
+ api.lastDofSamples = usedDofSamples;
400
434
  stats.frames++;
401
435
  if (sizes.renderScale !== sizes.scale) {
402
436
  result ??= await decode(png);
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
+ }
@@ -12,11 +12,12 @@ export function defaultWorkers(scale = 1, hardware = {}) {
12
12
  return Math.max(1, Math.min(12, Math.floor(cores / 2), Math.floor((free - 2 * 1024 ** 3) / workerBytes)));
13
13
  }
14
14
 
15
- export function createRenderPool(shot, options, fps, workers, size, onWarning) {
15
+ export function createRenderPool(shot, options, fps, workers, size, onWarning, onCaptured) {
16
16
  options = { ...options, _browserRecovery: options._browserRecovery ?? recoveryBudget() };
17
17
  reuseState(options);
18
18
  const slots = [], stats = { frames: 0, samples: 0, staticFrames: 0 };
19
- const histogram = {};
19
+ const histogram = {}, dofHistogram = {};
20
+ const captured = new Set();
20
21
  let failure;
21
22
  for (let i = 0; i < workers; i++) {
22
23
  const worker = new Worker(new URL('./render-worker.mjs', import.meta.url), { workerData: { shot, options, fps, size } });
@@ -28,6 +29,13 @@ export function createRenderPool(shot, options, fps, workers, size, onWarning) {
28
29
  slot.ready = new Promise((resolve, reject) => { slot.pending = { resolve, reject }; });
29
30
  slot.ready.catch(() => {});
30
31
  worker.on('message', message => {
32
+ if (message.type === 'captured') {
33
+ if (!captured.has(message.frame)) {
34
+ captured.add(message.frame);
35
+ try { onCaptured?.(captured.size); } catch (error) { fail(error); }
36
+ }
37
+ return;
38
+ }
31
39
  if (message.type === 'warning') { onWarning?.(message.warning); return; }
32
40
  if (message.type === 'failure') { fail(new MultiplaneError(message.code ?? 'worker_crashed', message.error, { details: message.details, hint: message.hint })); return; }
33
41
  const pending = slot.pending; slot.pending = null;
@@ -44,6 +52,7 @@ export function createRenderPool(shot, options, fps, workers, size, onWarning) {
44
52
  const api = {
45
53
  stats,
46
54
  get blurSamples() { return blurSampleReport(histogram); },
55
+ get dofSamples() { return blurSampleReport(dofHistogram); },
47
56
  get staticReuse() { return reuseReport(options); },
48
57
  progress: () => `Samples: ${stats.samples}; static: ${stats.staticFrames}; adaptive: ${stats.adaptiveFrames ?? 0}; hooks: ${stats.hookFrames ?? 0}`,
49
58
  async *frames(first, end) {
@@ -69,9 +78,10 @@ export function createRenderPool(shot, options, fps, workers, size, onWarning) {
69
78
  if (options.transport === 'raw') schedule(slot);
70
79
  // Worker audits have already replaced invalid frames in this batch.
71
80
  for (const [i, png] of batch.frames.entries()) {
72
- const count = batch.blurSamples[i];
81
+ const count = batch.blurSamples[i], dofCount = batch.dofSamples[i];
82
+ dofHistogram[dofCount] = (dofHistogram[dofCount] ?? 0) + 1;
73
83
  histogram[count] = (histogram[count] ?? 0) + 1;
74
- yield { frame: batch.first + i, png, hash: batch.hashes?.[i], blurSamples: count };
84
+ yield { frame: batch.first + i, png, hash: batch.hashes?.[i], blurSamples: count, dofSamples: dofCount };
75
85
  }
76
86
  if (options.transport !== 'raw') schedule(slot);
77
87
  }
@@ -86,8 +96,8 @@ export function createRenderPool(shot, options, fps, workers, size, onWarning) {
86
96
  },
87
97
  };
88
98
  const frames = api.frames.bind(api);
89
- api.frames = async function* (first, end, onCaptured) {
90
- for await (const item of frames(first, end)) { await onCaptured?.(item); yield item; }
99
+ api.frames = async function* (first, end, onDelivered) {
100
+ for await (const item of frames(first, end)) { await onDelivered?.(item); yield item; }
91
101
  };
92
102
  return api;
93
103
  }
@@ -25,12 +25,13 @@ try {
25
25
  if (closing) return;
26
26
  running = true;
27
27
  try {
28
- const frames = [], hashes = [], blurSamples = [], before = { ...processed.stats };
28
+ const frames = [], hashes = [], blurSamples = [], dofSamples = [], before = { ...processed.stats };
29
29
  const source = async function* (first, end) {
30
30
  for (let index = first; index < end && !closing; index++) {
31
31
  const frame = message.first + index;
32
32
  const png = await processed.frame(frame / workerData.fps, frame, index === end - 1);
33
- yield { png, hash: processed.lastHash, blurSamples: processed.lastBlurSamples };
33
+ parentPort.postMessage({ type: 'captured', frame });
34
+ yield { png, hash: processed.lastHash, blurSamples: processed.lastBlurSamples, dofSamples: processed.lastDofSamples };
34
35
  }
35
36
  };
36
37
  // RAW post output already owns an independent buffer. Keep it until the
@@ -41,10 +42,11 @@ try {
41
42
  ? new Uint8Array(result.buffer) : Uint8Array.from(result);
42
43
  frames.push(png);
43
44
  blurSamples.push(item.blurSamples);
45
+ dofSamples.push(item.dofSamples);
44
46
  if (workerData.options.verify) hashes.push(item.hash);
45
47
  }
46
48
  const stats = Object.fromEntries(Object.entries(processed.stats).map(([key, value]) => [key, value - before[key]]));
47
- if (!closing) parentPort.postMessage({ type: 'frames', first: message.first, frames, hashes, blurSamples, stats }, frames.map(f => f.buffer));
49
+ if (!closing) parentPort.postMessage({ type: 'frames', first: message.first, frames, hashes, blurSamples, dofSamples, stats }, frames.map(f => f.buffer));
48
50
  } catch (error) {
49
51
  sendFailure(error); closing = true;
50
52
  } finally {
package/src/render.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { readBenchmark, validBenchmark, benchmarkMachine, selectWorkers, classifyShot } from './workers.mjs';
1
+ import { readBenchmark, benchmarkMachine, selectWorkers, classifyShot } from './workers.mjs';
2
2
  import { recoveryBudget } from './browser-recovery.mjs';
3
3
  import path from 'node:path';
4
4
  import { mkdir, rm, writeFile } from 'node:fs/promises';
@@ -59,10 +59,10 @@ export async function renderShot(shot, options = {}) {
59
59
  capture = await openShot(shot, renderScale, { ...options, onWarning: events.warning, check: events.check, onBrowser: observeBrowser(events, ffmpegInfo) });
60
60
  const record = ['option', 'config'].includes(events.workersSource) ? null : readBenchmark();
61
61
  let machine, type;
62
+ if (!['option', 'config'].includes(events.workersSource)) type = await classifyShot(capture);
62
63
  if (record) {
63
64
  try {
64
65
  machine = benchmarkMachine(await environment(capture.browser), options.raster);
65
- if (validBenchmark(record, machine)) type = await classifyShot(capture);
66
66
  } catch { /* Optional tuning must not prevent an otherwise valid render. */ }
67
67
  }
68
68
  const selection = selectWorkers({ workers: options.workers, source: events.workersSource, record, machine, type,
@@ -94,7 +94,8 @@ export async function renderShot(shot, options = {}) {
94
94
  events.resources.setWorkers(actualWorkers);
95
95
  const started = performance.now();
96
96
  const frameHashes = process.env.RENDER_FRAME_HASHES ? [] : null;
97
- pool = createRenderPool(shot, options, fps, actualWorkers, { width, height }, events.warning);
97
+ pool = createRenderPool(shot, options, fps, actualWorkers, { width, height }, events.warning,
98
+ done => events.progress('capture', done, total));
98
99
  events.onCancel(() => pool?.cancel());
99
100
  await mkdir(path.dirname(out), { recursive: true });
100
101
  events.check();
@@ -122,7 +123,6 @@ export async function renderShot(shot, options = {}) {
122
123
  for await (const { frame, png } of pool.frames(first, end, async ({ frame, png }) => {
123
124
  events.check();
124
125
  await preview(png, frame);
125
- events.progress('capture', frame - first + 1, total);
126
126
  })) {
127
127
  events.check();
128
128
  if (failure) throw new MultiplaneError('encode_failed', undefined, { cause: failure });
@@ -149,7 +149,7 @@ export async function renderShot(shot, options = {}) {
149
149
  if (frameHashes) await writeFile(process.env.RENDER_FRAME_HASHES, JSON.stringify(frameHashes, null, 2));
150
150
  if (process.env.RENDER_PROFILE) await writeFile(process.env.RENDER_PROFILE, JSON.stringify({elapsed,frames:total,workers:actualWorkers,options,stats:pool.stats,encoderWriteMs,encoderDrainMs,outputCheckMs},null,2));
151
151
  events.artifact(out, 'video');
152
- result = { staticReuse: pool.staticReuse, blurSamples: pool.blurSamples, output: out, frames: framesSent, fps, from, to, width: width * scale, height: height * scale, encoder: encoding.encoder, format: options.format, alpha: options.alpha, post: { ...processed.post, adaptiveBlur: Number(options['adaptive-blur'] ?? 0.5) }, renderScale, scale, outputCheck };
152
+ result = { staticReuse: pool.staticReuse, blurSamples: pool.blurSamples, dofSamples: pool.dofSamples, output: out, frames: framesSent, fps, from, to, width: width * scale, height: height * scale, encoder: encoding.encoder, format: options.format, alpha: options.alpha, post: { ...processed.post, adaptiveBlur: Number(options['adaptive-blur'] ?? 0.5) }, renderScale, scale, outputCheck };
153
153
  } finally { await cleanup(); }
154
154
 
155
155
  return options.draft ? { ...result, draft: true } : result;
@@ -1,29 +1,29 @@
1
- import path from 'node:path';
2
- import { realpath } from 'node:fs';
3
- import { promisify } from 'node:util';
4
-
5
- const nativeRealpath = promisify(realpath.native);
6
-
7
- // Resolve aliases (including Windows 8.3 names) even for missing descendants.
8
- export async function normalizeResourcePath(file) {
9
- const absolute = path.resolve(file);
10
- try { return await nativeRealpath(absolute); }
11
- catch (error) {
12
- if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
13
- const parent = path.dirname(absolute);
14
- if (parent === absolute) return absolute;
15
- return path.join(await normalizeResourcePath(parent), path.basename(absolute));
16
- }
17
- }
18
-
19
- export function relativeResourcePath(root, target, platform = process.platform) {
20
- const paths = platform === 'win32' ? path.win32 : path.posix;
21
- return paths.relative(platform === 'win32' ? root.toLowerCase() : root,
22
- platform === 'win32' ? target.toLowerCase() : target);
23
- }
24
-
25
- export async function isInsideResourceRoot(root, target) {
26
- const [base, file] = await Promise.all([normalizeResourcePath(root), normalizeResourcePath(target)]);
27
- const relative = relativeResourcePath(base, file);
28
- return relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative);
29
- }
1
+ import path from 'node:path';
2
+ import { realpath } from 'node:fs';
3
+ import { promisify } from 'node:util';
4
+
5
+ const nativeRealpath = promisify(realpath.native);
6
+
7
+ // Resolve aliases (including Windows 8.3 names) even for missing descendants.
8
+ export async function normalizeResourcePath(file) {
9
+ const absolute = path.resolve(file);
10
+ try { return await nativeRealpath(absolute); }
11
+ catch (error) {
12
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
13
+ const parent = path.dirname(absolute);
14
+ if (parent === absolute) return absolute;
15
+ return path.join(await normalizeResourcePath(parent), path.basename(absolute));
16
+ }
17
+ }
18
+
19
+ export function relativeResourcePath(root, target, platform = process.platform) {
20
+ const paths = platform === 'win32' ? path.win32 : path.posix;
21
+ return paths.relative(platform === 'win32' ? root.toLowerCase() : root,
22
+ platform === 'win32' ? target.toLowerCase() : target);
23
+ }
24
+
25
+ export async function isInsideResourceRoot(root, target) {
26
+ const [base, file] = await Promise.all([normalizeResourcePath(root), normalizeResourcePath(target)]);
27
+ const relative = relativeResourcePath(base, file);
28
+ return relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative);
29
+ }
package/src/resources.mjs CHANGED
@@ -1,27 +1,27 @@
1
- import { cpus } from 'node:os';
2
- import { createProcessTreeReader } from './platform.mjs';
3
-
4
- // Diagnostics only: never pass these values to frame generation.
5
- export function createResourceSampler({ sample, intervalMs = 1000, onSample = () => {}, enabled = true } = {}) {
6
- const reader = !sample && enabled ? createProcessTreeReader() : undefined;
7
- sample ??= reader?.sample;
8
- let pending, timer, stopped = false, peakMemoryBytes = null, memorySamples = 0, workers = 1;
9
- const take = () => {
10
- if (pending || stopped || !enabled) return pending ?? Promise.resolve();
11
- clearTimeout(timer);
12
- pending = (async () => {
13
- let bytes = null;
14
- try { const value = await sample(); if (Number.isSafeInteger(value) && value > 0) bytes = value; } catch {}
15
- if (bytes !== null) { peakMemoryBytes = Math.max(peakMemoryBytes ?? 0, bytes); memorySamples++; }
16
- try { onSample(bytes); } catch {}
17
- })().finally(() => { pending = undefined; if (!stopped) { timer = setTimeout(take, intervalMs); timer.unref(); } });
18
- return pending;
19
- };
20
- void take();
21
- return {
22
- sample: take,
23
- setWorkers(value) { workers = value; },
24
- snapshot: () => ({ peakMemoryBytes, memorySamples, workers, cpuCount: cpus().length || null }),
25
- async stop() { stopped = true; clearTimeout(timer); await pending; await reader?.close(); },
26
- };
27
- }
1
+ import { cpus } from 'node:os';
2
+ import { createProcessTreeReader } from './platform.mjs';
3
+
4
+ // Diagnostics only: never pass these values to frame generation.
5
+ export function createResourceSampler({ sample, intervalMs = 1000, onSample = () => {}, enabled = true } = {}) {
6
+ const reader = !sample && enabled ? createProcessTreeReader() : undefined;
7
+ sample ??= reader?.sample;
8
+ let pending, timer, stopped = false, peakMemoryBytes = null, memorySamples = 0, workers = 1;
9
+ const take = () => {
10
+ if (pending || stopped || !enabled) return pending ?? Promise.resolve();
11
+ clearTimeout(timer);
12
+ pending = (async () => {
13
+ let bytes = null;
14
+ try { const value = await sample(); if (Number.isSafeInteger(value) && value > 0) bytes = value; } catch {}
15
+ if (bytes !== null) { peakMemoryBytes = Math.max(peakMemoryBytes ?? 0, bytes); memorySamples++; }
16
+ try { onSample(bytes); } catch {}
17
+ })().finally(() => { pending = undefined; if (!stopped) { timer = setTimeout(take, intervalMs); timer.unref(); } });
18
+ return pending;
19
+ };
20
+ void take();
21
+ return {
22
+ sample: take,
23
+ setWorkers(value) { workers = value; },
24
+ snapshot: () => ({ peakMemoryBytes, memorySamples, workers, cpuCount: cpus().length || null }),
25
+ async stop() { stopped = true; clearTimeout(timer); await pending; await reader?.close(); },
26
+ };
27
+ }
@@ -1,37 +1,37 @@
1
- (function () {
2
- const E = Shot.ease;
3
-
4
- function interp(a, b, k) {
5
- const s = a.s * Math.pow(b.s / a.s, k);
6
- if (b.s >= a.s) {
7
- const px = (b.f[0] - a.f[0]) * a.s * (1 - k);
8
- const py = (b.f[1] - a.f[1]) * a.s * (1 - k);
9
- return { f: [b.f[0] - px / s, b.f[1] - py / s], s };
10
- }
11
- const px = (a.f[0] - b.f[0]) * b.s * k;
12
- const py = (a.f[1] - b.f[1]) * b.s * k;
13
- return { f: [a.f[0] - px / s, a.f[1] - py / s], s };
14
- }
15
-
16
- function track(keys) {
17
- function at(t) {
18
- if (t <= keys[0].t) return { f: keys[0].f, s: keys[0].s };
19
- for (let i = 1; i < keys.length; i++) {
20
- const a = keys[i - 1], b = keys[i];
21
- if (b.t === a.t) continue;
22
- if (t < b.t) return interp(a, b, (b.ease || E.inOutCubic)((t - a.t) / (b.t - a.t)));
23
- }
24
- const z = keys[keys.length - 1];
25
- return { f: z.f, s: z.s };
26
- }
27
- return {
28
- at,
29
- apply(node, t, w = Shot.size.w, h = Shot.size.h) {
30
- const { f, s } = at(t);
31
- node.setAttribute('transform', `translate(${w / 2} ${h / 2}) scale(${s}) translate(${-f[0]} ${-f[1]})`);
32
- },
33
- };
34
- }
35
-
36
- window.Camera = { track, linear: k => k };
37
- })();
1
+ (function () {
2
+ const E = Shot.ease;
3
+
4
+ function interp(a, b, k) {
5
+ const s = a.s * Math.pow(b.s / a.s, k);
6
+ if (b.s >= a.s) {
7
+ const px = (b.f[0] - a.f[0]) * a.s * (1 - k);
8
+ const py = (b.f[1] - a.f[1]) * a.s * (1 - k);
9
+ return { f: [b.f[0] - px / s, b.f[1] - py / s], s };
10
+ }
11
+ const px = (a.f[0] - b.f[0]) * b.s * k;
12
+ const py = (a.f[1] - b.f[1]) * b.s * k;
13
+ return { f: [a.f[0] - px / s, a.f[1] - py / s], s };
14
+ }
15
+
16
+ function track(keys) {
17
+ function at(t) {
18
+ if (t <= keys[0].t) return { f: keys[0].f, s: keys[0].s };
19
+ for (let i = 1; i < keys.length; i++) {
20
+ const a = keys[i - 1], b = keys[i];
21
+ if (b.t === a.t) continue;
22
+ if (t < b.t) return interp(a, b, (b.ease || E.inOutCubic)((t - a.t) / (b.t - a.t)));
23
+ }
24
+ const z = keys[keys.length - 1];
25
+ return { f: z.f, s: z.s };
26
+ }
27
+ return {
28
+ at,
29
+ apply(node, t, w = Shot.size.w, h = Shot.size.h) {
30
+ const { f, s } = at(t);
31
+ node.setAttribute('transform', `translate(${w / 2} ${h / 2}) scale(${s}) translate(${-f[0]} ${-f[1]})`);
32
+ },
33
+ };
34
+ }
35
+
36
+ window.Camera = { track, linear: k => k };
37
+ })();
@@ -1,9 +1,9 @@
1
- html, body { margin: 0; height: 100%; overflow: hidden; background: var(--multiplane-page-background, #000); }
2
- #stage {
3
- position: absolute; left: 0; top: 0;
4
- width: 1920px; height: 1080px;
5
- transform-origin: 0 0;
6
- overflow: hidden;
7
- background: var(--paper, #fff);
8
- }
9
- #stage > svg { display: block; }
1
+ html, body { margin: 0; height: 100%; overflow: hidden; background: var(--multiplane-page-background, #000); }
2
+ #stage {
3
+ position: absolute; left: 0; top: 0;
4
+ width: 1920px; height: 1080px;
5
+ transform-origin: 0 0;
6
+ overflow: hidden;
7
+ background: var(--paper, #fff);
8
+ }
9
+ #stage > svg { display: block; }
package/src/server.mjs CHANGED
@@ -61,7 +61,13 @@ export async function startServer(port = 0, root = process.cwd()) {
61
61
  }
62
62
  let target = path.resolve(resourceRoot, `.${resourcePath}`);
63
63
  if (!await isInsideResourceRoot(resourceRoot, target)) { res.writeHead(403); return res.end(); }
64
- if ((await stat(target)).isDirectory()) target = path.join(target, 'index.html');
64
+ let targetInfo;
65
+ try { targetInfo = await stat(target); }
66
+ catch (error) {
67
+ if (pathname === '/favicon.ico' && error.code === 'ENOENT') { res.writeHead(204); return res.end(); }
68
+ throw error;
69
+ }
70
+ if (targetInfo.isDirectory()) target = path.join(target, 'index.html');
65
71
  target = await normalizeResourcePath(target);
66
72
  if (!await isInsideResourceRoot(resourceBase, target)) { res.writeHead(403); return res.end(); }
67
73
  const info = await stat(target);