multiplane 0.0.0-stage → 0.3.0
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 -0
- package/README.en.md +322 -0
- package/README.md +321 -2
- package/THIRD_PARTY_NOTICES.md +30 -0
- package/bin/multiplane +98 -0
- package/examples/alpha.html +18 -0
- package/examples/animation.html +17 -0
- package/examples/assets/OFL.txt +93 -0
- package/examples/assets/Roboto-Variable.ttf +0 -0
- package/examples/assets/card.svg +6 -0
- package/examples/assets/readme-card-0.svg +1 -0
- package/examples/assets/readme-card-1.svg +1 -0
- package/examples/assets/readme-card-2.svg +1 -0
- package/examples/assets/readme-detail.svg +1 -0
- package/examples/readme-cards.html +23 -0
- package/examples/readme-cut.html +13 -0
- package/examples/readme-depth.html +15 -0
- package/examples/readme-wheel.html +14 -0
- package/examples/three-cards.html +17 -0
- package/examples/typography.html +17 -0
- package/licenses/GPL-3.0.txt +232 -0
- package/licenses/LGPL-3.0.txt +304 -0
- package/licenses/acorn-LICENSE +21 -0
- package/licenses/colour-LICENSE.md +82 -0
- package/licenses/detect-libc-LICENSE +201 -0
- package/licenses/entities-LICENSE +11 -0
- package/licenses/nanoid-LICENSE +20 -0
- package/licenses/native/README.ijg +258 -0
- package/licenses/native/aom-LICENSE +27 -0
- package/licenses/native/aom-PATENTS +108 -0
- package/licenses/native/archive-COPYING +65 -0
- package/licenses/native/cairo-COPYING +33 -0
- package/licenses/native/cairo-COPYING-MPL-1.1 +470 -0
- package/licenses/native/cgif-LICENSE +21 -0
- package/licenses/native/exif-COPYING +504 -0
- package/licenses/native/expat-COPYING +21 -0
- package/licenses/native/ffi-LICENSE +21 -0
- package/licenses/native/fontconfig-COPYING +200 -0
- package/licenses/native/freetype-FTL.TXT +169 -0
- package/licenses/native/fribidi-COPYING +501 -0
- package/licenses/native/glib-COPYING +175 -0
- package/licenses/native/harfbuzz-COPYING +42 -0
- package/licenses/native/heif-COPYING +871 -0
- package/licenses/native/highway-LICENSE +371 -0
- package/licenses/native/imagequant-COPYRIGHT +36 -0
- package/licenses/native/lcms-COPYING +22 -0
- package/licenses/native/mozjpeg-LICENSE.md +135 -0
- package/licenses/native/nsgif-COPYING +21 -0
- package/licenses/native/pango-COPYING +482 -0
- package/licenses/native/pixman-COPYING +42 -0
- package/licenses/native/png-LICENSE +134 -0
- package/licenses/native/proxy-libintl-COPYING +482 -0
- package/licenses/native/rsvg-COPYING.LIB +502 -0
- package/licenses/native/sources.json +240 -0
- package/licenses/native/tiff-LICENSE.md +49 -0
- package/licenses/native/uhdr-LICENSE +230 -0
- package/licenses/native/vips-COPYING +502 -0
- package/licenses/native/webp-COPYING +30 -0
- package/licenses/native/webp-PATENTS +23 -0
- package/licenses/native/xml2-Copyright +24 -0
- package/licenses/native/zlib-ng-LICENSE.md +19 -0
- package/licenses/parse5-LICENSE +19 -0
- package/licenses/picocolors-LICENSE +15 -0
- package/licenses/playwright-LICENSE +202 -0
- package/licenses/playwright-NOTICE +5 -0
- package/licenses/playwright-ThirdPartyNotices.txt +14 -0
- package/licenses/playwright-core-ThirdPartyNotices.txt +13 -0
- package/licenses/postcss-LICENSE +20 -0
- package/licenses/postcss-value-parser-LICENSE +22 -0
- package/licenses/semver-LICENSE +15 -0
- package/licenses/sharp-LICENSE +191 -0
- package/licenses/sharp-win32-x64-LICENSE +191 -0
- package/licenses/sharp-win32-x64-NOTICES.md +59 -0
- package/licenses/source-map-js-LICENSE +28 -0
- package/licenses/three-LICENSE +21 -0
- package/package.json +92 -6
- package/skills/multiplane/SKILL.md +213 -0
- package/src/alpha.mjs +69 -0
- package/src/artifacts.mjs +61 -0
- package/src/benchmark.mjs +140 -0
- package/src/browser-recovery.mjs +26 -0
- package/src/capture.mjs +224 -0
- package/src/config.mjs +79 -0
- package/src/doctor.mjs +56 -0
- package/src/draft.mjs +30 -0
- package/src/encoder.mjs +16 -0
- package/src/environment.mjs +56 -0
- package/src/errors.mjs +51 -0
- package/src/events.mjs +252 -0
- package/src/ffmpeg.mjs +32 -0
- package/src/format.mjs +27 -0
- package/src/frame-rate.mjs +28 -0
- package/src/home-migration.mjs +54 -0
- package/src/hosted.mjs +134 -0
- package/src/index.mjs +14 -0
- package/src/inspect.mjs +20 -0
- package/src/jobs.mjs +51 -0
- package/src/lint.mjs +248 -0
- package/src/output-alpha.mjs +36 -0
- package/src/output-check.mjs +110 -0
- package/src/pixel-comparison.mjs +43 -0
- package/src/platform.mjs +114 -0
- package/src/png-native.mjs +12 -0
- package/src/png.mjs +74 -0
- package/src/post.mjs +433 -0
- package/src/preview.mjs +23 -0
- package/src/process-identity.mjs +17 -0
- package/src/process.mjs +9 -0
- package/src/render-pool.mjs +93 -0
- package/src/render-worker.mjs +59 -0
- package/src/render.mjs +157 -0
- package/src/resource-path.mjs +29 -0
- package/src/resources.mjs +27 -0
- package/src/runtime/camera.js +37 -0
- package/src/runtime/shot.css +9 -0
- package/src/runtime/shot.js +71 -0
- package/src/serve.mjs +23 -0
- package/src/server.mjs +93 -0
- package/src/setup.mjs +51 -0
- package/src/skills.mjs +128 -0
- package/src/static-reuse.mjs +265 -0
- package/src/stills.mjs +81 -0
- package/src/three/index.js +348 -0
- package/src/user-home.mjs +105 -0
- package/src/verify.mjs +161 -0
- package/src/video-pipe.mjs +6 -0
- package/src/workers.mjs +62 -0
- package/tools/bench/shots/canvas-2d.html +1 -0
- package/tools/bench/shots/dom-light.html +1 -0
- package/tools/bench/shots/scene.js +45 -0
- package/tools/bench/shots/webgl.html +1 -0
- package/tools/install-browser.mjs +18 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
|
3
|
+
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
|
4
|
+
import { BokehPass } from 'three/addons/postprocessing/BokehPass.js';
|
|
5
|
+
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
|
|
6
|
+
import { FullScreenQuad } from 'three/addons/postprocessing/Pass.js';
|
|
7
|
+
|
|
8
|
+
export { THREE };
|
|
9
|
+
|
|
10
|
+
export function apertureForBlurRadius({ radiusPixels, depth, focusDistance, fov = 38, pixelHeight = 1080 }) {
|
|
11
|
+
if (![radiusPixels, depth, focusDistance, fov, pixelHeight].every(Number.isFinite)
|
|
12
|
+
|| radiusPixels < 0 || depth <= 0 || focusDistance <= 0 || fov <= 0 || fov >= 180 || pixelHeight <= 0) throw Object.assign(new Error('Invalid blur conversion parameters.'), { code: 'shot_contract_invalid' });
|
|
13
|
+
if (radiusPixels === 0) return 0;
|
|
14
|
+
const defocus = Math.abs(1 / depth - 1 / focusDistance);
|
|
15
|
+
if (defocus === 0) throw Object.assign(new Error('Blur radius on the focal plane is always zero.'), { code: 'shot_contract_invalid' });
|
|
16
|
+
return radiusPixels * 2 * Math.tan(THREE.MathUtils.degToRad(fov / 2)) / (pixelHeight * defocus);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function halton(index, base) {
|
|
20
|
+
let value = 0, weight = 1;
|
|
21
|
+
while (index > 0) { weight /= base; value += weight * (index % base); index = Math.floor(index / base); }
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function roundedGeometry(w, h, radius) {
|
|
26
|
+
const r = Math.max(0, Math.min(radius, w / 2, h / 2));
|
|
27
|
+
const s = new THREE.Shape();
|
|
28
|
+
s.moveTo(-w / 2 + r, -h / 2);
|
|
29
|
+
s.lineTo(w / 2 - r, -h / 2); s.quadraticCurveTo(w / 2, -h / 2, w / 2, -h / 2 + r);
|
|
30
|
+
s.lineTo(w / 2, h / 2 - r); s.quadraticCurveTo(w / 2, h / 2, w / 2 - r, h / 2);
|
|
31
|
+
s.lineTo(-w / 2 + r, h / 2); s.quadraticCurveTo(-w / 2, h / 2, -w / 2, h / 2 - r);
|
|
32
|
+
s.lineTo(-w / 2, -h / 2 + r); s.quadraticCurveTo(-w / 2, -h / 2, -w / 2 + r, -h / 2);
|
|
33
|
+
const geometry = new THREE.ShapeGeometry(s, 16);
|
|
34
|
+
const p = geometry.attributes.position, uv = geometry.attributes.uv;
|
|
35
|
+
for (let i = 0; i < p.count; i++) uv.setXY(i, p.getX(i) / w + 0.5, p.getY(i) / h + 0.5);
|
|
36
|
+
return geometry;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const defaultStyle = Object.freeze({ annotationFontFamily: 'sans-serif', annotationColor: '#555555',
|
|
40
|
+
annotationTextColor: '#222222', textureBackground: '#eeeeee', browserFrameColor: '#666666', browserFrameFontFamily: 'sans-serif' });
|
|
41
|
+
|
|
42
|
+
async function loadTextureFont(family, weight, text) {
|
|
43
|
+
try {
|
|
44
|
+
if (typeof family !== 'string' || !family.trim() || !CSS.supports('font-family', family)) throw new Error('Invalid font family.');
|
|
45
|
+
if (!/^(['"]).*\1$/.test(family.trim()) && family.includes(',')) throw new Error('Provide a single font family.');
|
|
46
|
+
const loaded = await document.fonts.load(`${weight} 64px ${family}`, text || 'Text');
|
|
47
|
+
// FontFaceSet.load succeeds with an empty list for an unknown family.
|
|
48
|
+
// Probe local fonts explicitly instead of silently drawing a fallback.
|
|
49
|
+
if (!loaded.length && !/^(serif|sans-serif|monospace|cursive|fantasy|system-ui|ui-serif|ui-sans-serif|ui-monospace|ui-rounded|math)$/i.test(family.trim())) {
|
|
50
|
+
const name = family.trim().replace(/^(['"])(.*)\1$/, '$2');
|
|
51
|
+
await new FontFace('MultiplaneFontProbe', `local(${JSON.stringify(name)})`, { weight: String(weight) }).load();
|
|
52
|
+
}
|
|
53
|
+
} catch (cause) {
|
|
54
|
+
throw Object.assign(new Error(`Texture font failed to load: ${family}`, { cause }), { code: 'resource_failed' });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function annotate(ctx, annotations, width, height, style) {
|
|
59
|
+
for (const a of annotations) {
|
|
60
|
+
const x = a.x * width, y = a.y * height, w = (a.w ?? 0) * width, h = (a.h ?? 0) * height;
|
|
61
|
+
ctx.fillStyle = ctx.strokeStyle = a.color ?? style.annotationColor;
|
|
62
|
+
ctx.lineWidth = (a.lineWidth ?? 0.003) * width;
|
|
63
|
+
if (a.type === 'highlight') ctx.fillRect(x, y, w, h);
|
|
64
|
+
if (a.type === 'underline') { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + w, y); ctx.stroke(); }
|
|
65
|
+
if (a.type === 'circle') { ctx.beginPath(); ctx.ellipse(x + w / 2, y + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2); ctx.stroke(); }
|
|
66
|
+
if (a.text) {
|
|
67
|
+
ctx.fillStyle = a.textColor ?? style.annotationTextColor;
|
|
68
|
+
ctx.font = `${a.weight ?? 500} ${(a.fontSize ?? 0.03) * width}px ${style.annotationFontFamily}`;
|
|
69
|
+
ctx.textBaseline = 'middle';
|
|
70
|
+
ctx.fillText(a.text, x + (a.type === 'highlight' ? 0.006 * width : 0), y + h / 2);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createThreeLayer({ stage = document.querySelector('#stage'), size = Shot.size,
|
|
76
|
+
pixelRatio = devicePixelRatio, fov = 38, near = 0.1, far = 100,
|
|
77
|
+
focusDistance = 10, aperture = 0.15, samples = 64, quality = 'final',
|
|
78
|
+
adaptiveSamples = true, maxSamples = 1024, sampleSpacing = 1.5, sampleBlur = 1, rotateSamples = true,
|
|
79
|
+
previewAperture = 0.008, maxBlur = 0.016, toneMapping = false, zIndex = 1, style = {} } = {}) {
|
|
80
|
+
// TODO(design): Document the shared layer/card style fields and single-family font values.
|
|
81
|
+
const layerStyle = { ...defaultStyle, ...style };
|
|
82
|
+
const layerBackground = style.textureBackground !== undefined;
|
|
83
|
+
const renderer = new THREE.WebGLRenderer({ alpha: true, premultipliedAlpha: false, antialias: true, preserveDrawingBuffer: true });
|
|
84
|
+
renderer.setPixelRatio(pixelRatio);
|
|
85
|
+
renderer.setSize(size.w, size.h);
|
|
86
|
+
renderer.setClearColor(0x000000, 0);
|
|
87
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
88
|
+
renderer.toneMapping = toneMapping ? THREE.ACESFilmicToneMapping : THREE.NoToneMapping;
|
|
89
|
+
Object.assign(renderer.domElement.style, { position: 'absolute', inset: '0', zIndex, pointerEvents: 'none' });
|
|
90
|
+
stage.append(renderer.domElement);
|
|
91
|
+
const scene = new THREE.Scene();
|
|
92
|
+
const camera = new THREE.PerspectiveCamera(fov, size.w / size.h, near, far);
|
|
93
|
+
camera.position.z = 10;
|
|
94
|
+
camera.layers.enable(1);
|
|
95
|
+
const target = new THREE.WebGLRenderTarget(size.w * pixelRatio, size.h * pixelRatio, { type: THREE.HalfFloatType, samples: 4 });
|
|
96
|
+
const composer = new EffectComposer(renderer, target);
|
|
97
|
+
const renderPass = new RenderPass(scene, camera);
|
|
98
|
+
const bokeh = new BokehPass(scene, camera, { focus: focusDistance, aperture: previewAperture, maxblur: maxBlur });
|
|
99
|
+
const original = bokeh.materialBokeh.fragmentShader;
|
|
100
|
+
if (!original.includes('gl_FragColor.a = 1.0;')) throw Object.assign(new Error('BokehShader interface changed; check the three.js version.'), { code: 'shot_contract_invalid' });
|
|
101
|
+
bokeh.materialBokeh.fragmentShader = original.replace('gl_FragColor.a = 1.0;',
|
|
102
|
+
'gl_FragColor.rgb /= max(gl_FragColor.a, 0.00001);');
|
|
103
|
+
const renderBokeh = bokeh.render.bind(bokeh);
|
|
104
|
+
bokeh.render = (...args) => {
|
|
105
|
+
const mask = camera.layers.mask;
|
|
106
|
+
camera.layers.disable(1);
|
|
107
|
+
try { renderBokeh(...args); } finally { camera.layers.mask = mask; }
|
|
108
|
+
};
|
|
109
|
+
composer.addPass(renderPass); composer.addPass(bokeh); composer.addPass(new OutputPass());
|
|
110
|
+
composer.setSize(size.w, size.h);
|
|
111
|
+
const width = renderer.domElement.width, height = renderer.domElement.height;
|
|
112
|
+
const sampleTarget = new THREE.WebGLRenderTarget(width, height, { type: THREE.HalfFloatType });
|
|
113
|
+
sampleTarget.depthTexture = new THREE.DepthTexture(width, height, THREE.UnsignedIntType);
|
|
114
|
+
if (!renderer.extensions.has('EXT_float_blend')) throw Object.assign(new Error('Accumulated depth of field requires EXT_float_blend.'), { code: 'shot_contract_invalid' });
|
|
115
|
+
const accumulationTarget = new THREE.WebGLRenderTarget(width, height, { type: THREE.FloatType, depthBuffer: false });
|
|
116
|
+
const sumMaterial = new THREE.ShaderMaterial({
|
|
117
|
+
uniforms: { image: { value: sampleTarget.texture }, weight: { value: 1 / samples },
|
|
118
|
+
depthImage: { value: sampleTarget.depthTexture }, resolution: { value: new THREE.Vector2(width, height) },
|
|
119
|
+
cameraNear: { value: near }, cameraFar: { value: far }, focus: { value: focusDistance },
|
|
120
|
+
blurScale: { value: 0 } },
|
|
121
|
+
vertexShader: 'varying vec2 vUv; void main(){vUv=uv;gl_Position=vec4(position.xy,0.,1.);}',
|
|
122
|
+
fragmentShader: `
|
|
123
|
+
uniform sampler2D image, depthImage;
|
|
124
|
+
uniform float weight, cameraNear, cameraFar, focus, blurScale;
|
|
125
|
+
uniform vec2 resolution;
|
|
126
|
+
varying vec2 vUv;
|
|
127
|
+
float depthAt(vec2 uv) {
|
|
128
|
+
float d = texture2D(depthImage, uv).x;
|
|
129
|
+
return cameraNear * cameraFar / (cameraFar - d * (cameraFar - cameraNear));
|
|
130
|
+
}
|
|
131
|
+
void main() {
|
|
132
|
+
vec4 center = texture2D(image, vUv);
|
|
133
|
+
float z = depthAt(vUv);
|
|
134
|
+
float radius = blurScale * abs(1.0 / z - 1.0 / focus);
|
|
135
|
+
if (radius < 0.001 || z > cameraFar * 0.999) {
|
|
136
|
+
gl_FragColor = center * weight; return;
|
|
137
|
+
}
|
|
138
|
+
vec4 color = center;
|
|
139
|
+
float total = 1.0;
|
|
140
|
+
float slope = min(length(vec2(dFdx(z), dFdy(z))), z * 0.002);
|
|
141
|
+
for (int i = 0; i < 16; i++) {
|
|
142
|
+
float r = sqrt((float(i) + 0.5) / 16.0);
|
|
143
|
+
float a = float(i) * 2.39996323;
|
|
144
|
+
vec2 delta = vec2(cos(a), sin(a)) * r * radius * 2.0;
|
|
145
|
+
vec2 uv = vUv + delta / resolution;
|
|
146
|
+
float other = depthAt(uv);
|
|
147
|
+
float tolerance = max(z * 0.0002, slope * (length(delta) + 1.0) * 1.5);
|
|
148
|
+
float w = exp(-2.0 * r * r) * (1.0 - smoothstep(tolerance, tolerance * 2.0, abs(other - z)));
|
|
149
|
+
vec2 pixelUv = (floor(uv * resolution) + 0.5) / resolution;
|
|
150
|
+
color += texture2D(image, pixelUv) * w; total += w;
|
|
151
|
+
}
|
|
152
|
+
gl_FragColor = color / total * weight;
|
|
153
|
+
}`,
|
|
154
|
+
depthTest: false, depthWrite: false, transparent: true,
|
|
155
|
+
blending: THREE.CustomBlending, blendEquation: THREE.AddEquation,
|
|
156
|
+
blendSrc: THREE.OneFactor, blendDst: THREE.OneFactor,
|
|
157
|
+
});
|
|
158
|
+
const sumQuad = new FullScreenQuad(sumMaterial);
|
|
159
|
+
const output = new OutputPass();
|
|
160
|
+
output.material.fragmentShader = output.material.fragmentShader.replace(
|
|
161
|
+
'gl_FragColor = texture2D( tDiffuse, vUv );',
|
|
162
|
+
'gl_FragColor = texture2D( tDiffuse, vUv ); gl_FragColor.rgb /= max(gl_FragColor.a, 0.00001);');
|
|
163
|
+
output.renderToScreen = true;
|
|
164
|
+
const sampling = { quality, samples, adaptiveSamples, maxSamples, sampleSpacing, sampleBlur, rotateSamples };
|
|
165
|
+
const stats = { samples: 0, maxBlurRadius: 0, rotation: 0 };
|
|
166
|
+
const patterns = new Map();
|
|
167
|
+
function pattern(count) {
|
|
168
|
+
if (!patterns.has(count)) {
|
|
169
|
+
const points = Array.from({ length: count }, (_, i) => {
|
|
170
|
+
const r = Math.sqrt((i + 0.5) / count), theta = i * Math.PI * (3 - Math.sqrt(5));
|
|
171
|
+
return [r * Math.cos(theta), r * Math.sin(theta), halton(i + 1, 2) - 0.5, halton(i + 1, 3) - 0.5];
|
|
172
|
+
});
|
|
173
|
+
const mean = [0, 1, 2, 3].map(j => points.reduce((v, p) => v + p[j], 0) / count);
|
|
174
|
+
const lensScale = Math.max(1, ...points.map(p => Math.hypot(p[0] - mean[0], p[1] - mean[1])));
|
|
175
|
+
if (patterns.size >= 8) patterns.delete(patterns.keys().next().value);
|
|
176
|
+
patterns.set(count, points.map(p => p.map((v, j) => (v - mean[j]) / (j < 2 ? lensScale : 1))));
|
|
177
|
+
}
|
|
178
|
+
return patterns.get(count);
|
|
179
|
+
}
|
|
180
|
+
function renderAccumulation(focus, radius, count, blur, rotation) {
|
|
181
|
+
const position = camera.position.clone(), projection = camera.projectionMatrix.clone();
|
|
182
|
+
const projectionInverse = camera.projectionMatrixInverse.clone();
|
|
183
|
+
const autoClear = renderer.autoClear, oldTarget = renderer.getRenderTarget();
|
|
184
|
+
camera.updateMatrixWorld(true);
|
|
185
|
+
const right = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0);
|
|
186
|
+
const up = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 1);
|
|
187
|
+
if (camera.parent) {
|
|
188
|
+
const inverse = camera.parent.matrixWorld.clone().invert();
|
|
189
|
+
const origin = new THREE.Vector3().applyMatrix4(inverse);
|
|
190
|
+
right.applyMatrix4(inverse).sub(origin); up.applyMatrix4(inverse).sub(origin);
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
renderer.setRenderTarget(accumulationTarget); renderer.clear();
|
|
194
|
+
sumMaterial.uniforms.weight.value = 1 / count;
|
|
195
|
+
sumMaterial.uniforms.focus.value = focus;
|
|
196
|
+
sumMaterial.uniforms.cameraNear.value = camera.near;
|
|
197
|
+
sumMaterial.uniforms.cameraFar.value = camera.far;
|
|
198
|
+
sumMaterial.uniforms.blurScale.value = radius * height * projection.elements[5] * 0.5 * blur / Math.sqrt(count);
|
|
199
|
+
for (const [x, y, jx, jy] of pattern(count)) {
|
|
200
|
+
const dx = (x * Math.cos(rotation) - y * Math.sin(rotation)) * radius;
|
|
201
|
+
const dy = (x * Math.sin(rotation) + y * Math.cos(rotation)) * radius;
|
|
202
|
+
camera.position.copy(position).addScaledVector(right, dx).addScaledVector(up, dy);
|
|
203
|
+
camera.updateMatrixWorld(true);
|
|
204
|
+
camera.projectionMatrix.copy(projection);
|
|
205
|
+
camera.projectionMatrix.elements[8] += -projection.elements[0] * dx / focus + 2 * jx / width;
|
|
206
|
+
camera.projectionMatrix.elements[9] += -projection.elements[5] * dy / focus + 2 * jy / height;
|
|
207
|
+
camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
|
|
208
|
+
renderer.autoClear = true; renderer.setRenderTarget(sampleTarget); renderer.render(scene, camera);
|
|
209
|
+
renderer.autoClear = false; renderer.setRenderTarget(accumulationTarget); sumQuad.render(renderer);
|
|
210
|
+
}
|
|
211
|
+
renderer.autoClear = true;
|
|
212
|
+
output.render(renderer, null, accumulationTarget);
|
|
213
|
+
} finally {
|
|
214
|
+
camera.position.copy(position); camera.projectionMatrix.copy(projection);
|
|
215
|
+
camera.projectionMatrixInverse.copy(projectionInverse); camera.updateMatrixWorld(true);
|
|
216
|
+
renderer.autoClear = autoClear; renderer.setRenderTarget(oldTarget);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const pending = [], textures = new Set(), textureCache = new Map();
|
|
220
|
+
function configureTexture(texture) {
|
|
221
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
222
|
+
texture.generateMipmaps = true;
|
|
223
|
+
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
224
|
+
texture.magFilter = THREE.LinearFilter;
|
|
225
|
+
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
|
226
|
+
textures.add(texture);
|
|
227
|
+
return texture;
|
|
228
|
+
}
|
|
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;
|
|
233
|
+
const group = new THREE.Group();
|
|
234
|
+
group.position.fromArray(position); group.rotation.set(...rotation);
|
|
235
|
+
const material = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide });
|
|
236
|
+
const mesh = new THREE.Mesh(roundedGeometry(width, height, radius), material);
|
|
237
|
+
group.add(mesh);
|
|
238
|
+
if (border) {
|
|
239
|
+
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);
|
|
242
|
+
}
|
|
243
|
+
if (shadow) {
|
|
244
|
+
const c = document.createElement('canvas'); c.width = c.height = 512;
|
|
245
|
+
const ctx = c.getContext('2d');
|
|
246
|
+
ctx.filter = 'blur(22px)'; ctx.fillStyle = 'rgba(0,0,0,0.28)';
|
|
247
|
+
ctx.fillRect(62, 62, 388, 388);
|
|
248
|
+
const texture = configureTexture(new THREE.CanvasTexture(c));
|
|
249
|
+
const plane = new THREE.Mesh(new THREE.PlaneGeometry(width * 1.3, height * 1.3),
|
|
250
|
+
new THREE.MeshBasicMaterial({ map: texture, transparent: true, depthWrite: false, side: THREE.DoubleSide }));
|
|
251
|
+
plane.layers.set(1); plane.position.set(0.04, -0.07, -0.025); group.add(plane);
|
|
252
|
+
}
|
|
253
|
+
const key = JSON.stringify({ src, annotations, browserFrame, textureWidth, aspect: width / height, style: textureStyle, composeBackground });
|
|
254
|
+
if (!textureCache.has(key)) textureCache.set(key, (async () => {
|
|
255
|
+
const image = await new THREE.ImageLoader().loadAsync(src);
|
|
256
|
+
if (!annotations.length && !browserFrame && !composeBackground) return configureTexture(new THREE.Texture(image));
|
|
257
|
+
const fontLoads = annotations.filter(a => a.text).map(a => loadTextureFont(textureStyle.annotationFontFamily, a.weight ?? 500, a.text));
|
|
258
|
+
const title = new URL(src, location.href).pathname.split('/').pop();
|
|
259
|
+
if (browserFrame) fontLoads.push(loadTextureFont(textureStyle.browserFrameFontFamily, 400, title));
|
|
260
|
+
await Promise.all(fontLoads);
|
|
261
|
+
const c = document.createElement('canvas');
|
|
262
|
+
c.width = Math.min(renderer.capabilities.maxTextureSize, Math.max(image.width, textureWidth));
|
|
263
|
+
c.height = Math.round(c.width * height / width);
|
|
264
|
+
const ctx = c.getContext('2d');
|
|
265
|
+
const bar = browserFrame ? Math.round(c.height * 0.065) : 0;
|
|
266
|
+
ctx.fillStyle = textureStyle.textureBackground; ctx.fillRect(0, 0, c.width, c.height);
|
|
267
|
+
ctx.drawImage(image, 0, bar, c.width, c.height - bar);
|
|
268
|
+
if (browserFrame) {
|
|
269
|
+
ctx.fillStyle = textureStyle.browserFrameColor;
|
|
270
|
+
for (let i = 0; i < 3; i++) { ctx.beginPath(); ctx.arc(c.width * (0.022 + i * 0.019), bar / 2, c.width * 0.004, 0, 2 * Math.PI); ctx.fill(); }
|
|
271
|
+
ctx.font = `${bar * 0.45}px ${textureStyle.browserFrameFontFamily}`; ctx.textBaseline = 'middle';
|
|
272
|
+
ctx.fillText(title, c.width * 0.10, bar / 2);
|
|
273
|
+
}
|
|
274
|
+
annotate(ctx, annotations, c.width, c.height, textureStyle);
|
|
275
|
+
return configureTexture(new THREE.CanvasTexture(c));
|
|
276
|
+
})());
|
|
277
|
+
const loaded = textureCache.get(key).then(texture => { texture.needsUpdate = true; material.map = texture; material.needsUpdate = true; });
|
|
278
|
+
loaded.catch(() => {});
|
|
279
|
+
pending.push(loaded); scene.add(group);
|
|
280
|
+
group.userData.material = material;
|
|
281
|
+
return group;
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
scene, camera, renderer, composer, bokeh, card, sampling, stats, sampleTarget, accumulationTarget,
|
|
285
|
+
get ready() { return Promise.all(pending); },
|
|
286
|
+
render({ focusDistance: focus = focusDistance, aperture: radius = aperture,
|
|
287
|
+
samples: count = sampling.samples, quality: mode = sampling.quality,
|
|
288
|
+
adaptiveSamples: adaptive = sampling.adaptiveSamples, maxSamples: maximum = sampling.maxSamples,
|
|
289
|
+
sampleSpacing: spacing = sampling.sampleSpacing, sampleBlur: blur = sampling.sampleBlur,
|
|
290
|
+
rotateSamples: rotate = sampling.rotateSamples,
|
|
291
|
+
t = window.SHOT?.sampleContext?.time ?? 0, motionBlur = window.SHOT?.sampleContext?.motionBlur ?? 1,
|
|
292
|
+
previewAperture: previewRadius = previewAperture, maxBlur: previewMax = maxBlur } = {}) {
|
|
293
|
+
if (!Number.isFinite(focus) || focus <= 0 || !Number.isFinite(radius) || radius < 0
|
|
294
|
+
|| !Number.isInteger(count) || count < 1 || !['preview', 'final'].includes(mode)
|
|
295
|
+
|| !Number.isInteger(maximum) || maximum < count || !Number.isFinite(spacing) || spacing <= 0
|
|
296
|
+
|| !Number.isFinite(blur) || blur < 0 || !Number.isFinite(t)
|
|
297
|
+
|| !Number.isInteger(motionBlur) || motionBlur < 1) throw Object.assign(new Error('Invalid three layer sampling parameters.'), { code: 'shot_contract_invalid' });
|
|
298
|
+
if (mode === 'preview') {
|
|
299
|
+
bokeh.uniforms.focus.value = focus; bokeh.uniforms.aperture.value = previewRadius; bokeh.uniforms.maxblur.value = previewMax;
|
|
300
|
+
composer.render(0);
|
|
301
|
+
} else {
|
|
302
|
+
scene.updateMatrixWorld(true); camera.updateWorldMatrix(true, false);
|
|
303
|
+
const view = new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
|
|
304
|
+
const frustum = new THREE.Frustum().setFromProjectionMatrix(view);
|
|
305
|
+
let maxRadius = 0;
|
|
306
|
+
const point = new THREE.Vector3(), transform = new THREE.Matrix4();
|
|
307
|
+
scene.traverseVisible(object => {
|
|
308
|
+
if (!object.isMesh || !object.layers.test(camera.layers) || !object.material?.depthWrite
|
|
309
|
+
|| !frustum.intersectsObject(object)) return;
|
|
310
|
+
transform.multiplyMatrices(camera.matrixWorldInverse, object.matrixWorld);
|
|
311
|
+
const vertices = object.geometry.attributes.position;
|
|
312
|
+
let minZ = Infinity, maxZ = -Infinity;
|
|
313
|
+
for (let i = 0; i < vertices.count; i++) {
|
|
314
|
+
point.fromBufferAttribute(vertices, i).applyMatrix4(transform);
|
|
315
|
+
minZ = Math.min(minZ, -point.z); maxZ = Math.max(maxZ, -point.z);
|
|
316
|
+
}
|
|
317
|
+
if (maxZ < camera.near || minZ > camera.far) return;
|
|
318
|
+
for (const z of [Math.max(camera.near, minZ), Math.min(camera.far, maxZ)]) {
|
|
319
|
+
maxRadius = Math.max(maxRadius, radius * height * camera.projectionMatrix.elements[5] * 0.5 * Math.abs(1 / z - 1 / focus));
|
|
320
|
+
}
|
|
321
|
+
});
|
|
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);
|
|
324
|
+
const rotation = rotate && motionBlur > 1 ? (phase - Math.floor(phase)) * Math.PI * 2 : 0;
|
|
325
|
+
Object.assign(stats, { samples: actual, maxBlurRadius: maxRadius, rotation });
|
|
326
|
+
renderAccumulation(focus, radius, actual, blur, rotation);
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
gpuInfo() {
|
|
330
|
+
const gl = renderer.getContext(), ext = gl.getExtension('WEBGL_debug_renderer_info');
|
|
331
|
+
const name = ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
|
|
332
|
+
return { renderer: name, hardware: ext ? !/swiftshader|llvmpipe|software|basic render|warp/i.test(name) : null,
|
|
333
|
+
width: renderer.domElement.width, height: renderer.domElement.height, anisotropy: renderer.capabilities.getMaxAnisotropy() };
|
|
334
|
+
},
|
|
335
|
+
dispose() {
|
|
336
|
+
scene.traverse(object => { object.geometry?.dispose(); object.material?.dispose(); });
|
|
337
|
+
for (const texture of textures) texture.dispose();
|
|
338
|
+
for (const pass of composer.passes) pass.dispose?.();
|
|
339
|
+
sampleTarget.dispose(); accumulationTarget.dispose(); sumMaterial.dispose(); sumQuad.dispose(); output.dispose();
|
|
340
|
+
composer.dispose(); renderer.dispose(); renderer.domElement.remove();
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function driftPosition(t, from, to, settle, velocity) {
|
|
346
|
+
const time = Math.max(0, t), k = Math.min(time / settle, 1), eased = 1 - (1 - k) ** 3;
|
|
347
|
+
return from.map((value, i) => value + (to[i] - value - velocity[i] * settle) * eased + velocity[i] * time);
|
|
348
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { migrateUserHome } from './home-migration.mjs';
|
|
3
|
+
import { readFileSync, mkdirSync, statSync, renameSync, openSync, writeSync, closeSync, unlinkSync } from 'node:fs';
|
|
4
|
+
import { userHome } from './platform.mjs';
|
|
5
|
+
import { encoders } from './encoder.mjs';
|
|
6
|
+
|
|
7
|
+
const fields = {
|
|
8
|
+
ffmpeg: ['ffmpeg', v => typeof v === 'string' && !!v.trim() && !v.includes('\0')],
|
|
9
|
+
jobsDir: ['job-dir', v => typeof v === 'string' && !!v.trim() && !v.includes('\0')],
|
|
10
|
+
raster: ['raster', v => ['gpu', 'software'].includes(v)],
|
|
11
|
+
encoder: ['encoder', v => encoders.includes(v)],
|
|
12
|
+
workers: ['workers', v => Number.isSafeInteger(v) && v > 0],
|
|
13
|
+
hosted: ['hosted', v => typeof v === 'boolean'],
|
|
14
|
+
};
|
|
15
|
+
const supported = command => command === 'render' ? Object.keys(fields)
|
|
16
|
+
: command === 'benchmark' ? ['raster'] : command === 'doctor' ? ['ffmpeg', 'raster'] : command === 'verify' ? ['jobsDir', 'raster', 'workers', 'hosted']
|
|
17
|
+
: command === 'stills' ? ['jobsDir', 'raster', 'hosted'] : ['status', 'cancel', 'wait'].includes(command) ? ['jobsDir'] : [];
|
|
18
|
+
|
|
19
|
+
// Share one bounded retry budget across lock acquisition and index operations.
|
|
20
|
+
export function retryFileOperation(operation, deadline, { platform = process.platform, now = () => performance.now(), sleep = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) } = {}) {
|
|
21
|
+
let delay = 10;
|
|
22
|
+
for (;;) {
|
|
23
|
+
try { return operation(); }
|
|
24
|
+
catch (error) {
|
|
25
|
+
const remaining = deadline - now();
|
|
26
|
+
if (platform !== 'win32' || !['EPERM', 'EBUSY', 'EACCES'].includes(error.code) || remaining <= 0) throw error;
|
|
27
|
+
sleep(Math.min(delay, remaining)); delay = Math.min(100, delay * 2);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function loadUserConfig(command, explicit = {}, env = process.env) {
|
|
33
|
+
const migration = migrateUserHome(env);
|
|
34
|
+
const directory = userHome(env), file = path.join(directory, 'config.json'), warnings = [...migration.warnings];
|
|
35
|
+
const config = { path: file, exists: false, applied: [] }, values = {};
|
|
36
|
+
const invalid = field => warnings.push({ code: 'config_invalid', message: `Invalid user configuration field: ${field}.`, details: { field } });
|
|
37
|
+
let data;
|
|
38
|
+
try {
|
|
39
|
+
statSync(file); config.exists = true;
|
|
40
|
+
const text = readFileSync(file, 'utf8');
|
|
41
|
+
try { data = JSON.parse(text); }
|
|
42
|
+
// "$" identifies an invalid whole config document.
|
|
43
|
+
catch { invalid('$'); }
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error.code !== 'ENOENT') warnings.push({ code: 'home_unavailable', message: 'User configuration could not be read.' });
|
|
46
|
+
}
|
|
47
|
+
if (data !== undefined) {
|
|
48
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) invalid('$');
|
|
49
|
+
else {
|
|
50
|
+
if (data.schema != null && data.schema !== 'multiplane/1') invalid('schema');
|
|
51
|
+
for (const [field, [key, validate]] of Object.entries(fields)) {
|
|
52
|
+
if (data[field] == null) continue;
|
|
53
|
+
if (!validate(data[field])) { invalid(field); continue; }
|
|
54
|
+
if (!supported(command).includes(field)) continue;
|
|
55
|
+
const environment = field === 'ffmpeg' ? env.MULTIPLANE_FFMPEG : field === 'jobsDir' ? env.MULTIPLANE_JOBS_DIR : undefined;
|
|
56
|
+
if (explicit[key] === undefined && environment === undefined) { values[key] = data[field]; config.applied.push(field); }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (supported(command).includes('ffmpeg') && env.MULTIPLANE_FFMPEG !== undefined) values.ffmpeg = env.MULTIPLANE_FFMPEG;
|
|
61
|
+
if (supported(command).includes('jobsDir') && env.MULTIPLANE_JOBS_DIR !== undefined) values['job-dir'] = env.MULTIPLANE_JOBS_DIR;
|
|
62
|
+
return { directory, config, migration, warnings, options: { ...values, ...explicit } };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Serialize rotation and append together, recovering abandoned locks.
|
|
66
|
+
export function appendJobIndex(directory, record, { limit = 10 * 1024 * 1024, timeout = 1000 } = {}) {
|
|
67
|
+
mkdirSync(directory, { recursive: true });
|
|
68
|
+
const lock = path.join(directory, 'jobs.lock'), file = path.join(directory, 'jobs.jsonl');
|
|
69
|
+
const deadline = performance.now() + Math.min(1000, timeout);
|
|
70
|
+
const retry = operation => retryFileOperation(operation, deadline);
|
|
71
|
+
let handle;
|
|
72
|
+
for (;;) {
|
|
73
|
+
try { handle = retry(() => openSync(lock, 'wx')); break; }
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error.code !== 'EEXIST') throw error;
|
|
76
|
+
try {
|
|
77
|
+
const info = retry(() => statSync(lock));
|
|
78
|
+
let owner;
|
|
79
|
+
try { owner = JSON.parse(retry(() => readFileSync(lock, 'utf8'))); } catch { /* A new owner may still be writing. */ }
|
|
80
|
+
let dead = false;
|
|
81
|
+
if (Number.isSafeInteger(owner?.pid) && owner.pid > 0) {
|
|
82
|
+
try { process.kill(owner.pid, 0); } catch (e) { dead = e.code === 'ESRCH'; }
|
|
83
|
+
}
|
|
84
|
+
if (dead || Date.now() - info.mtimeMs > 10000) {
|
|
85
|
+
const current = retry(() => statSync(lock));
|
|
86
|
+
if (current.ino === info.ino && current.mtimeMs === info.mtimeMs) retry(() => unlinkSync(lock));
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
} catch (e) { if (e.code === 'ENOENT') continue; throw e; }
|
|
90
|
+
if (performance.now() >= deadline) throw error;
|
|
91
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
retry(() => writeSync(handle, JSON.stringify({ pid: process.pid, ts: new Date().toISOString() }) + '\n'));
|
|
96
|
+
let size = 0;
|
|
97
|
+
try { size = retry(() => statSync(file)).size; } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
98
|
+
if (size > limit) retry(() => renameSync(file, path.join(directory, 'jobs.1.jsonl')));
|
|
99
|
+
const fd = retry(() => openSync(file, 'a'));
|
|
100
|
+
try {
|
|
101
|
+
const bytes = Buffer.from(JSON.stringify(record) + '\n');
|
|
102
|
+
if (retry(() => writeSync(fd, bytes)) !== bytes.length) throw new Error('Incomplete index append.');
|
|
103
|
+
} finally { closeSync(fd); }
|
|
104
|
+
} finally { closeSync(handle); retry(() => unlinkSync(lock)); }
|
|
105
|
+
}
|
package/src/verify.mjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { reuseEnabled, reuseReport, reuseState } from './static-reuse.mjs';
|
|
2
|
+
import { pixelDifference, classifyPixelDifference } from './pixel-comparison.mjs';
|
|
3
|
+
import { recoveryBudget } from './browser-recovery.mjs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { mkdir, mkdtemp, readFile, writeFile, rm } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { openShot, number } from './capture.mjs';
|
|
9
|
+
import { createPostCapture, resolveScale, blurSampleReport } from './post.mjs';
|
|
10
|
+
import { createRenderPool } from './render-pool.mjs';
|
|
11
|
+
import { decodePNG, encodePNG } from './png.mjs';
|
|
12
|
+
import { runCommand } from './events.mjs';
|
|
13
|
+
import { loaded, observeBrowser } from './environment.mjs';
|
|
14
|
+
import { MultiplaneError, invalidOption } from './errors.mjs';
|
|
15
|
+
import { writeArtifact } from './artifacts.mjs';
|
|
16
|
+
|
|
17
|
+
export async function verifyShot(shot, options = {}) {
|
|
18
|
+
return runCommand('verify', shot, options, async events => {
|
|
19
|
+
options = { ...events.options };
|
|
20
|
+
delete options.onEvent;
|
|
21
|
+
const compareReuse = reuseEnabled(options, false);
|
|
22
|
+
options['static-reuse'] = false;
|
|
23
|
+
options['no-static-reuse'] = true;
|
|
24
|
+
reuseState(options);
|
|
25
|
+
options._browserRecovery = recoveryBudget();
|
|
26
|
+
const out = path.resolve(options.out ?? path.join(events.directory, 'verify'));
|
|
27
|
+
let capture, pool, referenceDirectory;
|
|
28
|
+
events.onCancel(() => pool?.cancel());
|
|
29
|
+
try {
|
|
30
|
+
events.check();
|
|
31
|
+
capture = await openShot(shot, resolveScale(options).renderScale, { ...options, onWarning: events.warning, check: events.check, onBrowser: observeBrowser(events) });
|
|
32
|
+
options.alpha = capture.alpha;
|
|
33
|
+
await loaded(events, capture);
|
|
34
|
+
const fps = number(options.fps ?? capture.fps, '--fps', { positive: true });
|
|
35
|
+
const workers = number(options.workers ?? 2, '--workers', { positive: true });
|
|
36
|
+
if (!Number.isSafeInteger(workers)) throw invalidOption('--workers', '--workers must be a positive integer.');
|
|
37
|
+
events.resources.setWorkers(workers);
|
|
38
|
+
const times = options.at === undefined ? [...new Set([0, 0.25, 0.5, 0.75].map(k => Math.floor(k * capture.duration * fps) / fps))]
|
|
39
|
+
: options.at.split(',').map(t => number(t, '--at'));
|
|
40
|
+
if (times.some(t => t >= capture.duration || Math.abs(t * fps - Math.round(t * fps)) > 1e-7)) throw invalidOption('--at', 'Verification times must be inside the shot and aligned to frames.');
|
|
41
|
+
const data = { staticReuse: reuseReport(options), format: 'png', alpha: capture.alpha, passed: true, times, workers, checks: [], findings: 0 };
|
|
42
|
+
const processed = createPostCapture(capture, { ...options, transport: 'png' }, fps);
|
|
43
|
+
const references = new Map(), hashes = [], rangeReferences = new Map();
|
|
44
|
+
if (compareReuse) referenceDirectory = await mkdtemp(path.join(tmpdir(), 'multiplane-verify-static-'));
|
|
45
|
+
await mkdir(out, { recursive: true });
|
|
46
|
+
const histogram = {};
|
|
47
|
+
const jitterChecks = new Set();
|
|
48
|
+
let errors = 0, totalChangedPixels = 0;
|
|
49
|
+
for (const t of times) {
|
|
50
|
+
events.check(); references.set(t, decodePNG(await processed.frame(t)));
|
|
51
|
+
histogram[processed.lastBlurSamples] = (histogram[processed.lastBlurSamples] ?? 0) + 1;
|
|
52
|
+
}
|
|
53
|
+
data.post = { ...processed.post, adaptiveBlur: Number(options['adaptive-blur'] ?? 0.5) };
|
|
54
|
+
data.blurSamples = blurSampleReport(histogram);
|
|
55
|
+
const check = async (t, actual, name, reference = references.get(t)) => {
|
|
56
|
+
events.check();
|
|
57
|
+
if (reference.file) reference = { ...reference, data: await readFile(reference.file) };
|
|
58
|
+
events.progress('compare', Math.round(t * fps), Math.ceil(captureDuration * fps));
|
|
59
|
+
actual = Buffer.from(actual);
|
|
60
|
+
if (reference.data.equals(actual)) return;
|
|
61
|
+
const difference = pixelDifference(reference, { ...reference, data: actual });
|
|
62
|
+
totalChangedPixels += difference.changedPixels;
|
|
63
|
+
if (classifyPixelDifference(difference, options.raster) === 'jitter') {
|
|
64
|
+
data.findings++;
|
|
65
|
+
jitterChecks.add(name);
|
|
66
|
+
events.emit('finding', { code: 'gpu_raster_jitter', severity: 'warning', message: 'Minor GPU raster differences detected.',
|
|
67
|
+
location: { t, frame: Math.round(t * fps) }, details: { check: name, ...difference } });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
let changedPixels = 0, maxChannelDiff = 0, x0 = reference.width, y0 = reference.height, x1 = -1, y1 = -1;
|
|
71
|
+
const diff = Buffer.alloc(reference.data.length);
|
|
72
|
+
for (let p = 0; p < actual.length; p += 4) {
|
|
73
|
+
let changed = false;
|
|
74
|
+
for (let c = 0; c < 4; c++) {
|
|
75
|
+
const delta = Math.abs(reference.data[p + c] - actual[p + c]);
|
|
76
|
+
maxChannelDiff = Math.max(maxChannelDiff, delta); changed ||= delta > 0;
|
|
77
|
+
}
|
|
78
|
+
diff[p + 3] = 255;
|
|
79
|
+
if (changed) {
|
|
80
|
+
changedPixels++;
|
|
81
|
+
const x = p / 4 % reference.width, y = Math.floor(p / 4 / reference.width);
|
|
82
|
+
x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y);
|
|
83
|
+
for (let c = 0; c < 3; c++) diff[p + c] = Math.max(32, Math.min(255, Math.abs(reference.data[p + c] - actual[p + c]) * 8));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
data.findings++;
|
|
87
|
+
errors++;
|
|
88
|
+
const images = {};
|
|
89
|
+
for (const [role, pixels] of Object.entries({ reference: reference.data, actual, diff })) {
|
|
90
|
+
const file = path.join(out, 'finding-' + data.findings + '-' + role + '.png');
|
|
91
|
+
await writeArtifact(file, encodePNG({ ...reference, data: pixels }));
|
|
92
|
+
images[role] = file; events.artifact(file, 'diff-image');
|
|
93
|
+
}
|
|
94
|
+
events.emit('finding', { code: 'nondeterministic_output', severity: 'error', message: 'Frame pixels differ.',
|
|
95
|
+
hint: 'Make render(t) depend only on t and seeded inputs.', location: { t, frame: Math.round(t * fps) },
|
|
96
|
+
details: { check: name, changedPixels, maxChannelDiff, bbox: { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 }, images } });
|
|
97
|
+
};
|
|
98
|
+
const finishCheck = name => {
|
|
99
|
+
data.checks.push({ name, passed: errors === 0, ...(jitterChecks.has(name) ? { jitter: true } : {}) });
|
|
100
|
+
if (errors) { data.passed = false; throw new MultiplaneError('nondeterministic_output', undefined, { result: { data } }); }
|
|
101
|
+
};
|
|
102
|
+
const captureDuration = capture.duration;
|
|
103
|
+
for (const t of [...times].reverse().concat(times)) await check(t, decodePNG(await processed.frame(t)).data, 'reorder');
|
|
104
|
+
finishCheck('reorder');
|
|
105
|
+
await capture.retryFrame(undefined, async () => {
|
|
106
|
+
await capture.page.reload();
|
|
107
|
+
await capture.page.evaluate(async () => { await SHOT.ready; await document.fonts.ready; });
|
|
108
|
+
});
|
|
109
|
+
const reloaded = createPostCapture(capture, { ...options, transport: 'png' }, fps);
|
|
110
|
+
for (const t of times) await check(t, decodePNG(await reloaded.frame(t)).data, 'reload');
|
|
111
|
+
finishCheck('reload');
|
|
112
|
+
const size = { width: capture.width, height: capture.height };
|
|
113
|
+
await capture.close(); capture = undefined;
|
|
114
|
+
for (const count of new Set([1, workers])) {
|
|
115
|
+
pool = createRenderPool(shot, { ...options, transport: 'raw' }, fps, count, size, events.warning);
|
|
116
|
+
const name = 'workers:' + count;
|
|
117
|
+
for (const t of times) {
|
|
118
|
+
const index = Math.round(t * fps);
|
|
119
|
+
for await (const frame of pool.frames(index, index + 1)) await check(t, frame.png, name);
|
|
120
|
+
}
|
|
121
|
+
finishCheck(name);
|
|
122
|
+
for await (const frame of pool.frames(0, compareReuse ? Math.ceil(fps * captureDuration) : Math.min(Math.ceil(fps * captureDuration), Math.max(2, Math.ceil(fps))))) {
|
|
123
|
+
events.check();
|
|
124
|
+
const pixels = Buffer.from(frame.png);
|
|
125
|
+
if (count === 1) {
|
|
126
|
+
const reference = { width: size.width * resolveScale(options).scale, height: size.height * resolveScale(options).scale };
|
|
127
|
+
if (referenceDirectory) {
|
|
128
|
+
reference.file = path.join(referenceDirectory, String(frame.frame));
|
|
129
|
+
await writeFile(reference.file, pixels);
|
|
130
|
+
} else reference.data = Buffer.from(pixels);
|
|
131
|
+
rangeReferences.set(frame.frame, reference);
|
|
132
|
+
}
|
|
133
|
+
else await check(frame.frame / fps, pixels, 'range', rangeReferences.get(frame.frame));
|
|
134
|
+
hashes.push({ workers: count, frame: frame.frame, sha256: createHash('sha256').update(frame.png).digest('hex') });
|
|
135
|
+
}
|
|
136
|
+
await pool.close(); pool = undefined;
|
|
137
|
+
if (count !== 1) finishCheck('range');
|
|
138
|
+
}
|
|
139
|
+
if (compareReuse) {
|
|
140
|
+
const reuseOptions = { ...options, 'static-reuse': true, 'no-static-reuse': false, transport: 'raw' };
|
|
141
|
+
for (const count of new Set([1, workers])) {
|
|
142
|
+
pool = createRenderPool(shot, reuseOptions, fps, count, size, events.warning);
|
|
143
|
+
const name = 'static-reuse:workers:' + count;
|
|
144
|
+
for await (const frame of pool.frames(0, Math.ceil(fps * captureDuration))) {
|
|
145
|
+
await check(frame.frame / fps, frame.png, name, rangeReferences.get(frame.frame));
|
|
146
|
+
}
|
|
147
|
+
data.staticReuse = pool.staticReuse;
|
|
148
|
+
finishCheck(name);
|
|
149
|
+
await pool.close(); pool = undefined;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const report = path.join(out, 'report.json');
|
|
153
|
+
await writeArtifact(report, JSON.stringify({ ...data, changedPixels: totalChangedPixels, hashes }, null, 2));
|
|
154
|
+
events.artifact(report, 'report');
|
|
155
|
+
return data;
|
|
156
|
+
} finally {
|
|
157
|
+
try { await capture?.close(); }
|
|
158
|
+
finally { try { await pool?.close(); } finally { if (referenceDirectory) await rm(referenceDirectory, { recursive: true, force: true }); } }
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export async function writeFrame(stream, bytes) {
|
|
2
|
+
const chunkSize = 1024 * 1024;
|
|
3
|
+
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
|
4
|
+
await new Promise((resolve, reject) => stream.write(bytes.subarray(offset, offset + chunkSize), error => error ? reject(error) : resolve()));
|
|
5
|
+
}
|
|
6
|
+
}
|