multiplane 0.3.3 → 0.3.6

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/src/hosted.mjs CHANGED
@@ -28,7 +28,7 @@ export async function hostedAvailable(command, options, env = process.env) {
28
28
  }
29
29
 
30
30
  export function hostedEnvironment(env) {
31
- const allowed = /^(PATH|MULTIPLANE_.*|HTTPS?_PROXY|ALL_PROXY|NO_PROXY|TEMP|TMP|TMPDIR|HOME|USERPROFILE|APPDATA|LOCALAPPDATA|SystemRoot|ComSpec|PATHEXT|LANG|LC_.*)$/i;
31
+ const allowed = /^(PATH|MULTIPLANE_.*|ELECTRON_RUN_AS_NODE|HTTPS?_PROXY|ALL_PROXY|NO_PROXY|TEMP|TMP|TMPDIR|HOME|USERPROFILE|APPDATA|LOCALAPPDATA|SystemRoot|ComSpec|PATHEXT|LANG|LC_.*)$/i;
32
32
  const secret = /TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|BEARER|COOKIE|SESSION|API_?KEY|PRIVATE_?KEY|ACCESS_?KEY/i;
33
33
  return Object.fromEntries(Object.entries(env).filter(([key, value]) => {
34
34
  if (value === undefined || !allowed.test(key) || secret.test(key)) return false;
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,24 @@ 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 => ({ motionBlur, motionBlurBase, observed,
284
+ motionProbeTime: finalCapture ? undefined : t,
285
+ dofMotionSamples: finalCapture ? dofMotionSamples : undefined });
276
286
  const sample = async (time, reuse = false) => {
277
287
  if (!cache.has(time)) {
278
288
  const start = performance.now();
279
- const observed = time === observedTime;
289
+ const observed = time === observedTime && !(motionBlur > 1 && capture.motionProbeUsed);
280
290
  observedTime = undefined;
281
- const png = await capture.frame(time, { motionBlur, observed });
291
+ const png = await capture.frame(time, context(observed));
282
292
  contextUsed ||= capture.sampleContextUsed === true;
293
+ motionProbeUsed ||= capture.motionProbeUsed === true;
283
294
  stats.captureMs += performance.now() - start;
284
295
  stats.samples++;
285
- cache.set(time, { png, image: post.motionBlur > 1 || upward || post.grain > 0 ? await decode(png, reuse) : null });
296
+ 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
297
  }
287
298
  return cache.get(time);
288
299
  };
@@ -294,20 +305,22 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
294
305
  return;
295
306
  }
296
307
  const begin = async time => {
297
- const start = performance.now(), observed = time === observedTime;
308
+ const start = performance.now(), observed = time === observedTime && !(motionBlur > 1 && capture.motionProbeUsed);
298
309
  observedTime = undefined;
299
- const pending = await capture.beginFrame(time, { motionBlur, observed });
310
+ const pending = await capture.beginFrame(time, context(observed));
300
311
  contextUsed ||= capture.sampleContextUsed === true;
312
+ motionProbeUsed ||= capture.motionProbeUsed === true;
301
313
  const png = pending.png.then(buffer => { stats.captureMs += performance.now() - start; return buffer; });
302
314
  png.catch(() => {});
303
- return { png };
315
+ return { png, dofSamples: capture.dofSamples ?? 0, probe: capture.motionProbeUsed === true && !finalCapture };
304
316
  };
305
317
  let pending = await begin(missing[0]);
306
- for (let i = 0; i < missing.length; i++) {
307
- const png = await pending.png;
318
+ for (let i = 0; i < missing.length; i++) {
319
+ const png = await pending.png;
320
+ const { dofSamples, probe } = pending;
308
321
  const next = i + 1 < missing.length ? await begin(missing[i + 1]) : null;
309
322
  stats.samples++;
310
- cache.set(missing[i], { png, image: await decode(png, reuse) });
323
+ cache.set(missing[i], { png, dofSamples, probe, image: await decode(png, reuse) });
311
324
  pending = next;
312
325
  }
313
326
  };
@@ -315,7 +328,7 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
315
328
  let png, result, usedSamples = motionBlur;
316
329
  if (candidate && !verifyReuse) { ({ png, image: result, usedSamples } = previousRaw); }
317
330
  else if (upward) {
318
- // Calibrated in capture raster pixels: about 2px for standard, 1px for high.
331
+ // Calibrated in capture raster pixels: about 2px for fast/standard, 1px for high.
319
332
  const threshold = resolveQuality(options) === 'high' ? 1 : 2;
320
333
  await sampleMany([...endpoints, t], true);
321
334
  const centerProbe = cache.get(t);
@@ -323,7 +336,7 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
323
336
  let legacy = contextUsed;
324
337
  let grid = legacy ? times : grids.get(motionBlur);
325
338
  if (still) {
326
- png = centerProbe.png; result = centerProbe.image; usedSamples = 1;
339
+ png = centerProbe.png; result = centerProbe.image; usedSamples = 1; selectedTimes = [t];
327
340
  stats.staticFrames++; stats.adaptiveFrames++;
328
341
  } else {
329
342
  if (legacy) cache.clear();
@@ -338,22 +351,25 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
338
351
  const images = grid.map(time => cache.get(time).image), center = cache.get(t);
339
352
  const identical = images.every(image => image.data.equals(center.image.data));
340
353
  if (identical) {
341
- png = center.png; result = center.image; usedSamples = 1;
354
+ png = center.png; result = center.image; usedSamples = 1; selectedTimes = [t];
342
355
  stats.staticFrames++; stats.adaptiveFrames++; break;
343
356
  }
344
357
  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));
346
- if (difference <= threshold || motionBlur >= post.maxMotionBlur) {
347
- usedSamples = motionBlur;
358
+ 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));
361
+ }
362
+ if (difference <= threshold || motionBlur >= post.maxMotionBlur) {
363
+ usedSamples = motionBlur; selectedTimes = grid;
348
364
  result = timed('averageMs', () => average(images)); break;
349
365
  }
350
366
  motionBlur = Math.min(post.maxMotionBlur, motionBlur * 2);
351
- if (legacy) cache.clear();
367
+ if (legacy) cache.clear();
352
368
  observedTime = undefined;
353
369
  grid = legacy ? sampleTimes(t, fps, capture.duration, capture.cuts, { ...post, motionBlur }) : grids.get(motionBlur);
354
370
  }
355
371
  }
356
- } else if (motionBlur === 1) ({ png, image: result } = await sample(t));
372
+ } else if (motionBlur === 1) { selectedTimes = [t]; ({ png, image: result } = await sample(t)); }
357
373
  else if (hooked) {
358
374
  await sampleMany(times);
359
375
  result = timed('averageMs', () => average(times.map(time => cache.get(time).image)));
@@ -362,7 +378,7 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
362
378
  const first = cache.get(times[0]), last = cache.get(times.at(-1));
363
379
  const identical = first.image.data === last.image.data || first.image.data.equals(last.image.data);
364
380
  if (identical || (!alpha && imageDifference(first.image, last.image) < adaptive)) {
365
- png = first.png; result = first.image; usedSamples = 1; stats.adaptiveFrames++;
381
+ png = first.png; result = first.image; usedSamples = 1; selectedTimes = [times[0]]; stats.adaptiveFrames++;
366
382
  if (identical) stats.staticFrames++;
367
383
  } else {
368
384
  await sampleMany(times);
@@ -372,13 +388,29 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
372
388
  await sampleMany([times[0], times.at(-1), t]);
373
389
  const first = cache.get(times[0]), last = cache.get(times.at(-1)), center = cache.get(t);
374
390
  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++;
391
+ png = center.png; result = center.image; usedSamples = 1; selectedTimes = [t]; stats.staticFrames++;
376
392
  } else {
377
393
  await sampleMany(times);
378
394
  const images = times.map(time => cache.get(time).image);
379
395
  result = timed('averageMs', () => average(images));
380
396
  }
381
397
  }
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
+ }
382
414
  if (verifyReuse) {
383
415
  stats.auditMs += performance.now() - auditStart;
384
416
  Atomics.add(state, 2, 1);
@@ -394,9 +426,10 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
394
426
  if (candidate && !verifyReuse) Atomics.add(state, 1, 1);
395
427
  // Keep a decoded raw reference as well; never retain a transferable output buffer.
396
428
  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 };
429
+ previousRaw = { t, frame, motionBlur, uniform, png, image: result, usedSamples, usedDofSamples, run: candidate ? previousRaw.run + 1 : 0 };
398
430
  // Contributing temporal samples, excluding probes and failed attempts.
399
- api.lastBlurSamples = usedSamples;
431
+ api.lastBlurSamples = usedSamples;
432
+ api.lastDofSamples = usedDofSamples;
400
433
  stats.frames++;
401
434
  if (sizes.renderScale !== sizes.scale) {
402
435
  result ??= await decode(png);
@@ -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;
@@ -43,7 +51,8 @@ export function createRenderPool(shot, options, fps, workers, size, onWarning) {
43
51
  }
44
52
  const api = {
45
53
  stats,
46
- get blurSamples() { return blurSampleReport(histogram); },
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
@@ -40,11 +41,12 @@ try {
40
41
  const png = result.byteOffset === 0 && result.byteLength === result.buffer.byteLength && !isMarkedAsUntransferable(result.buffer)
41
42
  ? new Uint8Array(result.buffer) : Uint8Array.from(result);
42
43
  frames.push(png);
43
- blurSamples.push(item.blurSamples);
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;
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);
package/src/stills.mjs CHANGED
@@ -44,17 +44,18 @@ export async function stillsShot(shot, options = {}) {
44
44
  await mkdir(out, { recursive: true });
45
45
  for (const file of [...times.map(t => `t-${t.toFixed(2)}.png`), '_sheet.png']) await requireNewOutput(path.join(out, file));
46
46
  const frames = [];
47
- const histogram = {};
47
+ const histogram = {}, dofHistogram = {};
48
48
  const source = async function* (first, end) {
49
49
  for (let index = first; index < end; index++) {
50
50
  const t = times[index];
51
51
  events.check();
52
52
  const png = await processed.frame(t, Math.round(t * fps), index === end - 1);
53
53
  events.progress('capture', index + 1, times.length);
54
- yield { t, png, blurSamples: processed.lastBlurSamples };
54
+ yield { t, png, blurSamples: processed.lastBlurSamples, dofSamples: processed.lastDofSamples };
55
55
  }
56
56
  };
57
- for await (const { t, png: buffer, blurSamples } of verifiedFrames(source, { ...options, check: events.check }, times.length)) {
57
+ for await (const { t, png: buffer, blurSamples, dofSamples } of verifiedFrames(source, { ...options, check: events.check }, times.length)) {
58
+ dofHistogram[dofSamples] = (dofHistogram[dofSamples] ?? 0) + 1;
58
59
  histogram[blurSamples] = (histogram[blurSamples] ?? 0) + 1;
59
60
  events.check();
60
61
  await writeArtifact(path.join(out, `t-${t.toFixed(2)}.png`), buffer);
@@ -73,7 +74,7 @@ export async function stillsShot(shot, options = {}) {
73
74
  await writeArtifact(path.join(out, '_sheet.png'), sheetPNG);
74
75
  await checkStill(path.join(out, '_sheet.png'), { width: 1920, height: Math.ceil(Math.ceil(frames.length / 4) * rowHeight) });
75
76
  events.artifact(path.join(out, '_sheet.png'), 'contact-sheet');
76
- result = { staticReuse: processed.staticReuse, post: { ...processed.post, adaptiveBlur: Number(options['adaptive-blur'] ?? 0.5) }, blurSamples: blurSampleReport(histogram), format: 'png', alpha: capture.alpha, stills: times.map(t => ({ t, frame: Math.round(t * fps), path: path.join(out, `t-${t.toFixed(2)}.png`) })), contactSheet: path.join(out, '_sheet.png') };
77
+ result = { staticReuse: processed.staticReuse, post: { ...processed.post, adaptiveBlur: Number(options['adaptive-blur'] ?? 0.5) }, blurSamples: blurSampleReport(histogram), dofSamples: blurSampleReport(dofHistogram), format: 'png', alpha: capture.alpha, stills: times.map(t => ({ t, frame: Math.round(t * fps), path: path.join(out, `t-${t.toFixed(2)}.png`) })), contactSheet: path.join(out, '_sheet.png') };
77
78
  } finally { await cleanup(); }
78
79
 
79
80
  return options.draft ? { ...result, draft: true } : result;
@@ -79,7 +79,6 @@ export function createThreeLayer({ stage = document.querySelector('#stage'), siz
79
79
  previewAperture = 0.008, maxBlur = 0.016, toneMapping = false, zIndex = 1, style = {} } = {}) {
80
80
  // TODO(design): Document the shared layer/card style fields and single-family font values.
81
81
  const layerStyle = { ...defaultStyle, ...style };
82
- const layerBackground = style.textureBackground !== undefined;
83
82
  const renderer = new THREE.WebGLRenderer({ alpha: true, premultipliedAlpha: false, antialias: true, preserveDrawingBuffer: true });
84
83
  renderer.setPixelRatio(pixelRatio);
85
84
  renderer.setSize(size.w, size.h);
@@ -177,7 +176,7 @@ export function createThreeLayer({ stage = document.querySelector('#stage'), siz
177
176
  }
178
177
  return patterns.get(count);
179
178
  }
180
- function renderAccumulation(focus, radius, count, blur, rotation) {
179
+ function renderAccumulation(focus, radius, count, blur, rotation, budget = count) {
181
180
  const position = camera.position.clone(), projection = camera.projectionMatrix.clone();
182
181
  const projectionInverse = camera.projectionMatrixInverse.clone();
183
182
  const autoClear = renderer.autoClear, oldTarget = renderer.getRenderTarget();
@@ -195,7 +194,7 @@ export function createThreeLayer({ stage = document.querySelector('#stage'), siz
195
194
  sumMaterial.uniforms.focus.value = focus;
196
195
  sumMaterial.uniforms.cameraNear.value = camera.near;
197
196
  sumMaterial.uniforms.cameraFar.value = camera.far;
198
- sumMaterial.uniforms.blurScale.value = radius * height * projection.elements[5] * 0.5 * blur / Math.sqrt(count);
197
+ sumMaterial.uniforms.blurScale.value = radius * height * projection.elements[5] * 0.5 * blur / Math.sqrt(budget);
199
198
  for (const [x, y, jx, jy] of pattern(count)) {
200
199
  const dx = (x * Math.cos(rotation) - y * Math.sin(rotation)) * radius;
201
200
  const dy = (x * Math.sin(rotation) + y * Math.cos(rotation)) * radius;
@@ -226,19 +225,34 @@ export function createThreeLayer({ stage = document.querySelector('#stage'), siz
226
225
  textures.add(texture);
227
226
  return texture;
228
227
  }
229
- function card({ src, width, height, radius = 0, position = [0, 0, 0], rotation = [0, 0, 0],
230
- border = null, shadow = false, annotations = [], browserFrame = false, textureWidth = 2048, style = {} }) {
231
- const textureStyle = { ...layerStyle, ...style };
232
- const composeBackground = layerBackground || style.textureBackground !== undefined;
228
+ /**
229
+ * @param {object} options
230
+ * @param {number} [options.lodBias=0] Texture mip LOD bias; negative values sharpen
231
+ * front-facing cards but can cause moire on dense textures at oblique angles.
232
+ */
233
+ function card({ src, width, height, radius = 0, position = [0, 0, 0], rotation = [0, 0, 0],
234
+ border = null, shadow = false, annotations = [], browserFrame = false, textureWidth = 2048, style = {}, lodBias = 0 }) {
235
+ if (!Number.isFinite(lodBias)) throw Object.assign(new Error('lodBias must be a finite number.'), { code: 'shot_contract_invalid' });
236
+ const textureStyle = { ...layerStyle, ...style };
237
+ const composeBackground = annotations.length > 0 || browserFrame || style.textureBackground !== undefined;
233
238
  const group = new THREE.Group();
234
239
  group.position.fromArray(position); group.rotation.set(...rotation);
235
- const material = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide });
240
+ const material = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide, transparent: !composeBackground });
241
+ if (lodBias !== 0) {
242
+ material.onBeforeCompile = shader => {
243
+ shader.uniforms.cardLodBias = { value: lodBias };
244
+ shader.fragmentShader = 'uniform float cardLodBias;\n' + shader.fragmentShader.replace('#include <map_fragment>',
245
+ THREE.ShaderChunk.map_fragment.replace('texture2D( map, vMapUv )', 'texture2D( map, vMapUv, cardLodBias )'));
246
+ };
247
+ material.customProgramCacheKey = () => 'card-lod-bias';
248
+ }
236
249
  const mesh = new THREE.Mesh(roundedGeometry(width, height, radius), material);
237
250
  group.add(mesh);
238
251
  if (border) {
239
252
  const edge = new THREE.Mesh(roundedGeometry(width + 2 * border.width, height + 2 * border.width, radius + border.width),
240
- new THREE.MeshBasicMaterial({ color: border.color, side: THREE.DoubleSide }));
241
- edge.position.z = -0.002; group.add(edge);
253
+ new THREE.MeshBasicMaterial({ color: border.color, side: THREE.DoubleSide,
254
+ polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 }));
255
+ group.add(edge);
242
256
  }
243
257
  if (shadow) {
244
258
  const c = document.createElement('canvas'); c.width = c.height = 512;
@@ -288,7 +302,8 @@ export function createThreeLayer({ stage = document.querySelector('#stage'), siz
288
302
  adaptiveSamples: adaptive = sampling.adaptiveSamples, maxSamples: maximum = sampling.maxSamples,
289
303
  sampleSpacing: spacing = sampling.sampleSpacing, sampleBlur: blur = sampling.sampleBlur,
290
304
  rotateSamples: rotate = sampling.rotateSamples,
291
- t = window.SHOT?.sampleContext?.time ?? 0, motionBlur = window.SHOT?.sampleContext?.motionBlur ?? 1,
305
+ t = window.SHOT?.sampleContext?.time ?? 0,
306
+ motionBlur = window.SHOT?.sampleContext?.motionBlurBase ?? window.SHOT?.sampleContext?.motionBlur ?? 1,
292
307
  previewAperture: previewRadius = previewAperture, maxBlur: previewMax = maxBlur } = {}) {
293
308
  if (!Number.isFinite(focus) || focus <= 0 || !Number.isFinite(radius) || radius < 0
294
309
  || !Number.isInteger(count) || count < 1 || !['preview', 'final'].includes(mode)
@@ -319,11 +334,18 @@ export function createThreeLayer({ stage = document.querySelector('#stage'), siz
319
334
  maxRadius = Math.max(maxRadius, radius * height * camera.projectionMatrix.elements[5] * 0.5 * Math.abs(1 / z - 1 / focus));
320
335
  }
321
336
  });
322
- const actual = adaptive ? Math.min(maximum, Math.max(count, Math.ceil((2 * maxRadius / spacing) ** 2))) : count;
323
- const phase = t * 997 * Math.sqrt(2);
337
+ const budget = adaptive ? Math.min(maximum, Math.max(count, Math.ceil((2 * maxRadius / spacing) ** 2))) : count;
338
+ const probe = globalThis.__multiplaneMotionProbe;
339
+ // Probes retain the stable base pattern; final captures share one frame budget.
340
+ const divisor = probe?.motionSamples ?? motionBlur;
341
+ const minimum = probe?.time === undefined ? 2 : 8;
342
+ const actual = rotate && motionBlur > 1 ? Math.min(budget, Math.max(minimum, Math.ceil(budget / divisor))) : budget;
343
+ if (probe) probe.samples += actual;
344
+ if (probe && rotate && motionBlur > 1) probe.used = true;
345
+ const phase = (probe?.time ?? t) * 997 * Math.sqrt(2);
324
346
  const rotation = rotate && motionBlur > 1 ? (phase - Math.floor(phase)) * Math.PI * 2 : 0;
325
347
  Object.assign(stats, { samples: actual, maxBlurRadius: maxRadius, rotation });
326
- renderAccumulation(focus, radius, actual, blur, rotation);
348
+ renderAccumulation(focus, radius, actual, blur, rotation, budget);
327
349
  }
328
350
  },
329
351
  gpuInfo() {
package/src/verify.mjs CHANGED
@@ -43,15 +43,17 @@ export async function verifyShot(shot, options = {}) {
43
43
  const references = new Map(), hashes = [], rangeReferences = new Map();
44
44
  if (compareReuse) referenceDirectory = await mkdtemp(path.join(tmpdir(), 'multiplane-verify-static-'));
45
45
  await mkdir(out, { recursive: true });
46
- const histogram = {};
46
+ const histogram = {}, dofHistogram = {};
47
47
  const jitterChecks = new Set();
48
48
  let errors = 0, totalChangedPixels = 0;
49
49
  for (const t of times) {
50
50
  events.check(); references.set(t, decodePNG(await processed.frame(t)));
51
+ dofHistogram[processed.lastDofSamples] = (dofHistogram[processed.lastDofSamples] ?? 0) + 1;
51
52
  histogram[processed.lastBlurSamples] = (histogram[processed.lastBlurSamples] ?? 0) + 1;
52
53
  }
53
54
  data.post = { ...processed.post, adaptiveBlur: Number(options['adaptive-blur'] ?? 0.5) };
54
- data.blurSamples = blurSampleReport(histogram);
55
+ data.blurSamples = blurSampleReport(histogram);
56
+ data.dofSamples = blurSampleReport(dofHistogram);
55
57
  const check = async (t, actual, name, reference = references.get(t)) => {
56
58
  events.check();
57
59
  if (reference.file) reference = { ...reference, data: await readFile(reference.file) };
package/src/workers.mjs CHANGED
@@ -41,7 +41,11 @@ export function selectWorkers({ workers, source, record, machine, type, scale =
41
41
  if (validBenchmark(record, machine) && shotTypes.includes(type)) {
42
42
  return { workers: Math.max(1, Math.min(record.shots[type].workers, memoryWorkers(memoryScale, hardware))), source: 'benchmark' };
43
43
  }
44
- return { workers: defaultWorkers(scale, hardware), source: 'estimate' };
44
+ const estimate = defaultWorkers(scale, hardware);
45
+ // Unbenchmarked WebGL contexts share one GPU and retain large render targets.
46
+ const free = Math.min(hardware.free ?? freemem(), hardware.total ?? totalmem());
47
+ const threeLimit = Math.max(1, Math.floor((free - 2 * 1024 ** 3) / ((0.5 + 0.5 * memoryScale ** 2) * 1024 ** 3)));
48
+ return { workers: type === 'three' ? Math.min(4, estimate, threeLimit) : estimate, source: 'estimate' };
45
49
  }
46
50
 
47
51
  // Inspect the loaded probe only; its context is discarded before real rendering.