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
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { invalidOption } from './errors.mjs';
|
|
2
|
+
|
|
3
|
+
export function reuseEnabled(options = {}, fallback = true) {
|
|
4
|
+
for (const key of ['static-reuse', 'no-static-reuse']) {
|
|
5
|
+
if (options[key] !== undefined && typeof options[key] !== 'boolean') throw invalidOption('--' + key, '--' + key + ' must be a boolean.');
|
|
6
|
+
}
|
|
7
|
+
if (options['static-reuse'] && options['no-static-reuse']) throw invalidOption('--static-reuse', 'Static reuse switches are mutually exclusive.');
|
|
8
|
+
return !options['no-static-reuse'] && (options['static-reuse'] ?? fallback);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Shared by all workers, including browser replacements. Only the first mismatch warns.
|
|
12
|
+
export const observationReasons = ['mutation', 'drawing_or_setter', 'unobservable_api', 'hook_replaced', 'dynamic_element', 'editable_focus', 'animation_or_font', 'image', 'external_style', 'stylesheet'];
|
|
13
|
+
export function reuseState(options) {
|
|
14
|
+
return options._staticReuseState ??= new Int32Array(new SharedArrayBuffer((4 + observationReasons.length) * 4));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function reuseReport(options) {
|
|
18
|
+
const state = reuseState(options);
|
|
19
|
+
return { enabled: reuseEnabled(options) && !Atomics.load(state, 0),
|
|
20
|
+
reusedFrames: Atomics.load(state, 0) ? 0 : Atomics.load(state, 1),
|
|
21
|
+
verifiedFrames: Atomics.load(state, 2),
|
|
22
|
+
disabledReason: Atomics.load(state, 0) ? 'pixel_mismatch' : null,
|
|
23
|
+
observationReasons: Object.fromEntries(observationReasons.map((reason, i) => [reason, Atomics.load(state, 4 + i)]).filter(([, count]) => count)) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A bounded interval is published only after its closing audit. A mismatch
|
|
27
|
+
// repairs this interval; already audited intervals need no rollback.
|
|
28
|
+
export const REUSE_INTERVAL = 16;
|
|
29
|
+
export async function* verifiedFrames(source, options, count, { owned = false } = {}) {
|
|
30
|
+
const state = reuseState(options);
|
|
31
|
+
for (let first = 0; first < count;) {
|
|
32
|
+
options.check?.();
|
|
33
|
+
const end = Math.min(count, first + REUSE_INTERVAL + (first === 0 ? 1 : 0));
|
|
34
|
+
if (!reuseEnabled(options) || Atomics.load(state, 0)) {
|
|
35
|
+
yield* source(first, end);
|
|
36
|
+
} else {
|
|
37
|
+
const pending = [];
|
|
38
|
+
for await (const item of source(first, end)) {
|
|
39
|
+
options.check?.();
|
|
40
|
+
pending.push({ ...item, png: owned ? item.png : Buffer.from(item.png) });
|
|
41
|
+
}
|
|
42
|
+
if (Atomics.load(state, 0)) yield* source(first, end);
|
|
43
|
+
else yield* pending;
|
|
44
|
+
}
|
|
45
|
+
first = end;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Runs before any shot script, so saved drawing functions are instrumented too.
|
|
50
|
+
export function installStaticObserver({ platform } = {}) {
|
|
51
|
+
let dirty = true, suspended = false, unsafe = false, styleSignature, stylesDirty = true, stylesUnsafe = false, previousSheets = [], readingStyles = false;
|
|
52
|
+
const exposedDeclarations = new Map();
|
|
53
|
+
let reason = null;
|
|
54
|
+
const mark = () => { if (!suspended) { dirty = true; reason = 'drawing_or_setter'; } };
|
|
55
|
+
const changedRecords = records => records.some(record => record.type === 'attributes'
|
|
56
|
+
? record.oldValue !== record.target.getAttribute(record.attributeName)
|
|
57
|
+
: record.type === 'characterData' ? record.oldValue !== record.target.data : true);
|
|
58
|
+
const mutations = records => {
|
|
59
|
+
if (records.some(record => record.type === 'childList' || record.target.nodeName === 'STYLE' || record.target.nodeName === 'LINK' || record.target.parentNode?.nodeName === 'STYLE')) stylesDirty = true;
|
|
60
|
+
if (changedRecords(records)) { mark(); reason = 'mutation'; }
|
|
61
|
+
};
|
|
62
|
+
const observer = new MutationObserver(mutations);
|
|
63
|
+
observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true, attributeOldValue: true, characterDataOldValue: true });
|
|
64
|
+
const hooks = [];
|
|
65
|
+
const wrapped = new WeakMap();
|
|
66
|
+
const wrap = (proto, name) => {
|
|
67
|
+
if (!proto) return;
|
|
68
|
+
if (wrapped.get(proto)?.has(name)) return;
|
|
69
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, name);
|
|
70
|
+
if (!descriptor || (typeof descriptor.value !== 'function' && !descriptor.set)) return;
|
|
71
|
+
try {
|
|
72
|
+
const replacement = { ...descriptor };
|
|
73
|
+
const style = proto === globalThis.CSSStyleDeclaration?.prototype;
|
|
74
|
+
const stylesheet = ['CSSStyleSheet', 'StyleSheet', 'StylePropertyMap', 'MediaList'].some(type => proto === globalThis[type]?.prototype)
|
|
75
|
+
|| proto === globalThis.CSSRule?.prototype || globalThis.CSSRule?.prototype.isPrototypeOf(proto);
|
|
76
|
+
const sheetEdit = object => {
|
|
77
|
+
if ((style && object.parentRule) || stylesheet) stylesDirty = true;
|
|
78
|
+
};
|
|
79
|
+
if (typeof descriptor.value === 'function') replacement.value = function (...args) {
|
|
80
|
+
const before = style ? this.cssText : undefined;
|
|
81
|
+
const result = descriptor.value.apply(this, args);
|
|
82
|
+
if (!style || before !== this.cssText) { mark(); sheetEdit(this); }
|
|
83
|
+
if (proto === globalThis.CSSStyleSheet?.prototype && name === 'replace') result.then(() => { stylesDirty = true; mark(); }, mark);
|
|
84
|
+
return result;
|
|
85
|
+
};
|
|
86
|
+
if (descriptor.set) replacement.set = function (value) {
|
|
87
|
+
// PutForwards setters mutate a stable native object (rule.style/media).
|
|
88
|
+
const current = () => {
|
|
89
|
+
const result = descriptor.get?.call(this);
|
|
90
|
+
return result?.cssText ?? result?.mediaText ?? result;
|
|
91
|
+
};
|
|
92
|
+
const before = current();
|
|
93
|
+
const result = descriptor.set.call(this, value);
|
|
94
|
+
if (!descriptor.get || before !== current()) { mark(); sheetEdit(this); }
|
|
95
|
+
return result;
|
|
96
|
+
};
|
|
97
|
+
Object.defineProperty(proto, name, replacement);
|
|
98
|
+
if (!wrapped.has(proto)) wrapped.set(proto, new Set());
|
|
99
|
+
wrapped.get(proto).add(name);
|
|
100
|
+
hooks.push(() => {
|
|
101
|
+
const current = Object.getOwnPropertyDescriptor(proto, name);
|
|
102
|
+
return current?.value === replacement.value && current?.set === replacement.set;
|
|
103
|
+
});
|
|
104
|
+
} catch { unsafe = true; }
|
|
105
|
+
};
|
|
106
|
+
for (const type of ['CanvasRenderingContext2D', 'WebGLRenderingContext', 'WebGL2RenderingContext', 'ImageBitmapRenderingContext', 'OffscreenCanvasRenderingContext2D', 'CSSStyleDeclaration', 'CSSStyleSheet', 'StylePropertyMap', 'StyleSheet', 'CSSRule', 'CSSStyleRule', 'CSSGroupingRule', 'CSSConditionRule', 'CSSMediaRule', 'CSSKeyframesRule', 'CSSKeyframeRule', 'MediaList', 'Animation', 'AnimationEffect', 'KeyframeEffect']) {
|
|
107
|
+
const proto = globalThis[type]?.prototype;
|
|
108
|
+
for (const name of Object.getOwnPropertyNames(proto ?? {})) if (name !== 'constructor' && name !== 'getExtension') wrap(proto, name);
|
|
109
|
+
}
|
|
110
|
+
// Named CSS declaration properties are exotic instance setters, not prototype
|
|
111
|
+
// setters. Track only declarations exposed to shot code, including saved live
|
|
112
|
+
// references. Returning the native object preserves Web IDL brand checks.
|
|
113
|
+
for (const type of ['CSSStyleRule', 'CSSKeyframeRule', 'CSSFontFaceRule', 'CSSPageRule', 'CSSPositionTryRule']) {
|
|
114
|
+
const proto = globalThis[type]?.prototype;
|
|
115
|
+
const descriptor = proto && Object.getOwnPropertyDescriptor(proto, 'style');
|
|
116
|
+
if (!descriptor?.get) continue;
|
|
117
|
+
const get = function () {
|
|
118
|
+
const style = descriptor.get.call(this);
|
|
119
|
+
if (!readingStyles && !exposedDeclarations.has(style)) exposedDeclarations.set(style, style.cssText);
|
|
120
|
+
return style;
|
|
121
|
+
};
|
|
122
|
+
try {
|
|
123
|
+
Object.defineProperty(proto, 'style', { ...descriptor, get });
|
|
124
|
+
hooks.push(() => Object.getOwnPropertyDescriptor(proto, 'style')?.get === get);
|
|
125
|
+
} catch { unsafe = true; }
|
|
126
|
+
}
|
|
127
|
+
for (const type of ['Element', 'HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'HTMLSelectElement', 'HTMLOptionElement', 'HTMLCanvasElement', 'SVGElement']) {
|
|
128
|
+
const proto = globalThis[type]?.prototype;
|
|
129
|
+
for (const name of Object.getOwnPropertyNames(proto ?? {})) if (Object.getOwnPropertyDescriptor(proto, name)?.set) wrap(proto, name);
|
|
130
|
+
}
|
|
131
|
+
// SVG exposes live numeric/list objects whose writes need not emit attribute
|
|
132
|
+
// mutation records (for example rect.x.baseVal.value).
|
|
133
|
+
for (const type of Object.getOwnPropertyNames(globalThis).filter(name => /^SVG/.test(name))) {
|
|
134
|
+
const proto = globalThis[type]?.prototype;
|
|
135
|
+
for (const name of Object.getOwnPropertyNames(proto ?? {})) if (name !== 'constructor') wrap(proto, name);
|
|
136
|
+
}
|
|
137
|
+
for (const name of ['scroll', 'scrollTo', 'scrollBy']) wrap(Element.prototype, name);
|
|
138
|
+
wrap(HTMLElement.prototype, 'click');
|
|
139
|
+
for (const type of ['Selection', 'Range']) {
|
|
140
|
+
const proto = globalThis[type]?.prototype;
|
|
141
|
+
for (const name of Object.getOwnPropertyNames(proto ?? {})) if (name !== 'constructor') wrap(proto, name);
|
|
142
|
+
}
|
|
143
|
+
for (const type of ['HTMLInputElement', 'HTMLTextAreaElement', 'HTMLSelectElement', 'HTMLFormElement']) {
|
|
144
|
+
for (const name of ['stepUp', 'stepDown', 'select', 'setRangeText', 'setSelectionRange', 'setCustomValidity', 'reset']) wrap(globalThis[type]?.prototype, name);
|
|
145
|
+
}
|
|
146
|
+
const attach = Element.prototype.attachShadow;
|
|
147
|
+
const attachShadow = function (...args) { unsafe = true; return attach.apply(this, args); };
|
|
148
|
+
Element.prototype.attachShadow = attachShadow;
|
|
149
|
+
hooks.push(() => Element.prototype.attachShadow === attachShadow);
|
|
150
|
+
for (const type of ['HTMLCanvasElement', 'OffscreenCanvas']) {
|
|
151
|
+
const proto = globalThis[type]?.prototype;
|
|
152
|
+
if (!proto) continue;
|
|
153
|
+
const getContext = proto.getContext;
|
|
154
|
+
const replacement = function (...args) {
|
|
155
|
+
if (!['2d', 'webgl', 'webgl2', 'experimental-webgl', 'bitmaprenderer'].includes(args[0])) unsafe = true;
|
|
156
|
+
mark();
|
|
157
|
+
const context = getContext.apply(this, args);
|
|
158
|
+
if (typeof args[0] === 'string' && args[0].includes('webgl') && !context?.getContextAttributes()?.preserveDrawingBuffer) unsafe = true;
|
|
159
|
+
return context;
|
|
160
|
+
};
|
|
161
|
+
proto.getContext = replacement;
|
|
162
|
+
hooks.push(() => proto.getContext === replacement);
|
|
163
|
+
}
|
|
164
|
+
const uncertainCall = (target, name) => {
|
|
165
|
+
const original = target?.[name];
|
|
166
|
+
if (typeof original !== 'function') return;
|
|
167
|
+
try {
|
|
168
|
+
const replacement = function (...args) { unsafe = true; return original.apply(this, args); };
|
|
169
|
+
target[name] = replacement;
|
|
170
|
+
hooks.push(() => target[name] === replacement);
|
|
171
|
+
}
|
|
172
|
+
catch { unsafe = true; }
|
|
173
|
+
};
|
|
174
|
+
// Timer callbacks remain covered by the DOM and drawing hooks.
|
|
175
|
+
uncertainCall(HTMLCanvasElement.prototype, 'transferControlToOffscreen');
|
|
176
|
+
uncertainCall(Element.prototype, 'setHTMLUnsafe');
|
|
177
|
+
uncertainCall(Document, 'parseHTMLUnsafe');
|
|
178
|
+
uncertainCall(Document.prototype, 'write');
|
|
179
|
+
uncertainCall(Document.prototype, 'writeln');
|
|
180
|
+
uncertainCall(globalThis.Worklet?.prototype, 'addModule');
|
|
181
|
+
// Extension objects and transferred contexts can render outside the
|
|
182
|
+
// instrumented prototypes. Never trust their apparent inactivity.
|
|
183
|
+
for (const type of ['WebGLRenderingContext', 'WebGL2RenderingContext']) uncertainCall(globalThis[type]?.prototype, 'getExtension');
|
|
184
|
+
const uncertainStyle = text => /paint\s*\(/i.test(text)
|
|
185
|
+
|| [...text.matchAll(/url\s*\(([^)]*)\)/gi)].some(match => !match[1].trim().replace(/^["']|["']$/g, '').startsWith('#'));
|
|
186
|
+
const uncertainRules = rules => [...rules].some(rule =>
|
|
187
|
+
rule.type === CSSRule.IMPORT_RULE
|
|
188
|
+
|| (rule.style && rule.type !== CSSRule.FONT_FACE_RULE && uncertainStyle(rule.style.cssText))
|
|
189
|
+
|| (rule.cssRules && uncertainRules(rule.cssRules)));
|
|
190
|
+
const images = new Map();
|
|
191
|
+
const staticImage = source => {
|
|
192
|
+
if (!source || source.startsWith('#')) return true;
|
|
193
|
+
if (images.has(source)) return images.get(source);
|
|
194
|
+
images.set(source, false);
|
|
195
|
+
// Image pixels may animate without attributes or load events. Inspect the
|
|
196
|
+
// complete resource once, then reuse only formats proven to be static.
|
|
197
|
+
(async () => {
|
|
198
|
+
let decoder;
|
|
199
|
+
try {
|
|
200
|
+
const response = await fetch(source, { cache: 'force-cache', headers: { 'X-Multiplane-Image-Probe': '1' } });
|
|
201
|
+
if (!response.ok) return;
|
|
202
|
+
const data = await response.arrayBuffer();
|
|
203
|
+
const type = response.headers.get('content-type')?.split(';')[0].trim();
|
|
204
|
+
let safe;
|
|
205
|
+
if (type === 'image/svg+xml') {
|
|
206
|
+
const text = new TextDecoder().decode(data);
|
|
207
|
+
const svg = new DOMParser().parseFromString(text, 'image/svg+xml');
|
|
208
|
+
safe = !svg.querySelector('parsererror,script,animate,animateTransform,animateMotion,set,foreignObject,iframe,video')
|
|
209
|
+
&& !/@(?:keyframes|import)|\banimation\s*:|\bon\w+\s*=|(?:href|src)\s*=\s*["'](?!#)/i.test(text) && !uncertainStyle(text);
|
|
210
|
+
} else if (globalThis.ImageDecoder && type && await ImageDecoder.isTypeSupported(type)) {
|
|
211
|
+
decoder = new ImageDecoder({ data, type });
|
|
212
|
+
await decoder.tracks.ready;
|
|
213
|
+
safe = decoder.tracks.selectedTrack && !decoder.tracks.selectedTrack.animated;
|
|
214
|
+
}
|
|
215
|
+
if (safe) { images.set(source, true); mark(); }
|
|
216
|
+
} catch { /* Unreadable or unsupported image resources stay ineligible. */ }
|
|
217
|
+
finally { decoder?.close(); }
|
|
218
|
+
})();
|
|
219
|
+
return false;
|
|
220
|
+
};
|
|
221
|
+
for (const event of ['load', 'error', 'scroll', 'resize', 'focus', 'blur', 'contextlost', 'contextrestored', 'webglcontextlost', 'webglcontextrestored']) addEventListener(event, mark, true);
|
|
222
|
+
document.fonts.addEventListener('loading', mark);
|
|
223
|
+
document.fonts.addEventListener('loadingdone', mark);
|
|
224
|
+
document.fonts.addEventListener('loadingerror', mark);
|
|
225
|
+
const drain = () => mutations(observer.takeRecords());
|
|
226
|
+
const changedStyles = () => {
|
|
227
|
+
try {
|
|
228
|
+
let declarationChanged = false;
|
|
229
|
+
for (const [style, previous] of exposedDeclarations) {
|
|
230
|
+
const current = style.cssText;
|
|
231
|
+
if (current !== previous) { exposedDeclarations.set(style, current); stylesDirty = declarationChanged = true; }
|
|
232
|
+
}
|
|
233
|
+
const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
|
|
234
|
+
if (sheets.length !== previousSheets.length || sheets.some((sheet, i) => sheet !== previousSheets[i])) stylesDirty = true;
|
|
235
|
+
if (!stylesDirty) return stylesUnsafe;
|
|
236
|
+
previousSheets = sheets;
|
|
237
|
+
readingStyles = true;
|
|
238
|
+
stylesUnsafe = sheets.some(sheet => uncertainRules(sheet.cssRules));
|
|
239
|
+
stylesDirty = false;
|
|
240
|
+
if (stylesUnsafe) return true;
|
|
241
|
+
// CSSOM edits (including adopted sheets, grouping rules and exotic named
|
|
242
|
+
// style setters) do not reliably produce DOM mutation records.
|
|
243
|
+
const signature = JSON.stringify(sheets.map(sheet => [sheet.disabled, sheet.media.mediaText, [...sheet.cssRules].map(rule => rule.cssText)]));
|
|
244
|
+
const changed = styleSignature !== undefined && styleSignature !== signature;
|
|
245
|
+
styleSignature = signature;
|
|
246
|
+
return changed || declarationChanged;
|
|
247
|
+
} catch { return true; }
|
|
248
|
+
finally { readingStyles = false; }
|
|
249
|
+
};
|
|
250
|
+
const uncertain = () => unsafe ? 'unobservable_api' : !hooks.every(check => check()) ? 'hook_replaced'
|
|
251
|
+
// Native macOS textarea scrollbars can animate outside observable DOM/CSS state.
|
|
252
|
+
: platform === 'darwin' && document.querySelector('textarea') ? 'unobservable_api'
|
|
253
|
+
: document.querySelector('video,iframe,object,embed,marquee,animate,animateTransform,animateMotion,set') ? 'dynamic_element'
|
|
254
|
+
: document.activeElement?.matches('input,textarea,[contenteditable]:not([contenteditable="false"])') ? 'editable_focus'
|
|
255
|
+
: document.getAnimations().some(animation => animation.playState === 'running' || (animation.pending && animation.playState !== 'paused')) || document.fonts.status !== 'loaded' ? 'animation_or_font'
|
|
256
|
+
: [...document.images].some(image => !image.complete || !staticImage(image.currentSrc || image.src))
|
|
257
|
+
|| [...document.querySelectorAll('svg image,svg use')].some(image => !staticImage(image.getAttribute('href') ?? image.getAttribute('xlink:href'))) ? 'image'
|
|
258
|
+
: [...document.querySelectorAll('[style]')].some(element => uncertainStyle(element.getAttribute('style'))) ? 'external_style'
|
|
259
|
+
: changedStyles() ? 'stylesheet' : null;
|
|
260
|
+
Object.defineProperty(globalThis, '__multiplaneStatic', { value: {
|
|
261
|
+
suspend() { drain(); suspended = true; },
|
|
262
|
+
resume() { observer.takeRecords(); suspended = false; },
|
|
263
|
+
disqualify() { unsafe = true; },
|
|
264
|
+
get reason() { return reason; },
|
|
265
|
+
read() { drain(); reason = dirty ? reason ?? 'mutation' : uncertain(); dirty = false; return reason !== null; },
|
|
266
|
+
} });
|
|
267
|
+
}
|
package/src/stills.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { reuseState, verifiedFrames } from './static-reuse.mjs';
|
|
2
|
+
import { recoveryBudget } from './browser-recovery.mjs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { mkdir } from 'node:fs/promises';
|
|
5
|
+
import { openShot, number } from './capture.mjs';
|
|
6
|
+
import { createPostCapture, resolveScale, blurSampleReport } from './post.mjs';
|
|
7
|
+
import { runCommand } from './events.mjs';
|
|
8
|
+
import { loaded, observeBrowser } from './environment.mjs';
|
|
9
|
+
import { invalidOption } from './errors.mjs';
|
|
10
|
+
import { requireNewOutput, writeArtifact } from './artifacts.mjs';
|
|
11
|
+
import { checkStill } from './output-check.mjs';
|
|
12
|
+
|
|
13
|
+
export async function stillsShot(shot, options = {}) {
|
|
14
|
+
return runCommand('stills', shot, options, async events => {
|
|
15
|
+
options = { ...events.options };
|
|
16
|
+
delete options.onEvent;
|
|
17
|
+
reuseState(options);
|
|
18
|
+
options._browserRecovery = recoveryBudget();
|
|
19
|
+
let result;
|
|
20
|
+
|
|
21
|
+
let capture, cleaning;
|
|
22
|
+
const cleanup = () => cleaning ??= (async () => { await capture?.close(); })();
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const out = path.resolve(options.out ?? path.join(events.directory, 'stills'));
|
|
26
|
+
events.check();
|
|
27
|
+
events.progress('load');
|
|
28
|
+
if (options.at !== undefined && options.every !== undefined) throw invalidOption('--at', '--at and --every are mutually exclusive.');
|
|
29
|
+
capture = await openShot(shot, resolveScale(options).renderScale, { ...options, onWarning: events.warning, check: events.check, onBrowser: observeBrowser(events) });
|
|
30
|
+
options.alpha = capture.alpha;
|
|
31
|
+
await loaded(events, capture);
|
|
32
|
+
const fps = number(options.fps ?? capture.fps, '--fps', { positive: true });
|
|
33
|
+
const processed = createPostCapture(capture, { ...options, onWarning: events.warning }, fps);
|
|
34
|
+
let times;
|
|
35
|
+
if (options.at !== undefined) times = options.at.split(',').map(t => number(t, '--at'));
|
|
36
|
+
else {
|
|
37
|
+
const interval = number(options.every ?? 1, '--every', { positive: true });
|
|
38
|
+
times = Array.from({ length: Math.ceil(capture.duration / interval) }, (_, i) => i * interval);
|
|
39
|
+
}
|
|
40
|
+
if (times.some(t => t > capture.duration)) throw invalidOption('--at', 'Still time exceeds shot duration.');
|
|
41
|
+
if (new Set(times.map(t => t.toFixed(2))).size !== times.length) throw invalidOption('--at', 'Still filenames collide at two decimal places.');
|
|
42
|
+
const { scale } = resolveScale(options);
|
|
43
|
+
events.progress('capture', 0, times.length);
|
|
44
|
+
await mkdir(out, { recursive: true });
|
|
45
|
+
for (const file of [...times.map(t => `t-${t.toFixed(2)}.png`), '_sheet.png']) await requireNewOutput(path.join(out, file));
|
|
46
|
+
const frames = [];
|
|
47
|
+
const histogram = {};
|
|
48
|
+
const source = async function* (first, end) {
|
|
49
|
+
for (let index = first; index < end; index++) {
|
|
50
|
+
const t = times[index];
|
|
51
|
+
events.check();
|
|
52
|
+
const png = await processed.frame(t, Math.round(t * fps), index === end - 1);
|
|
53
|
+
events.progress('capture', index + 1, times.length);
|
|
54
|
+
yield { t, png, blurSamples: processed.lastBlurSamples };
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
for await (const { t, png: buffer, blurSamples } of verifiedFrames(source, { ...options, check: events.check }, times.length)) {
|
|
58
|
+
histogram[blurSamples] = (histogram[blurSamples] ?? 0) + 1;
|
|
59
|
+
events.check();
|
|
60
|
+
await writeArtifact(path.join(out, `t-${t.toFixed(2)}.png`), buffer);
|
|
61
|
+
await checkStill(path.join(out, `t-${t.toFixed(2)}.png`), { width: capture.width * scale, height: capture.height * scale });
|
|
62
|
+
frames.push({ time: t.toFixed(2), image: buffer.toString('base64') });
|
|
63
|
+
events.artifact(path.join(out, `t-${t.toFixed(2)}.png`), 'still');
|
|
64
|
+
}
|
|
65
|
+
const thumbnailHeight = 480 * capture.height / capture.width;
|
|
66
|
+
const rowHeight = thumbnailHeight + 40;
|
|
67
|
+
const sheetPNG = await capture.retryFrame(undefined, async () => {
|
|
68
|
+
const sheet = await capture.browser.newPage({ viewport: { width: 1920, height: Math.ceil(Math.ceil(frames.length / 4) * rowHeight) }, deviceScaleFactor: 1 });
|
|
69
|
+
await sheet.setContent(`<html><head><style>body{margin:0;background:#222;color:#fff;font:24px sans-serif}main{display:grid;grid-template-columns:repeat(4,480px)}figure{margin:0;width:480px;height:${rowHeight}px}img{${options.alpha ? 'background:conic-gradient(#bbb 25%,#eee 0 50%,#bbb 0 75%,#eee 0) 0 0/24px 24px;' : ''}display:block;width:480px;height:${thumbnailHeight}px}figcaption{height:40px;line-height:40px;text-align:center}</style></head><body><main>${frames.map(f => `<figure><img src="data:image/png;base64,${f.image}"><figcaption>t = ${f.time} s</figcaption></figure>`).join('')}</main></body></html>`);
|
|
70
|
+
await sheet.evaluate(async () => { await document.fonts.ready; await Promise.all([...document.images].map(img => img.decode())); });
|
|
71
|
+
try { return await sheet.screenshot({ fullPage: true }); } finally { await sheet.close().catch(() => {}); }
|
|
72
|
+
});
|
|
73
|
+
await writeArtifact(path.join(out, '_sheet.png'), sheetPNG);
|
|
74
|
+
await checkStill(path.join(out, '_sheet.png'), { width: 1920, height: Math.ceil(Math.ceil(frames.length / 4) * rowHeight) });
|
|
75
|
+
events.artifact(path.join(out, '_sheet.png'), 'contact-sheet');
|
|
76
|
+
result = { staticReuse: processed.staticReuse, post: { ...processed.post, adaptiveBlur: Number(options['adaptive-blur'] ?? 0.5) }, blurSamples: blurSampleReport(histogram), format: 'png', alpha: capture.alpha, stills: times.map(t => ({ t, frame: Math.round(t * fps), path: path.join(out, `t-${t.toFixed(2)}.png`) })), contactSheet: path.join(out, '_sheet.png') };
|
|
77
|
+
} finally { await cleanup(); }
|
|
78
|
+
|
|
79
|
+
return options.draft ? { ...result, draft: true } : result;
|
|
80
|
+
});
|
|
81
|
+
}
|