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.
- package/LICENSE +202 -202
- package/README.en.md +327 -326
- package/README.md +327 -326
- package/THIRD_PARTY_NOTICES.md +30 -30
- package/examples/alpha.html +18 -18
- package/examples/animation.html +17 -17
- package/examples/assets/OFL.txt +93 -93
- package/examples/assets/card.svg +6 -6
- package/examples/three-cards.html +17 -17
- package/examples/typography.html +17 -17
- package/licenses/GPL-3.0.txt +232 -232
- package/licenses/LGPL-3.0.txt +304 -304
- package/licenses/acorn-LICENSE +21 -21
- package/licenses/colour-LICENSE.md +82 -82
- package/licenses/detect-libc-LICENSE +201 -201
- package/licenses/entities-LICENSE +11 -11
- package/licenses/nanoid-LICENSE +20 -20
- package/licenses/parse5-LICENSE +19 -19
- package/licenses/picocolors-LICENSE +15 -15
- package/licenses/playwright-NOTICE +5 -5
- package/licenses/playwright-ThirdPartyNotices.txt +14 -14
- package/licenses/playwright-core-ThirdPartyNotices.txt +13 -13
- package/licenses/postcss-LICENSE +20 -20
- package/licenses/postcss-value-parser-LICENSE +22 -22
- package/licenses/semver-LICENSE +15 -15
- package/licenses/sharp-LICENSE +191 -191
- package/licenses/sharp-win32-x64-NOTICES.md +45 -45
- package/licenses/source-map-js-LICENSE +28 -28
- package/licenses/three-LICENSE +21 -21
- package/package.json +1 -1
- package/skills/multiplane/SKILL.md +215 -213
- package/src/alpha.mjs +69 -69
- package/src/artifacts.mjs +61 -61
- package/src/browser-recovery.mjs +26 -26
- package/src/capture.mjs +16 -6
- package/src/encoder.mjs +16 -16
- package/src/environment.mjs +56 -56
- package/src/events.mjs +3 -0
- package/src/ffmpeg.mjs +32 -32
- package/src/format.mjs +27 -27
- package/src/frame-rate.mjs +28 -28
- package/src/home-migration.mjs +54 -54
- package/src/hosted.mjs +1 -1
- package/src/lint.mjs +248 -248
- package/src/output-alpha.mjs +36 -36
- package/src/output-check.mjs +110 -110
- package/src/platform.mjs +114 -114
- package/src/png-native.mjs +12 -12
- package/src/png.mjs +74 -74
- package/src/post.mjs +55 -21
- package/src/preview.mjs +23 -23
- package/src/process.mjs +9 -9
- package/src/render-pool.mjs +16 -6
- package/src/render-worker.mjs +5 -3
- package/src/render.mjs +5 -5
- package/src/resource-path.mjs +29 -29
- package/src/resources.mjs +27 -27
- package/src/runtime/camera.js +37 -37
- package/src/runtime/shot.css +9 -9
- package/src/server.mjs +7 -1
- package/src/setup.mjs +51 -51
- package/src/skills.mjs +128 -128
- package/src/stills.mjs +5 -4
- package/src/three/index.js +44 -14
- package/src/verify.mjs +3 -1
- package/src/video-pipe.mjs +6 -6
- package/src/workers.mjs +5 -1
- package/tools/bench/shots/canvas-2d.html +1 -1
- package/tools/bench/shots/dom-light.html +1 -1
- package/tools/bench/shots/scene.js +44 -44
- package/tools/bench/shots/webgl.html +1 -1
- package/tools/install-browser.mjs +18 -18
package/src/post.mjs
CHANGED
|
@@ -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,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,
|
|
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,
|
|
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++)
|
|
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
|
+
}
|
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;
|
|
@@ -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,
|
|
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
|
|
@@ -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,
|
|
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/resource-path.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/runtime/camera.js
CHANGED
|
@@ -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
|
+
})();
|
package/src/runtime/shot.css
CHANGED
|
@@ -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
|
-
|
|
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);
|