multiplane 0.0.0-stage → 0.3.1
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 +324 -0
- package/README.md +323 -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 +232 -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 +267 -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
package/src/post.mjs
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { reuseEnabled, reuseState, reuseReport, REUSE_INTERVAL } from './static-reuse.mjs';
|
|
2
|
+
import { pixelDifference, classifyPixelDifference } from './pixel-comparison.mjs';
|
|
3
|
+
import { averageAlpha, resizeAlpha, clearHiddenColors } from './alpha.mjs';
|
|
4
|
+
import { MultiplaneError, invalidOption } from './errors.mjs';
|
|
5
|
+
import { number, validateScale } from './capture.mjs';
|
|
6
|
+
import { decodePNG, encodePNG } from './png.mjs';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { decodeNativePNG } from './png-native.mjs';
|
|
9
|
+
import sharp from 'sharp';
|
|
10
|
+
|
|
11
|
+
export const postArgs = ['quality', 'motion-blur', 'max-motion-blur', 'shutter', 'grain', 'no-post', 'adaptive-blur'];
|
|
12
|
+
export const scaleArgs = ['scale', 'render-scale', 'upscale'];
|
|
13
|
+
export const DEFAULT_ADAPTIVE_BLUR = 0.5;
|
|
14
|
+
|
|
15
|
+
export function resolveScale(options = {}) {
|
|
16
|
+
const scale = number(options.scale ?? 1, '--scale', { positive: true });
|
|
17
|
+
const renderScale = number(options['render-scale'] ?? scale, '--render-scale', { positive: true });
|
|
18
|
+
if (renderScale > scale) throw invalidOption('--render-scale', '--render-scale must not exceed --scale.');
|
|
19
|
+
const upscale = options.upscale ?? 'lanczos';
|
|
20
|
+
if (!['neighbor', 'lanczos'].includes(upscale)) throw invalidOption('--upscale', '--upscale must be neighbor or lanczos.');
|
|
21
|
+
return { scale, renderScale, upscale };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function imageDifference(a, b) {
|
|
25
|
+
if (a.width !== b.width || a.height !== b.height) throw new MultiplaneError('internal_error', 'Sample dimensions differ.');
|
|
26
|
+
let sum = 0;
|
|
27
|
+
if (a.data.byteOffset % 4 === 0 && b.data.byteOffset % 4 === 0) {
|
|
28
|
+
const aa = new Uint32Array(a.data.buffer, a.data.byteOffset, a.data.length / 4);
|
|
29
|
+
const bb = new Uint32Array(b.data.buffer, b.data.byteOffset, b.data.length / 4);
|
|
30
|
+
for (let p = 0; p < aa.length; p++) {
|
|
31
|
+
const x = aa[p], y = bb[p];
|
|
32
|
+
if (x === y) continue;
|
|
33
|
+
sum += Math.abs((x & 255) - (y & 255)) + Math.abs(((x >>> 8) & 255) - ((y >>> 8) & 255)) + Math.abs(((x >>> 16) & 255) - ((y >>> 16) & 255));
|
|
34
|
+
}
|
|
35
|
+
return sum / (a.width * a.height * 3);
|
|
36
|
+
}
|
|
37
|
+
for (let p = 0; p < a.data.length; p += 4) for (let c = 0; c < 3; c++) sum += Math.abs(a.data[p + c] - b.data[p + c]);
|
|
38
|
+
return sum / (a.width * a.height * 3);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function resizeImage(image, width, height, upscale) {
|
|
42
|
+
const data = await sharp(image.data, { raw: { width: image.width, height: image.height, channels: 4 } })
|
|
43
|
+
.resize(width, height, { kernel: upscale === 'neighbor' ? 'nearest' : 'lanczos3' }).raw().toBuffer();
|
|
44
|
+
return { width, height, data };
|
|
45
|
+
}
|
|
46
|
+
export function resolveQuality(options = {}) {
|
|
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.');
|
|
49
|
+
return quality;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolvePost(options = {}, post = {}) {
|
|
53
|
+
const quality = resolveQuality(options);
|
|
54
|
+
if (options['no-post']) return { motionBlur: 1, maxMotionBlur: 1, shutter: 0.5, grain: 0 };
|
|
55
|
+
const motionBlur = number(options['motion-blur'] ?? post.motionBlur ?? 1, 'motionBlur', { positive: true });
|
|
56
|
+
if (!Number.isSafeInteger(motionBlur)) throw invalidOption('motionBlur', 'motionBlur must be a positive integer.');
|
|
57
|
+
const maxMotionBlur = number(quality === 'draft' ? 1 : options['max-motion-blur'] ?? post.maxMotionBlur ?? (quality === 'high' ? 256 : 64), 'maxMotionBlur', { positive: true });
|
|
58
|
+
if (!Number.isSafeInteger(maxMotionBlur)) throw invalidOption('maxMotionBlur', 'maxMotionBlur must be a positive integer.');
|
|
59
|
+
// TODO(design): Document max <= base as disabling upward adaptation, including draft overrides.
|
|
60
|
+
return { motionBlur, maxMotionBlur, shutter: number(options.shutter ?? post.shutter ?? 0.5, 'shutter'), grain: number(options.grain ?? post.grain ?? 0, 'grain') };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Local temporal change divided by spatial edge strength estimates displacement.
|
|
64
|
+
// Edge normalization avoids treating dense text as faster than a single straight edge.
|
|
65
|
+
export function localMotionDifference(a, b, alpha = false, stopAbove = Infinity) {
|
|
66
|
+
if (a.width !== b.width || a.height !== b.height) throw new MultiplaneError('internal_error', 'Sample dimensions differ.');
|
|
67
|
+
let maximum = 0;
|
|
68
|
+
const channels = alpha ? 4 : 3;
|
|
69
|
+
const value = (image, p, c) => image.data[p + c] * (alpha && c < 3 ? image.data[p + 3] / 255 : 1);
|
|
70
|
+
for (let y = 0; y < a.height; y += 8) for (let x = 0; x < a.width; x += 8) {
|
|
71
|
+
const right = Math.min(x + 8, a.width), bottom = Math.min(y + 8, a.height);
|
|
72
|
+
let changed = false;
|
|
73
|
+
for (let yy = y; yy < bottom && !changed; yy++) for (let xx = x; xx < right && !changed; xx++) {
|
|
74
|
+
const p = (yy * a.width + xx) * 4;
|
|
75
|
+
for (let c = 0; c < channels; c++) if (value(a, p, c) !== value(b, p, c)) { changed = true; break; }
|
|
76
|
+
}
|
|
77
|
+
if (!changed) continue;
|
|
78
|
+
const sums = [0, 0, 0, 0], edgesA = [0, 0, 0, 0], edgesB = [0, 0, 0, 0];
|
|
79
|
+
for (let yy = y; yy < bottom; yy++) for (let xx = x; xx < right; xx++) {
|
|
80
|
+
const p = (yy * a.width + xx) * 4;
|
|
81
|
+
for (let c = 0; c < channels; c++) {
|
|
82
|
+
const av = value(a, p, c), bv = value(b, p, c);
|
|
83
|
+
sums[c] += Math.abs(av - bv);
|
|
84
|
+
if (xx > 0) {
|
|
85
|
+
edgesA[c] += Math.abs(av - value(a, p - 4, c));
|
|
86
|
+
edgesB[c] += Math.abs(bv - value(b, p - 4, c));
|
|
87
|
+
}
|
|
88
|
+
if (yy > 0) {
|
|
89
|
+
edgesA[c] += Math.abs(av - value(a, p - a.width * 4, c));
|
|
90
|
+
edgesB[c] += Math.abs(bv - value(b, p - b.width * 4, c));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// A byte-scale floor bounds division in flat or nearly flat blocks.
|
|
95
|
+
for (let c = 0; c < channels; c++) maximum = Math.max(maximum, sums[c] / Math.max(16, edgesA[c], edgesB[c]));
|
|
96
|
+
if (maximum > stopAbove) return maximum;
|
|
97
|
+
}
|
|
98
|
+
return maximum;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function blurSampleReport(histogram) {
|
|
102
|
+
const entries = Object.entries(histogram).map(([n, count]) => [Number(n), count]).filter(([, count]) => count > 0).sort((a, b) => a[0] - b[0]);
|
|
103
|
+
const frames = entries.reduce((sum, [, count]) => sum + count, 0);
|
|
104
|
+
return { min: entries[0]?.[0] ?? 0, max: entries.at(-1)?.[0] ?? 0,
|
|
105
|
+
mean: frames ? entries.reduce((sum, [n, count]) => sum + n * count, 0) / frames : 0, histogram: Object.fromEntries(entries) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function sampleTimes(t, fps, duration, cuts, { motionBlur, shutter }) {
|
|
109
|
+
let lo = 0, hi = duration;
|
|
110
|
+
for (const c of cuts) {
|
|
111
|
+
if (t >= c) lo = Math.max(lo, c);
|
|
112
|
+
else hi = Math.min(hi, Math.max(t, c - 1e-6));
|
|
113
|
+
}
|
|
114
|
+
return Array.from({ length: motionBlur }, (_, i) => Math.min(hi, Math.max(lo, t + ((i + 0.5) / motionBlur - 0.5) * shutter / fps)));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Work backwards from the final midpoint grid. Mirrored pairs keep the
|
|
118
|
+
// centroid at zero; each smaller level chooses evenly spaced remaining pairs.
|
|
119
|
+
export function nestedSampleGrids(t, fps, duration, cuts, post) {
|
|
120
|
+
const counts = [post.motionBlur];
|
|
121
|
+
while (counts.at(-1) < post.maxMotionBlur) counts.push(Math.min(post.maxMotionBlur, counts.at(-1) * 2));
|
|
122
|
+
const maximum = counts.at(-1);
|
|
123
|
+
let pairs = Array.from({ length: Math.floor(maximum / 2) }, (_, i) => (i + 0.5) / maximum - 0.5);
|
|
124
|
+
const grids = new Map();
|
|
125
|
+
for (const count of [...counts].reverse()) {
|
|
126
|
+
const size = Math.floor(count / 2);
|
|
127
|
+
let first = 0;
|
|
128
|
+
pairs = Array.from({ length: size }, (_, i) => {
|
|
129
|
+
const target = (i + 0.5) / count - 0.5;
|
|
130
|
+
let best = first;
|
|
131
|
+
for (let j = first + 1; j <= pairs.length - (size - i); j++) {
|
|
132
|
+
if (Math.abs(pairs[j] - target) < Math.abs(pairs[best] - target)) best = j;
|
|
133
|
+
}
|
|
134
|
+
first = best + 1;
|
|
135
|
+
return pairs[best];
|
|
136
|
+
});
|
|
137
|
+
const offsets = [...pairs, ...(count % 2 ? [0] : []), ...pairs.map(x => -x)].sort((a, b) => a - b);
|
|
138
|
+
// Reuse the existing clipping rules, including the cut epsilon.
|
|
139
|
+
const bounds = sampleTimes(t, fps, duration, cuts, { motionBlur: 2, shutter: post.shutter * 2 });
|
|
140
|
+
grids.set(count, offsets.map(x => Math.min(bounds[1], Math.max(bounds[0], t + x * post.shutter / fps))));
|
|
141
|
+
}
|
|
142
|
+
return grids;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const linear = Float64Array.from({ length: 256 }, (_, i) => {
|
|
146
|
+
const v = i / 255;
|
|
147
|
+
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
148
|
+
});
|
|
149
|
+
const transfer = v => Math.round(255 * (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055));
|
|
150
|
+
const transferTable = Uint8Array.from({ length: 65537 }, (_, i) => transfer(i / 65536));
|
|
151
|
+
export function linearToByte(v) {
|
|
152
|
+
const index = Math.floor(v * 65536), value = transferTable[index];
|
|
153
|
+
return value === transferTable[index + 1] ? value : transfer(v);
|
|
154
|
+
}
|
|
155
|
+
export function averageLinear(images) {
|
|
156
|
+
const { width, height } = images[0], data = Buffer.alloc(images[0].data.length);
|
|
157
|
+
if (images.every(image => image.data.byteOffset % 4 === 0)) {
|
|
158
|
+
const words = images.map(image => new Uint32Array(image.data.buffer, image.data.byteOffset, image.data.length / 4));
|
|
159
|
+
const output = new Uint32Array(data.buffer, data.byteOffset, data.length / 4);
|
|
160
|
+
for (let pixel = 0; pixel < output.length; pixel++) {
|
|
161
|
+
const first = words[0][pixel];
|
|
162
|
+
let equal = true;
|
|
163
|
+
for (let i = 1; i < words.length; i++) if (words[i][pixel] !== first) { equal = false; break; }
|
|
164
|
+
if (equal) { output[pixel] = first; continue; }
|
|
165
|
+
for (let c = 0; c < 4; c++) {
|
|
166
|
+
const p = pixel * 4 + c;
|
|
167
|
+
let sum = 0;
|
|
168
|
+
for (const image of images) sum += c === 3 ? image.data[p] : linear[image.data[p]];
|
|
169
|
+
const v = sum / images.length;
|
|
170
|
+
data[p] = c === 3 ? Math.round(v) : linearToByte(v);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { width, height, data };
|
|
174
|
+
}
|
|
175
|
+
for (let p = 0; p < data.length; p++) {
|
|
176
|
+
let sum = 0;
|
|
177
|
+
for (const image of images) sum += p % 4 === 3 ? image.data[p] : linear[image.data[p]];
|
|
178
|
+
const v = sum / images.length;
|
|
179
|
+
data[p] = p % 4 === 3 ? Math.round(v) : linearToByte(v);
|
|
180
|
+
}
|
|
181
|
+
return { width, height, data };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const grainTables = new Map();
|
|
185
|
+
function grainTable(grain) {
|
|
186
|
+
if (!(grain > 0 && grain <= 128)) return null;
|
|
187
|
+
if (!grainTables.has(grain)) {
|
|
188
|
+
if (grainTables.size === 4) grainTables.delete(grainTables.keys().next().value);
|
|
189
|
+
grainTables.set(grain, Int16Array.from({ length: 65537 }, (_, i) => Math.round((i / 65536 - 0.5) * Math.sqrt(12) * grain)));
|
|
190
|
+
}
|
|
191
|
+
return grainTables.get(grain);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function addGrain(image, grain, frame) {
|
|
195
|
+
const data = Buffer.from(image.data);
|
|
196
|
+
const table = grainTable(grain);
|
|
197
|
+
let state = (frame >>> 0) ^ 0x9e3779b9;
|
|
198
|
+
for (let p = 0; p < data.length; p += 4) {
|
|
199
|
+
state = (state + 0x6d2b79f5) | 0;
|
|
200
|
+
let r = Math.imul(state ^ (state >>> 15), 1 | state);
|
|
201
|
+
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
|
|
202
|
+
const value = (r ^ (r >>> 14)) >>> 0, index = value >>> 16;
|
|
203
|
+
const offset = table && table[index] === table[index + 1] ? table[index]
|
|
204
|
+
: Math.round((value / 4294967296 - 0.5) * Math.sqrt(12) * grain);
|
|
205
|
+
data[p] = Math.max(0, Math.min(255, data[p] + offset));
|
|
206
|
+
data[p + 1] = Math.max(0, Math.min(255, data[p + 1] + offset));
|
|
207
|
+
data[p + 2] = Math.max(0, Math.min(255, data[p + 2] + offset));
|
|
208
|
+
}
|
|
209
|
+
return { ...image, data };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function createPostCapture(capture, options = {}, fps = capture.fps) {
|
|
213
|
+
const alpha = options.alpha ?? capture.alpha ?? capture.post?.alpha ?? false;
|
|
214
|
+
const average = alpha ? averageAlpha : averageLinear;
|
|
215
|
+
const sizes = resolveScale(options);
|
|
216
|
+
validateScale(capture.width, capture.height, sizes.scale);
|
|
217
|
+
validateScale(capture.width, capture.height, sizes.renderScale, '--render-scale');
|
|
218
|
+
const adaptive = number(options['adaptive-blur'] ?? DEFAULT_ADAPTIVE_BLUR, '--adaptive-blur');
|
|
219
|
+
const post = resolvePost(options, capture.post), stats = { samples: 0, staticFrames: 0, adaptiveFrames: 0, hookFrames: 0, reusedDecodes: 0, frames: 0, observeMs: 0, auditMs: 0, captureMs: 0, decodeMs: 0, averageMs: 0, grainMs: 0, pngMs: 0, resizeMs: 0 };
|
|
220
|
+
const timed = (key, fn) => { const start = performance.now(); const value = fn(); stats[key] += performance.now() - start; return value; };
|
|
221
|
+
let decodedCache, previousRaw;
|
|
222
|
+
const state = reuseState(options);
|
|
223
|
+
const enabled = reuseEnabled(options) && !!capture.observe;
|
|
224
|
+
const decode = async (png, reuse = false) => {
|
|
225
|
+
if (reuse && decodedCache?.png.equals(png)) { stats.reusedDecodes++; return decodedCache.image; }
|
|
226
|
+
const start = performance.now();
|
|
227
|
+
let image = options.decoder === 'js' ? decodePNG(png) : await decodeNativePNG(png);
|
|
228
|
+
stats.decodeMs += performance.now() - start;
|
|
229
|
+
if (alpha && sizes.renderScale < 1) {
|
|
230
|
+
const resizeStart = performance.now();
|
|
231
|
+
image = resizeAlpha(image, capture.width * sizes.renderScale, capture.height * sizes.renderScale, sizes.upscale);
|
|
232
|
+
stats.resizeMs += performance.now() - resizeStart;
|
|
233
|
+
}
|
|
234
|
+
if (reuse) decodedCache = { png: Buffer.from(png), image };
|
|
235
|
+
return image;
|
|
236
|
+
};
|
|
237
|
+
const api = {
|
|
238
|
+
post, stats,
|
|
239
|
+
get staticReuse() { return reuseReport(options); },
|
|
240
|
+
progress: () => `Samples: ${stats.samples}; static: ${stats.staticFrames}; adaptive: ${stats.adaptiveFrames}; hooks: ${stats.hookFrames}`,
|
|
241
|
+
async frame(t, frame = Math.round(t * fps), audit = false) {
|
|
242
|
+
let motionBlur = post.motionBlur;
|
|
243
|
+
const hooked = motionBlur > 1 && typeof capture.blurAt === 'function';
|
|
244
|
+
if (hooked) {
|
|
245
|
+
const requested = await capture.blurAt(t);
|
|
246
|
+
if (typeof requested !== 'number' || !Number.isSafeInteger(requested) || requested < 1) throw new MultiplaneError('shot_contract_invalid', 'SHOT.blurAt(t) must return a positive integer sample count.');
|
|
247
|
+
motionBlur = Math.min(requested, motionBlur);
|
|
248
|
+
stats.hookFrames++;
|
|
249
|
+
}
|
|
250
|
+
// Explicit shot hooks retain their existing authority over sampling.
|
|
251
|
+
// TODO(design): Document that blurAt bypasses upward adaptation.
|
|
252
|
+
const upward = !hooked && motionBlur > 1 && post.maxMotionBlur > motionBlur;
|
|
253
|
+
const initialMotionBlur = motionBlur;
|
|
254
|
+
const grids = upward ? nestedSampleGrids(t, fps, capture.duration, capture.cuts, post) : null;
|
|
255
|
+
const endpoints = sampleTimes(t, fps, capture.duration, capture.cuts, { motionBlur: 2, shutter: post.shutter * 2 });
|
|
256
|
+
const times = sampleTimes(t, fps, capture.duration, capture.cuts, { ...post, motionBlur: upward ? Math.max(2, motionBlur) : motionBlur });
|
|
257
|
+
let candidate = false, uniform = motionBlur === 1, observedTime;
|
|
258
|
+
if (enabled && !Atomics.load(state, 0)) {
|
|
259
|
+
const probes = [...new Set(motionBlur === 1 && !upward ? [t] : [...endpoints, ...times, t])].sort((a, b) => a - b);
|
|
260
|
+
const changes = [];
|
|
261
|
+
const observeStart = performance.now();
|
|
262
|
+
for (const time of probes) {
|
|
263
|
+
changes.push(await capture.observe(time, { motionBlur }));
|
|
264
|
+
observedTime = time;
|
|
265
|
+
if (changes.at(-1)) break;
|
|
266
|
+
}
|
|
267
|
+
stats.observeMs += performance.now() - observeStart;
|
|
268
|
+
uniform = !changes.some(Boolean);
|
|
269
|
+
const nearCut = capture.cuts.some(c => Math.abs(t - c) <= 1 / fps + 1e-9 || (previousRaw && c > previousRaw.t && c <= t));
|
|
270
|
+
candidate = !!previousRaw && previousRaw.uniform && previousRaw.motionBlur === motionBlur
|
|
271
|
+
&& frame === previousRaw.frame + 1 && t > previousRaw.t && !nearCut && !changes.some(Boolean);
|
|
272
|
+
}
|
|
273
|
+
const verifyReuse = candidate && (audit || previousRaw.run === 0 || frame % REUSE_INTERVAL === 0);
|
|
274
|
+
const cache = new Map();
|
|
275
|
+
let contextUsed = false;
|
|
276
|
+
const sample = async (time, reuse = false) => {
|
|
277
|
+
if (!cache.has(time)) {
|
|
278
|
+
const start = performance.now();
|
|
279
|
+
const observed = time === observedTime;
|
|
280
|
+
observedTime = undefined;
|
|
281
|
+
const png = await capture.frame(time, { motionBlur, observed });
|
|
282
|
+
contextUsed ||= capture.sampleContextUsed === true;
|
|
283
|
+
stats.captureMs += performance.now() - start;
|
|
284
|
+
stats.samples++;
|
|
285
|
+
cache.set(time, { png, image: post.motionBlur > 1 || upward || post.grain > 0 ? await decode(png, reuse) : null });
|
|
286
|
+
}
|
|
287
|
+
return cache.get(time);
|
|
288
|
+
};
|
|
289
|
+
const sampleMany = async (sequence, reuse = false) => {
|
|
290
|
+
const missing = [...new Set(sequence)].filter(time => !cache.has(time));
|
|
291
|
+
if (!missing.length) return;
|
|
292
|
+
if (!capture.beginFrame || options.pipeline === false) {
|
|
293
|
+
for (const time of missing) await sample(time, reuse);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const begin = async time => {
|
|
297
|
+
const start = performance.now(), observed = time === observedTime;
|
|
298
|
+
observedTime = undefined;
|
|
299
|
+
const pending = await capture.beginFrame(time, { motionBlur, observed });
|
|
300
|
+
contextUsed ||= capture.sampleContextUsed === true;
|
|
301
|
+
const png = pending.png.then(buffer => { stats.captureMs += performance.now() - start; return buffer; });
|
|
302
|
+
png.catch(() => {});
|
|
303
|
+
return { png };
|
|
304
|
+
};
|
|
305
|
+
let pending = await begin(missing[0]);
|
|
306
|
+
for (let i = 0; i < missing.length; i++) {
|
|
307
|
+
const png = await pending.png;
|
|
308
|
+
const next = i + 1 < missing.length ? await begin(missing[i + 1]) : null;
|
|
309
|
+
stats.samples++;
|
|
310
|
+
cache.set(missing[i], { png, image: await decode(png, reuse) });
|
|
311
|
+
pending = next;
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
const auditStart = performance.now();
|
|
315
|
+
let png, result, usedSamples = motionBlur;
|
|
316
|
+
if (candidate && !verifyReuse) { ({ png, image: result, usedSamples } = previousRaw); }
|
|
317
|
+
else if (upward) {
|
|
318
|
+
// Calibrated in capture raster pixels: about 2px for standard, 1px for high.
|
|
319
|
+
const threshold = resolveQuality(options) === 'high' ? 1 : 2;
|
|
320
|
+
await sampleMany([...endpoints, t], true);
|
|
321
|
+
const centerProbe = cache.get(t);
|
|
322
|
+
const still = endpoints.every(time => cache.get(time).image.data.equals(centerProbe.image.data));
|
|
323
|
+
let legacy = contextUsed;
|
|
324
|
+
let grid = legacy ? times : grids.get(motionBlur);
|
|
325
|
+
if (still) {
|
|
326
|
+
png = centerProbe.png; result = centerProbe.image; usedSamples = 1;
|
|
327
|
+
stats.staticFrames++; stats.adaptiveFrames++;
|
|
328
|
+
} else {
|
|
329
|
+
if (legacy) cache.clear();
|
|
330
|
+
while (true) {
|
|
331
|
+
await sampleMany([...grid, t], true);
|
|
332
|
+
// A conditional read discovered after the probes restarts the frame
|
|
333
|
+
// on the old grids. No image from a different sample context survives.
|
|
334
|
+
if (contextUsed && !legacy) {
|
|
335
|
+
legacy = true; motionBlur = initialMotionBlur;
|
|
336
|
+
cache.clear(); observedTime = undefined; grid = times; continue;
|
|
337
|
+
}
|
|
338
|
+
const images = grid.map(time => cache.get(time).image), center = cache.get(t);
|
|
339
|
+
const identical = images.every(image => image.data.equals(center.image.data));
|
|
340
|
+
if (identical) {
|
|
341
|
+
png = center.png; result = center.image; usedSamples = 1;
|
|
342
|
+
stats.staticFrames++; stats.adaptiveFrames++; break;
|
|
343
|
+
}
|
|
344
|
+
let difference = 0;
|
|
345
|
+
for (let i = 1; i < images.length && difference <= threshold; i++) difference = Math.max(difference, localMotionDifference(images[i - 1], images[i], alpha, threshold));
|
|
346
|
+
if (difference <= threshold || motionBlur >= post.maxMotionBlur) {
|
|
347
|
+
usedSamples = motionBlur;
|
|
348
|
+
result = timed('averageMs', () => average(images)); break;
|
|
349
|
+
}
|
|
350
|
+
motionBlur = Math.min(post.maxMotionBlur, motionBlur * 2);
|
|
351
|
+
if (legacy) cache.clear();
|
|
352
|
+
observedTime = undefined;
|
|
353
|
+
grid = legacy ? sampleTimes(t, fps, capture.duration, capture.cuts, { ...post, motionBlur }) : grids.get(motionBlur);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
} else if (motionBlur === 1) ({ png, image: result } = await sample(t));
|
|
357
|
+
else if (hooked) {
|
|
358
|
+
await sampleMany(times);
|
|
359
|
+
result = timed('averageMs', () => average(times.map(time => cache.get(time).image)));
|
|
360
|
+
} else if (adaptive > 0) {
|
|
361
|
+
await sampleMany([times[0], times.at(-1)], true);
|
|
362
|
+
const first = cache.get(times[0]), last = cache.get(times.at(-1));
|
|
363
|
+
const identical = first.image.data === last.image.data || first.image.data.equals(last.image.data);
|
|
364
|
+
if (identical || (!alpha && imageDifference(first.image, last.image) < adaptive)) {
|
|
365
|
+
png = first.png; result = first.image; usedSamples = 1; stats.adaptiveFrames++;
|
|
366
|
+
if (identical) stats.staticFrames++;
|
|
367
|
+
} else {
|
|
368
|
+
await sampleMany(times);
|
|
369
|
+
result = timed('averageMs', () => average(times.map(time => cache.get(time).image)));
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
await sampleMany([times[0], times.at(-1), t]);
|
|
373
|
+
const first = cache.get(times[0]), last = cache.get(times.at(-1)), center = cache.get(t);
|
|
374
|
+
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++;
|
|
376
|
+
} else {
|
|
377
|
+
await sampleMany(times);
|
|
378
|
+
const images = times.map(time => cache.get(time).image);
|
|
379
|
+
result = timed('averageMs', () => average(images));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (verifyReuse) {
|
|
383
|
+
stats.auditMs += performance.now() - auditStart;
|
|
384
|
+
Atomics.add(state, 2, 1);
|
|
385
|
+
const actual = result ??= await decode(png), expected = previousRaw.image ??= await decode(previousRaw.png);
|
|
386
|
+
if (classifyPixelDifference(pixelDifference(expected, actual), options.raster) === 'different') {
|
|
387
|
+
if (Atomics.compareExchange(state, 0, 0, 1) === 0) {
|
|
388
|
+
Atomics.store(state, 3, frame);
|
|
389
|
+
options.onWarning?.({ code: 'static_reuse_disabled', message: 'Static reuse disabled after a pixel mismatch; frames will be recaptured.', details: { frame, t } });
|
|
390
|
+
}
|
|
391
|
+
candidate = false;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (candidate && !verifyReuse) Atomics.add(state, 1, 1);
|
|
395
|
+
// Keep a decoded raw reference as well; never retain a transferable output buffer.
|
|
396
|
+
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 };
|
|
398
|
+
// Contributing temporal samples, excluding probes and failed attempts.
|
|
399
|
+
api.lastBlurSamples = usedSamples;
|
|
400
|
+
stats.frames++;
|
|
401
|
+
if (sizes.renderScale !== sizes.scale) {
|
|
402
|
+
result ??= await decode(png);
|
|
403
|
+
const start = performance.now();
|
|
404
|
+
result = await (alpha ? resizeAlpha : resizeImage)(result, capture.width * sizes.scale, capture.height * sizes.scale, sizes.upscale);
|
|
405
|
+
stats.resizeMs += performance.now() - start;
|
|
406
|
+
png = undefined;
|
|
407
|
+
}
|
|
408
|
+
if (post.grain > 0) result = timed('grainMs', () => addGrain(result, post.grain, frame));
|
|
409
|
+
// Grain changes straight visible RGB only; never alpha or hidden output colors.
|
|
410
|
+
if (alpha) { result = clearHiddenColors(result ?? await decode(png)); png = undefined; }
|
|
411
|
+
if (options.verify) {
|
|
412
|
+
const image = result ??= await decode(png);
|
|
413
|
+
if (image.width !== capture.width * sizes.scale || image.height !== capture.height * sizes.scale) throw new MultiplaneError('internal_error', 'Pixel verification dimensions do not match output.');
|
|
414
|
+
api.lastHash = createHash('sha256').update(image.data).digest('hex');
|
|
415
|
+
}
|
|
416
|
+
if (options.transport === 'raw') {
|
|
417
|
+
const image = result ?? await decode(png);
|
|
418
|
+
return image === decodedCache?.image || image === previousRaw?.image ? Buffer.from(image.data) : image.data;
|
|
419
|
+
}
|
|
420
|
+
if (post.grain > 0) return timed('pngMs', () => encodePNG(result));
|
|
421
|
+
return png ?? timed('pngMs', () => encodePNG(result));
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
if (capture.retryFrame) {
|
|
425
|
+
const frame = api.frame.bind(api);
|
|
426
|
+
api.frame = (t, index, audit) => capture.retryFrame(t, async () => {
|
|
427
|
+
const before = { ...stats };
|
|
428
|
+
try { return await frame(t, index, audit); }
|
|
429
|
+
catch (error) { Object.assign(stats, before); decodedCache = previousRaw = undefined; throw error; }
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
return api;
|
|
433
|
+
}
|
package/src/preview.mjs
ADDED
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
const execute = (file, args, options) => new Promise((resolve, reject) => {
|
|
3
|
+
execFile(file, args, options, (error, stdout) => error ? reject(error) : resolve({ stdout }));
|
|
4
|
+
});
|
|
5
|
+
// Process creation time, never the app publication time. Failure is fail-closed.
|
|
6
|
+
export async function processCreatedAt(pid) {
|
|
7
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
8
|
+
try {
|
|
9
|
+
process.kill(pid, 0);
|
|
10
|
+
const { stdout } = process.platform === 'win32'
|
|
11
|
+
? await execute('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
12
|
+
`(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o')`], { windowsHide: true, timeout: 5000 })
|
|
13
|
+
: await execute('ps', ['-p', String(pid), '-o', 'lstart='], { env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' }, timeout: 5000 });
|
|
14
|
+
const time = Date.parse(stdout.trim() + (process.platform === 'win32' ? '' : ' UTC'));
|
|
15
|
+
return Number.isFinite(time) ? new Date(time).toISOString() : null;
|
|
16
|
+
} catch { return null; }
|
|
17
|
+
}
|
package/src/process.mjs
ADDED
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { reuseState, reuseReport, reuseEnabled, REUSE_INTERVAL } from './static-reuse.mjs';
|
|
2
|
+
import { recoveryBudget } from './browser-recovery.mjs';
|
|
3
|
+
import { MultiplaneError } from './errors.mjs';
|
|
4
|
+
import { availableParallelism, freemem, totalmem } from 'node:os';
|
|
5
|
+
import { Worker } from 'node:worker_threads';
|
|
6
|
+
import { blurSampleReport } from './post.mjs';
|
|
7
|
+
|
|
8
|
+
export function defaultWorkers(scale = 1, hardware = {}) {
|
|
9
|
+
const cores = hardware.cores ?? availableParallelism();
|
|
10
|
+
const free = Math.min(hardware.free ?? freemem(), hardware.total ?? totalmem());
|
|
11
|
+
const workerBytes = (0.25 + 0.1875 * scale * scale) * 1024 ** 3;
|
|
12
|
+
return Math.max(1, Math.min(12, Math.floor(cores / 2), Math.floor((free - 2 * 1024 ** 3) / workerBytes)));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createRenderPool(shot, options, fps, workers, size, onWarning) {
|
|
16
|
+
options = { ...options, _browserRecovery: options._browserRecovery ?? recoveryBudget() };
|
|
17
|
+
reuseState(options);
|
|
18
|
+
const slots = [], stats = { frames: 0, samples: 0, staticFrames: 0 };
|
|
19
|
+
const histogram = {};
|
|
20
|
+
let failure;
|
|
21
|
+
for (let i = 0; i < workers; i++) {
|
|
22
|
+
const worker = new Worker(new URL('./render-worker.mjs', import.meta.url), { workerData: { shot, options, fps, size } });
|
|
23
|
+
const slot = { worker, pending: null, closed: false };
|
|
24
|
+
const fail = error => {
|
|
25
|
+
failure ??= error;
|
|
26
|
+
for (const s of slots) { s.pending?.reject(error); s.pending = null; }
|
|
27
|
+
};
|
|
28
|
+
slot.ready = new Promise((resolve, reject) => { slot.pending = { resolve, reject }; });
|
|
29
|
+
slot.ready.catch(() => {});
|
|
30
|
+
worker.on('message', message => {
|
|
31
|
+
if (message.type === 'warning') { onWarning?.(message.warning); return; }
|
|
32
|
+
if (message.type === 'failure') { fail(new MultiplaneError(message.code ?? 'worker_crashed', message.error, { details: message.details, hint: message.hint })); return; }
|
|
33
|
+
const pending = slot.pending; slot.pending = null;
|
|
34
|
+
pending?.resolve(message);
|
|
35
|
+
});
|
|
36
|
+
worker.on('error', error => fail(new MultiplaneError('worker_crashed', undefined, { cause: error })));
|
|
37
|
+
slot.exited = new Promise(resolve => worker.on('exit', code => {
|
|
38
|
+
slot.closed = true;
|
|
39
|
+
if (slot.pending || code !== 0) fail(new MultiplaneError('worker_crashed', `Rendering worker exited early: ${code}`));
|
|
40
|
+
resolve();
|
|
41
|
+
}));
|
|
42
|
+
slots.push(slot);
|
|
43
|
+
}
|
|
44
|
+
const api = {
|
|
45
|
+
stats,
|
|
46
|
+
get blurSamples() { return blurSampleReport(histogram); },
|
|
47
|
+
get staticReuse() { return reuseReport(options); },
|
|
48
|
+
progress: () => `Samples: ${stats.samples}; static: ${stats.staticFrames}; adaptive: ${stats.adaptiveFrames ?? 0}; hooks: ${stats.hookFrames ?? 0}`,
|
|
49
|
+
async *frames(first, end) {
|
|
50
|
+
await Promise.all(slots.map(s => s.ready));
|
|
51
|
+
const queue = [], chunk = reuseEnabled(options) ? Math.min(REUSE_INTERVAL + 1, Math.ceil((end - first) / slots.length)) : options.transport === 'raw' ? 1 : 4;
|
|
52
|
+
let next = first;
|
|
53
|
+
const schedule = slot => {
|
|
54
|
+
if (next >= end) return;
|
|
55
|
+
if (failure) throw failure;
|
|
56
|
+
const begin = next;
|
|
57
|
+
next = Math.min(end, next + chunk, reuseEnabled(options) ? (Math.floor(begin / REUSE_INTERVAL) + 1) * REUSE_INTERVAL + 1 : end);
|
|
58
|
+
const promise = new Promise((resolve, reject) => { slot.pending = { resolve, reject }; });
|
|
59
|
+
promise.catch(() => {});
|
|
60
|
+
slot.worker.postMessage({ type: 'render', first: begin, end: next });
|
|
61
|
+
queue.push({ slot, promise });
|
|
62
|
+
};
|
|
63
|
+
for (const slot of slots) schedule(slot);
|
|
64
|
+
while (queue.length) {
|
|
65
|
+
const { slot, promise } = queue.shift();
|
|
66
|
+
const batch = await promise;
|
|
67
|
+
if (failure) throw failure;
|
|
68
|
+
for (const [key, value] of Object.entries(batch.stats)) stats[key] = (stats[key] ?? 0) + value;
|
|
69
|
+
if (options.transport === 'raw') schedule(slot);
|
|
70
|
+
// Worker audits have already replaced invalid frames in this batch.
|
|
71
|
+
for (const [i, png] of batch.frames.entries()) {
|
|
72
|
+
const count = batch.blurSamples[i];
|
|
73
|
+
histogram[count] = (histogram[count] ?? 0) + 1;
|
|
74
|
+
yield { frame: batch.first + i, png, hash: batch.hashes?.[i], blurSamples: count };
|
|
75
|
+
}
|
|
76
|
+
if (options.transport !== 'raw') schedule(slot);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
async close() {
|
|
80
|
+
for (const slot of slots) if (!slot.closed) slot.worker.postMessage({ type: 'close' });
|
|
81
|
+
await Promise.all(slots.map(s => s.exited));
|
|
82
|
+
},
|
|
83
|
+
async cancel() {
|
|
84
|
+
for (const slot of slots) if (!slot.closed) slot.worker.postMessage({ type: 'cancel' });
|
|
85
|
+
await Promise.all(slots.map(s => s.exited));
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
const frames = api.frames.bind(api);
|
|
89
|
+
api.frames = async function* (first, end, onCaptured) {
|
|
90
|
+
for await (const item of frames(first, end)) { await onCaptured?.(item); yield item; }
|
|
91
|
+
};
|
|
92
|
+
return api;
|
|
93
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { verifiedFrames } from './static-reuse.mjs';
|
|
2
|
+
import { MultiplaneError, asError } from './errors.mjs';
|
|
3
|
+
import { parentPort, workerData, isMarkedAsUntransferable } from 'node:worker_threads';
|
|
4
|
+
import { openShot } from './capture.mjs';
|
|
5
|
+
import { createPostCapture, resolveScale } from './post.mjs';
|
|
6
|
+
|
|
7
|
+
let capture, closing = false, running = false;
|
|
8
|
+
const sendFailure = error => {
|
|
9
|
+
const failure = asError(error);
|
|
10
|
+
parentPort.postMessage({ type: 'failure', error: failure.message, code: failure.code, details: failure.details, hint: failure.hint });
|
|
11
|
+
};
|
|
12
|
+
async function close() {
|
|
13
|
+
closing = true;
|
|
14
|
+
if (running) return;
|
|
15
|
+
await capture?.close();
|
|
16
|
+
parentPort.close();
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
capture = await openShot(workerData.shot, resolveScale(workerData.options).renderScale, { ...workerData.options, onWarning: warning => parentPort.postMessage({ type: 'warning', warning }), check: () => { if (closing) throw new MultiplaneError('cancelled'); } });
|
|
20
|
+
if (workerData.size && (capture.width !== workerData.size.width || capture.height !== workerData.size.height)) throw new MultiplaneError('internal_error', 'Worker stage dimensions differ from the main process.');
|
|
21
|
+
const processed = createPostCapture(capture, { ...workerData.options, onWarning: warning => parentPort.postMessage({ type: 'warning', warning }) }, workerData.fps);
|
|
22
|
+
parentPort.on('message', async message => {
|
|
23
|
+
if (message.type === 'cancel') { closing = true; await capture?.close(); if (!running) parentPort.close(); return; }
|
|
24
|
+
if (message.type === 'close') { await close(); return; }
|
|
25
|
+
if (closing) return;
|
|
26
|
+
running = true;
|
|
27
|
+
try {
|
|
28
|
+
const frames = [], hashes = [], blurSamples = [], before = { ...processed.stats };
|
|
29
|
+
const source = async function* (first, end) {
|
|
30
|
+
for (let index = first; index < end && !closing; index++) {
|
|
31
|
+
const frame = message.first + index;
|
|
32
|
+
const png = await processed.frame(frame / workerData.fps, frame, index === end - 1);
|
|
33
|
+
yield { png, hash: processed.lastHash, blurSamples: processed.lastBlurSamples };
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
// RAW post output already owns an independent buffer. Keep it until the
|
|
37
|
+
// interval audit, then transfer it without a second full-frame copy.
|
|
38
|
+
for await (const item of verifiedFrames(source, workerData.options, message.end - message.first, { owned: workerData.options.transport === 'raw' })) {
|
|
39
|
+
const result = item.png;
|
|
40
|
+
const png = result.byteOffset === 0 && result.byteLength === result.buffer.byteLength && !isMarkedAsUntransferable(result.buffer)
|
|
41
|
+
? new Uint8Array(result.buffer) : Uint8Array.from(result);
|
|
42
|
+
frames.push(png);
|
|
43
|
+
blurSamples.push(item.blurSamples);
|
|
44
|
+
if (workerData.options.verify) hashes.push(item.hash);
|
|
45
|
+
}
|
|
46
|
+
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));
|
|
48
|
+
} catch (error) {
|
|
49
|
+
sendFailure(error); closing = true;
|
|
50
|
+
} finally {
|
|
51
|
+
running = false;
|
|
52
|
+
if (closing) await close();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
parentPort.postMessage({ type: 'ready' });
|
|
56
|
+
} catch (error) {
|
|
57
|
+
sendFailure(error);
|
|
58
|
+
await close();
|
|
59
|
+
}
|