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/README.en.md +327 -326
- package/README.md +327 -326
- package/package.json +92 -92
- package/skills/multiplane/SKILL.md +215 -213
- package/src/capture.mjs +16 -6
- package/src/events.mjs +5 -2
- package/src/hosted.mjs +1 -1
- package/src/post.mjs +59 -26
- package/src/render-pool.mjs +17 -7
- package/src/render-worker.mjs +6 -4
- package/src/render.mjs +5 -5
- package/src/server.mjs +7 -1
- package/src/stills.mjs +5 -4
- package/src/three/index.js +36 -14
- package/src/verify.mjs +4 -2
- package/src/workers.mjs +5 -1
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
|
-
|
|
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 :
|
|
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,
|
|
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,
|
|
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++)
|
|
346
|
-
|
|
347
|
-
|
|
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);
|
package/src/render-pool.mjs
CHANGED
|
@@ -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,
|
|
90
|
-
for await (const item of frames(first, end)) { await
|
|
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
|
}
|
package/src/render-worker.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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;
|
package/src/three/index.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
323
|
-
const
|
|
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
|
-
|
|
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.
|