@hyperframes/core 0.8.185 → 0.8.186

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.
@@ -0,0 +1,8 @@
1
+ /** Chrome <=155 marks a capture canvas with `layoutsubtree`; 157+ with `content="drawable"` and `drawable` on each captured child. */
2
+ export type HtmlInCanvasForm = "layoutsubtree" | "drawable";
3
+ export declare const CAPTURE_CANVAS_SELECTOR = "canvas[layoutsubtree], canvas[content=\"drawable\"]";
4
+ /** The form this browser's html-in-canvas takes, or null when it has no usable one. */
5
+ export declare function htmlInCanvasForm(win?: Window): HtmlInCanvasForm | null;
6
+ export declare function markCaptureCanvas(canvas: HTMLCanvasElement, form: HtmlInCanvasForm): void;
7
+ export declare function markCaptured(el: Element, form: HtmlInCanvasForm): void;
8
+ export declare function unmarkCaptured(el: Element, form: HtmlInCanvasForm): void;
@@ -0,0 +1,29 @@
1
+ export const CAPTURE_CANVAS_SELECTOR = 'canvas[layoutsubtree], canvas[content="drawable"]';
2
+ /** The form this browser's html-in-canvas takes, or null when it has no usable one. */
3
+ export function htmlInCanvasForm(win = window) {
4
+ const ctx = win
5
+ .CanvasRenderingContext2D?.prototype;
6
+ if (!ctx || !("drawElementImage" in ctx))
7
+ return null;
8
+ const canvas = win.document.createElement("canvas");
9
+ if (!("requestPaint" in canvas))
10
+ return null;
11
+ // 155 already has updateElementGeometry but still wants layoutsubtree, so this order matters.
12
+ if ("layoutSubtree" in canvas)
13
+ return "layoutsubtree";
14
+ return "updateElementGeometry" in canvas ? "drawable" : null;
15
+ }
16
+ export function markCaptureCanvas(canvas, form) {
17
+ if (form === "layoutsubtree")
18
+ canvas.setAttribute("layoutsubtree", "");
19
+ else
20
+ canvas.setAttribute("content", "drawable");
21
+ }
22
+ export function markCaptured(el, form) {
23
+ if (form === "drawable")
24
+ el.setAttribute("drawable", "");
25
+ }
26
+ export function unmarkCaptured(el, form) {
27
+ if (form === "drawable")
28
+ el.removeAttribute("drawable");
29
+ }
@@ -0,0 +1,26 @@
1
+ type UaWindow = {
2
+ navigator: {
3
+ userAgent: string;
4
+ };
5
+ };
6
+ /** Major Chrome version from the user agent (HeadlessChrome included), or null when unknown. */
7
+ export declare function chromeMajor(win?: UaWindow): number | null;
8
+ export interface CaptureDefect {
9
+ code: string;
10
+ reason: string;
11
+ }
12
+ export interface DefectEnv {
13
+ isAccelerated: (canvas: HTMLCanvasElement) => boolean;
14
+ /** Result of `scanBackfaceDefect`, refreshed whenever the scene is rescanned. */
15
+ backface: boolean;
16
+ }
17
+ /** The first defect that makes html-in-canvas capture this root wrongly in any supported browser, or null. */
18
+ export declare function findCaptureDefect(root: HTMLElement, env: DefectEnv): CaptureDefect | null;
19
+ export declare function scanBackfaceDefect(root: HTMLElement): boolean;
20
+ export interface DefectScan {
21
+ sample: (i: number, steps: number, at: number) => void;
22
+ result: () => CaptureDefect | null;
23
+ facesNow: () => boolean;
24
+ }
25
+ export declare function createDefectScan(root: HTMLElement, isAccelerated: DefectEnv["isAccelerated"]): DefectScan;
26
+ export {};
@@ -0,0 +1,262 @@
1
+ import { CAPTURE_CANVAS_SELECTOR } from "./htmlInCanvas.js";
2
+ /** Major Chrome version from the user agent (HeadlessChrome included), or null when unknown. */
3
+ export function chromeMajor(win = window) {
4
+ const m = /Chrome\/(\d+)/.exec(win.navigator.userAgent);
5
+ return m?.[1] ? Number(m[1]) : null;
6
+ }
7
+ const hasHidden3dFace = (_root, env) => env.backface;
8
+ // The whole document: a render's shader-transition canvas sits in <body>, preview's in the root.
9
+ const hasAcceleratedCanvas = (root, env) => Array.from(root.ownerDocument.querySelectorAll("canvas")).some(env.isAccelerated);
10
+ const IDENTITY_TRANSFORM = /^matrix\(\s*1,\s*0,\s*0,\s*1,\s*0,\s*0\s*\)$|^matrix3d\(\s*1,\s*0,\s*0,\s*0,\s*0,\s*1,\s*0,\s*0,\s*0,\s*0,\s*1,\s*0,\s*0,\s*0,\s*0,\s*1\s*\)$/;
11
+ const moves = (value, identity) => !!value && value !== "none" && !value.split(/\s+/).every((p) => parseFloat(p) === identity);
12
+ // `scale`, `rotate` and `translate` transform the root just as `transform` does.
13
+ const hasRootTransform = (root) => {
14
+ const cs = getComputedStyle(root);
15
+ const t = cs.transform.trim();
16
+ if (moves(cs.scale, 1) || moves(cs.rotate, 0) || moves(cs.translate, 0))
17
+ return true;
18
+ return t !== "none" && t !== "" && !IDENTITY_TRANSFORM.test(t);
19
+ };
20
+ const hasRootBlend = (root) => {
21
+ const mode = getComputedStyle(root).mixBlendMode;
22
+ return !!mode && mode !== "normal";
23
+ };
24
+ const hasRootOpacity = (root) => Number.parseFloat(getComputedStyle(root).opacity) < 1;
25
+ // Moving the root tears these down and starts them again: a frame reloads, a custom element reconnects.
26
+ const hasReinitialised = (root) => root.querySelector("iframe, object, embed") !== null ||
27
+ Array.from(root.querySelectorAll("*")).some((el) => el.localName.includes("-") && customElements.get(el.localName) !== undefined);
28
+ const DEFECTS = [
29
+ {
30
+ code: "nested-capture-canvas",
31
+ reason: "the composition already uses html-in-canvas",
32
+ detect: (root) => root.querySelector(CAPTURE_CANVAS_SELECTOR) !== null,
33
+ },
34
+ {
35
+ // Chrome <=156 captures the canvas frozen at its pre-wrap frame; 157 fixed it.
36
+ code: "accelerated-canvas",
37
+ reason: "the composition draws a WebGL/WebGPU canvas, which html-in-canvas captures frozen",
38
+ detect: hasAcceleratedCanvas,
39
+ },
40
+ {
41
+ code: "backface-visibility",
42
+ reason: "backface-visibility: hidden inside a 3D transform is drawn mirrored by html-in-canvas",
43
+ detect: hasHidden3dFace,
44
+ },
45
+ {
46
+ code: "iframe-in-root",
47
+ reason: "the composition contains an <iframe>, <object>, <embed> or custom element, which is re-initialised when blur wraps the root",
48
+ detect: hasReinitialised,
49
+ },
50
+ {
51
+ code: "root-transform",
52
+ reason: "html-in-canvas can ignore a transform on the composition root",
53
+ detect: hasRootTransform,
54
+ },
55
+ {
56
+ code: "root-opacity",
57
+ reason: "html-in-canvas can ignore the composition root's own opacity",
58
+ detect: hasRootOpacity,
59
+ },
60
+ {
61
+ code: "root-blend-mode",
62
+ reason: "html-in-canvas can ignore the composition root's own mix-blend-mode",
63
+ detect: hasRootBlend,
64
+ },
65
+ ];
66
+ /** The first defect that makes html-in-canvas capture this root wrongly in any supported browser, or null. */
67
+ export function findCaptureDefect(root, env) {
68
+ const defect = DEFECTS.find((d) => d.detect(root, env));
69
+ return defect ? { code: defect.code, reason: defect.reason } : null;
70
+ }
71
+ const FACE_3D_TRANSFORM = /perspective\(|rotate3d\(|rotate[XY]\(|matrix3d\(/;
72
+ function isHidden(cs) {
73
+ return (cs.getPropertyValue("backface-visibility") === "hidden" ||
74
+ cs.getPropertyValue("-webkit-backface-visibility") === "hidden");
75
+ }
76
+ const individualTurnsIn3d = (cs) => !/^(none|-?[\d.]+[a-z]+)?$/.test(cs.rotate ?? "") ||
77
+ (cs.translate ?? "").trim().split(/\s+/).length > 2;
78
+ function transformsIn3d(el, cs) {
79
+ return (FACE_3D_TRANSFORM.test(cs.transform) ||
80
+ FACE_3D_TRANSFORM.test(el.style.transform) ||
81
+ individualTurnsIn3d(cs));
82
+ }
83
+ function in3dContext(el, root) {
84
+ for (let node = el; node; node = node.parentElement) {
85
+ const cs = getComputedStyle(node);
86
+ if (cs.transformStyle === "preserve-3d" || transformsIn3d(node, cs))
87
+ return true;
88
+ if (node !== el && cs.perspective !== "none" && cs.perspective !== "")
89
+ return true;
90
+ if (node === root)
91
+ break;
92
+ }
93
+ return false;
94
+ }
95
+ const MAX_RULE_DEPTH = 16;
96
+ const KF = "@keyframes ";
97
+ function splitSelectorList(list) {
98
+ const parts = [];
99
+ let depth = 0;
100
+ let from = 0;
101
+ for (let i = 0; i < list.length; i++) {
102
+ const ch = list.charAt(i);
103
+ if (ch === "(" || ch === "[")
104
+ depth++;
105
+ else if (ch === ")" || ch === "]")
106
+ depth--;
107
+ else if (ch === "," && depth === 0) {
108
+ parts.push(list.slice(from, i).trim());
109
+ from = i + 1;
110
+ }
111
+ }
112
+ parts.push(list.slice(from).trim());
113
+ return parts;
114
+ }
115
+ /** A nested rule's selector made absolute: `&` is the parent, and no `&` means a descendant. */
116
+ function resolveNested(selector, parent) {
117
+ if (parent === null)
118
+ return selector;
119
+ const outer = `:is(${parent})`;
120
+ return splitSelectorList(selector)
121
+ .map((part) => (part.includes("&") ? part.replaceAll("&", outer) : `${outer} ${part}`))
122
+ .join(", ");
123
+ }
124
+ function* styleBlocks(rules, parent, seen, depth) {
125
+ if (depth > MAX_RULE_DEPTH)
126
+ return;
127
+ for (const rule of Array.from(rules))
128
+ yield* ruleBlocks(rule, parent, seen, depth);
129
+ }
130
+ function nestedDeclarations(rule, parent) {
131
+ const style = "style" in rule ? rule.style : null;
132
+ return parent !== null && style instanceof CSSStyleDeclaration
133
+ ? { selector: parent, style }
134
+ : null;
135
+ }
136
+ function* ruleBlocks(rule, parent, seen, depth) {
137
+ if (rule instanceof CSSStyleRule) {
138
+ const selector = resolveNested(rule.selectorText, parent);
139
+ yield { selector, style: rule.style };
140
+ yield* styleBlocks(rule.cssRules, selector, seen, depth + 1);
141
+ return;
142
+ }
143
+ if (rule instanceof CSSImportRule) {
144
+ if (rule.styleSheet)
145
+ yield* sheetBlocks(rule.styleSheet, seen, depth + 1);
146
+ return;
147
+ }
148
+ const declarations = nestedDeclarations(rule, parent);
149
+ if (declarations)
150
+ yield declarations;
151
+ else if ("cssRules" in rule && rule.cssRules instanceof CSSRuleList) {
152
+ const scope = rule instanceof CSSKeyframesRule ? KF + rule.name : parent;
153
+ yield* styleBlocks(rule.cssRules, scope, seen, depth + 1);
154
+ }
155
+ }
156
+ function sheetRules(sheet) {
157
+ try {
158
+ return sheet.cssRules;
159
+ }
160
+ catch {
161
+ return null;
162
+ }
163
+ }
164
+ function* sheetBlocks(sheet, seen, depth) {
165
+ const rules = seen.has(sheet) ? null : sheetRules(sheet);
166
+ seen.add(sheet);
167
+ if (rules)
168
+ yield* styleBlocks(rules, null, seen, depth);
169
+ }
170
+ function matching(root, selector) {
171
+ try {
172
+ const found = Array.from(root.querySelectorAll(selector));
173
+ return root.matches(selector) ? [root, ...found] : found;
174
+ }
175
+ catch {
176
+ return [];
177
+ }
178
+ }
179
+ function runningFaces(root) {
180
+ return (document.getAnimations?.() ?? []).flatMap(({ effect }) => {
181
+ if (!(effect instanceof KeyframeEffect))
182
+ return [];
183
+ const el = effect.target;
184
+ const keyed = effect.getKeyframes().some((k) => k.backfaceVisibility === "hidden");
185
+ return keyed && el instanceof HTMLElement && root.contains(el) ? [el] : [];
186
+ });
187
+ }
188
+ function namedFaces(root, keyframed) {
189
+ if (keyframed.length === 0)
190
+ return [];
191
+ return [root, ...root.querySelectorAll("*")].filter((el) => getComputedStyle(el)
192
+ .animationName.split(", ")
193
+ .some((n) => keyframed.includes(KF + n)));
194
+ }
195
+ function hiddenSelectors() {
196
+ const seen = new Set();
197
+ const sheets = [...Array.from(document.styleSheets), ...(document.adoptedStyleSheets ?? [])];
198
+ const out = [];
199
+ for (const sheet of sheets) {
200
+ for (const block of sheetBlocks(sheet, seen, 0))
201
+ if (isHidden(block.style))
202
+ out.push(block.selector);
203
+ }
204
+ return out;
205
+ }
206
+ function backfaceCandidates(root, selectors) {
207
+ const keyframed = selectors.filter((s) => s.startsWith(KF));
208
+ const found = new Set([
209
+ ...root.querySelectorAll('[style*="backface"]'),
210
+ ...runningFaces(root),
211
+ ...namedFaces(root, keyframed),
212
+ ]);
213
+ for (const selector of selectors)
214
+ for (const el of matching(root, selector))
215
+ found.add(el);
216
+ return found;
217
+ }
218
+ /**
219
+ * Whether a `backface-visibility: hidden` element is in a 3D context right now (flat uses are
220
+ * harmless); call it at several timeline times to see a rotation that a later tween applies.
221
+ */
222
+ function createBackfaceCheck(root) {
223
+ const selectors = hiddenSelectors();
224
+ let candidates = new Set();
225
+ const anyFace = () => {
226
+ for (const el of candidates) {
227
+ if (isHidden(getComputedStyle(el)) && in3dContext(el, root))
228
+ return true;
229
+ }
230
+ return false;
231
+ };
232
+ return {
233
+ scan: () => {
234
+ candidates = new Set([...candidates, ...backfaceCandidates(root, selectors)]);
235
+ return anyFace();
236
+ },
237
+ recheck: anyFace,
238
+ };
239
+ }
240
+ export function scanBackfaceDefect(root) {
241
+ return createBackfaceCheck(root).scan();
242
+ }
243
+ const FACE_EVERY = 10;
244
+ export function createDefectScan(root, isAccelerated) {
245
+ const faces = createBackfaceCheck(root);
246
+ let found = null;
247
+ return {
248
+ sample: (i, steps, at) => {
249
+ if (found)
250
+ return;
251
+ const checkFaces = i % FACE_EVERY === 0 || i === steps;
252
+ const defect = findCaptureDefect(root, {
253
+ isAccelerated,
254
+ backface: checkFaces && faces.scan(),
255
+ });
256
+ if (defect)
257
+ found = { ...defect, reason: `${defect.reason} (first seen at t=${at.toFixed(1)}s)` };
258
+ },
259
+ result: () => found,
260
+ facesNow: faces.recheck,
261
+ };
262
+ }