@liquid-glass-forge/dom 0.0.0-stage → 0.1.0-rc.2
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 +21 -0
- package/NOTICE +7 -0
- package/README.md +65 -2
- package/dist/cpu.d.ts +90 -0
- package/dist/cpu.js +240 -0
- package/dist/host.d.ts +41 -0
- package/dist/host.js +244 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/mapping.d.ts +60 -0
- package/dist/mapping.js +92 -0
- package/dist/providers.d.ts +58 -0
- package/dist/providers.js +402 -0
- package/dist/scene.d.ts +70 -0
- package/dist/scene.js +494 -0
- package/dist/styles.css +23 -0
- package/package.json +39 -4
package/dist/host.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { createSource, MAX_PIXELS } from '@liquid-glass-forge/core';
|
|
2
|
+
export class HostError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message, options) {
|
|
5
|
+
super(message, options);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = 'HostError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const owners = new WeakMap();
|
|
11
|
+
const materialValid = (value) => value === 'glass' || value === 'opaque';
|
|
12
|
+
/** Own only a decorative target and explicitly touched host styles. Never move semantic children. */
|
|
13
|
+
export function mountGlassHost(element, options = {}) {
|
|
14
|
+
const doc = element?.ownerDocument, view = doc?.defaultView;
|
|
15
|
+
if (!view || !(element instanceof view.HTMLElement) ||
|
|
16
|
+
['IMG', 'INPUT', 'CANVAS', 'VIDEO', 'AUDIO', 'TEXTAREA', 'SELECT', 'IFRAME', 'BR', 'HR', 'LINK', 'META'].includes(element.tagName) ||
|
|
17
|
+
['inline', 'contents'].includes(view.getComputedStyle(element).display))
|
|
18
|
+
throw new HostError('INVALID_HOST', 'Expected an HTML block or inline-block container');
|
|
19
|
+
const position = view.getComputedStyle(element).position;
|
|
20
|
+
const needsPosition = !position || position === 'static';
|
|
21
|
+
if (owners.has(element))
|
|
22
|
+
throw new HostError('INVALID_HOST', 'Element already has a glass host');
|
|
23
|
+
if (!materialValid(options.material ?? 'glass'))
|
|
24
|
+
throw new HostError('INVALID_HOST', 'Invalid material');
|
|
25
|
+
const supplied = options.target !== undefined;
|
|
26
|
+
const target = options.target ?? doc.createElement('canvas');
|
|
27
|
+
if (!(target instanceof view.HTMLCanvasElement) || (supplied && target.parentElement !== element)) {
|
|
28
|
+
throw new HostError('INVALID_HOST', 'Supplied target must be a dedicated canvas child of the host');
|
|
29
|
+
}
|
|
30
|
+
const originalWidth = target.width, originalHeight = target.height;
|
|
31
|
+
let originalPixels;
|
|
32
|
+
let context = null;
|
|
33
|
+
if (supplied) {
|
|
34
|
+
if (originalWidth * originalHeight > MAX_PIXELS)
|
|
35
|
+
throw new HostError('INVALID_HOST', 'Supplied backing exceeds core pixel bound');
|
|
36
|
+
try {
|
|
37
|
+
context = target.getContext('2d', { colorSpace: 'srgb', willReadFrequently: true });
|
|
38
|
+
if (!context)
|
|
39
|
+
throw new Error('Canvas2D unavailable');
|
|
40
|
+
if (originalWidth && originalHeight)
|
|
41
|
+
originalPixels = context.getImageData(0, 0, originalWidth, originalHeight);
|
|
42
|
+
}
|
|
43
|
+
catch (cause) {
|
|
44
|
+
throw new HostError('UNAVAILABLE_HOST', 'Supplied target must have a readable 2D backing', { cause });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const originalStyleAttributes = new Map([[element, element.getAttribute('style')], [target, target.getAttribute('style')]]);
|
|
48
|
+
const undo = [];
|
|
49
|
+
const styleRecords = new Map();
|
|
50
|
+
const style = (node, key, value) => {
|
|
51
|
+
let records = styleRecords.get(node.style);
|
|
52
|
+
if (!records) {
|
|
53
|
+
records = new Map();
|
|
54
|
+
styleRecords.set(node.style, records);
|
|
55
|
+
}
|
|
56
|
+
let record = records.get(key);
|
|
57
|
+
if (!record) {
|
|
58
|
+
record = { value: node.style.getPropertyValue(key), priority: node.style.getPropertyPriority(key), ownedValue: '', ownedPriority: '' };
|
|
59
|
+
records.set(key, record);
|
|
60
|
+
const saved = record;
|
|
61
|
+
undo.push(() => {
|
|
62
|
+
if (node.style.getPropertyValue(key) !== saved.ownedValue || node.style.getPropertyPriority(key) !== saved.ownedPriority)
|
|
63
|
+
return;
|
|
64
|
+
if (saved.value)
|
|
65
|
+
node.style.setProperty(key, saved.value, saved.priority);
|
|
66
|
+
else
|
|
67
|
+
node.style.removeProperty(key);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
node.style.setProperty(key, value);
|
|
71
|
+
record.ownedValue = node.style.getPropertyValue(key);
|
|
72
|
+
record.ownedPriority = node.style.getPropertyPriority(key);
|
|
73
|
+
};
|
|
74
|
+
const attrRecords = new Map();
|
|
75
|
+
const attr = (key, value) => {
|
|
76
|
+
let record = attrRecords.get(key);
|
|
77
|
+
if (!record) {
|
|
78
|
+
record = { value: target.getAttribute(key), owned: value };
|
|
79
|
+
attrRecords.set(key, record);
|
|
80
|
+
const saved = record;
|
|
81
|
+
undo.push(() => { if (target.getAttribute(key) === saved.owned) {
|
|
82
|
+
if (saved.value === null)
|
|
83
|
+
target.removeAttribute(key);
|
|
84
|
+
else
|
|
85
|
+
target.setAttribute(key, saved.value);
|
|
86
|
+
} });
|
|
87
|
+
}
|
|
88
|
+
target.setAttribute(key, value);
|
|
89
|
+
record.owned = value;
|
|
90
|
+
};
|
|
91
|
+
const token = (node, name) => {
|
|
92
|
+
if (!node.classList.contains(name)) {
|
|
93
|
+
const hadClass = node.hasAttribute('class');
|
|
94
|
+
node.classList.add(name);
|
|
95
|
+
undo.push(() => { node.classList.remove(name); if (!node.className && !hadClass)
|
|
96
|
+
node.removeAttribute('class'); });
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
let disposed = false, ownedWidth = target.width, ownedHeight = target.height, backingTouched = false;
|
|
100
|
+
let presentationImage;
|
|
101
|
+
const alive = () => { if (disposed)
|
|
102
|
+
throw new HostError('DISPOSED', 'Host is disposed'); };
|
|
103
|
+
const restore = () => {
|
|
104
|
+
if (backingTouched && target.width === ownedWidth && target.height === ownedHeight) {
|
|
105
|
+
target.width = supplied ? originalWidth : 0;
|
|
106
|
+
target.height = supplied ? originalHeight : 0;
|
|
107
|
+
if (supplied && originalPixels)
|
|
108
|
+
target.getContext('2d')?.putImageData(originalPixels, 0, 0);
|
|
109
|
+
}
|
|
110
|
+
originalPixels = undefined;
|
|
111
|
+
presentationImage = undefined;
|
|
112
|
+
for (const release of undo.reverse()) {
|
|
113
|
+
try {
|
|
114
|
+
release();
|
|
115
|
+
}
|
|
116
|
+
catch { /* Continue releasing independent ownership. */ }
|
|
117
|
+
}
|
|
118
|
+
for (const [node, text] of originalStyleAttributes) {
|
|
119
|
+
const expected = doc.createElement('div').style;
|
|
120
|
+
expected.cssText = text ?? '';
|
|
121
|
+
const keys = new Set([...Array.from(node.style), ...Array.from(expected)]);
|
|
122
|
+
if ([...keys].every(key => node.style.getPropertyValue(key) === expected.getPropertyValue(key) && node.style.getPropertyPriority(key) === expected.getPropertyPriority(key))) {
|
|
123
|
+
if (text === null)
|
|
124
|
+
node.removeAttribute('style');
|
|
125
|
+
else
|
|
126
|
+
node.setAttribute('style', text);
|
|
127
|
+
}
|
|
128
|
+
if (text === null && node.style.cssText === '' && node.getAttribute('style') !== null)
|
|
129
|
+
node.removeAttribute('style');
|
|
130
|
+
}
|
|
131
|
+
if (!supplied)
|
|
132
|
+
target.remove();
|
|
133
|
+
context = null;
|
|
134
|
+
owners.delete(element);
|
|
135
|
+
};
|
|
136
|
+
const host = Object.freeze({
|
|
137
|
+
element, target,
|
|
138
|
+
measure() { alive(); return Object.freeze({ width: element.clientWidth, height: element.clientHeight }); },
|
|
139
|
+
setMaterial(material) {
|
|
140
|
+
alive();
|
|
141
|
+
if (!materialValid(material))
|
|
142
|
+
throw new HostError('INVALID_HOST', 'Invalid material');
|
|
143
|
+
attr('data-lgf-material', material);
|
|
144
|
+
style(target, 'background-color', material === 'opaque' ? 'Canvas' : 'var(--lgf-tint, rgba(245,248,255,0.78))');
|
|
145
|
+
const filter = material === 'opaque' ? 'none' : 'blur(var(--lgf-blur, 12px)) saturate(1.1)';
|
|
146
|
+
style(target, 'backdrop-filter', filter);
|
|
147
|
+
style(target, '-webkit-backdrop-filter', filter);
|
|
148
|
+
},
|
|
149
|
+
prepareCanvas(width, height) {
|
|
150
|
+
alive();
|
|
151
|
+
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1 || width * height > MAX_PIXELS)
|
|
152
|
+
throw new HostError('INVALID_PIXELS', 'Invalid native canvas size');
|
|
153
|
+
context ??= target.getContext('2d', { colorSpace: 'srgb', willReadFrequently: true });
|
|
154
|
+
if (!context)
|
|
155
|
+
throw new HostError('UNAVAILABLE_HOST', 'Canvas2D unavailable');
|
|
156
|
+
ownedWidth = width;
|
|
157
|
+
ownedHeight = height;
|
|
158
|
+
backingTouched = true;
|
|
159
|
+
presentationImage = undefined;
|
|
160
|
+
if (target.width !== width)
|
|
161
|
+
target.width = width;
|
|
162
|
+
if (target.height !== height)
|
|
163
|
+
target.height = height;
|
|
164
|
+
return target;
|
|
165
|
+
},
|
|
166
|
+
present(pixels) {
|
|
167
|
+
alive();
|
|
168
|
+
if ('presentedTarget' in pixels) {
|
|
169
|
+
if (pixels.presentedTarget !== target || target.width !== pixels.width || target.height !== pixels.height || pixels.width !== ownedWidth || pixels.height !== ownedHeight || !backingTouched)
|
|
170
|
+
throw new HostError('INVALID_PIXELS', 'Unprepared native presentation');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if ('canvas' in pixels) {
|
|
174
|
+
const canvas = pixels.canvas, frameView = canvas?.ownerDocument?.defaultView;
|
|
175
|
+
if (!frameView || !(canvas instanceof frameView.HTMLCanvasElement) || canvas === target || canvas.width !== pixels.width || canvas.height !== pixels.height)
|
|
176
|
+
throw new HostError('INVALID_PIXELS', 'Invalid canvas frame');
|
|
177
|
+
host.prepareCanvas(pixels.width, pixels.height);
|
|
178
|
+
if (typeof context.reset === 'function')
|
|
179
|
+
context.reset();
|
|
180
|
+
else
|
|
181
|
+
target.width = pixels.width;
|
|
182
|
+
context.globalCompositeOperation = 'copy';
|
|
183
|
+
context.imageSmoothingEnabled = false;
|
|
184
|
+
context.drawImage(canvas, 0, 0);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
createSource({ contractVersion: 1, width: pixels.width, height: pixels.height, data: pixels.data, revision: 0, ownership: 'borrowed', colorSpace: 'srgb', alphaMode: 'straight' });
|
|
189
|
+
}
|
|
190
|
+
catch (cause) {
|
|
191
|
+
throw new HostError('INVALID_PIXELS', 'Expected bounded RGBA8 output', { cause });
|
|
192
|
+
}
|
|
193
|
+
context ??= target.getContext('2d', { colorSpace: 'srgb', willReadFrequently: true });
|
|
194
|
+
if (!context)
|
|
195
|
+
throw new HostError('UNAVAILABLE_HOST', 'Canvas2D is unavailable');
|
|
196
|
+
if (target.width !== pixels.width)
|
|
197
|
+
target.width = pixels.width;
|
|
198
|
+
if (target.height !== pixels.height)
|
|
199
|
+
target.height = pixels.height;
|
|
200
|
+
ownedWidth = pixels.width;
|
|
201
|
+
ownedHeight = pixels.height;
|
|
202
|
+
backingTouched = true;
|
|
203
|
+
if (!presentationImage || presentationImage.width !== pixels.width || presentationImage.height !== pixels.height) {
|
|
204
|
+
presentationImage = context.createImageData(pixels.width, pixels.height);
|
|
205
|
+
}
|
|
206
|
+
presentationImage.data.set(pixels.data);
|
|
207
|
+
context.putImageData(presentationImage, 0, 0);
|
|
208
|
+
},
|
|
209
|
+
clear() { alive(); presentationImage = undefined; target.width = 0; target.height = 0; ownedWidth = 0; ownedHeight = 0; backingTouched = true; },
|
|
210
|
+
dispose() { if (disposed)
|
|
211
|
+
return; disposed = true; restore(); }
|
|
212
|
+
});
|
|
213
|
+
try {
|
|
214
|
+
token(element, 'lgf-host');
|
|
215
|
+
token(target, 'lgf-surface');
|
|
216
|
+
if (needsPosition) {
|
|
217
|
+
style(element, 'position', 'relative');
|
|
218
|
+
for (const key of ['top', 'right', 'bottom', 'left'])
|
|
219
|
+
style(element, key, 'auto');
|
|
220
|
+
}
|
|
221
|
+
style(element, 'isolation', 'isolate');
|
|
222
|
+
for (const [key, value] of Object.entries({ display: 'block', 'box-sizing': 'border-box', 'margin-top': '0px', 'margin-right': '0px', 'margin-bottom': '0px', 'margin-left': '0px', 'border-width': '0px', padding: '0px', position: 'absolute', top: '0px', right: '0px', bottom: '0px', left: '0px', width: '100%', height: '100%', 'pointer-events': 'none', 'z-index': '-1', 'border-radius': 'inherit' }))
|
|
223
|
+
style(target, key, value);
|
|
224
|
+
attr('aria-hidden', 'true');
|
|
225
|
+
attr('role', 'presentation');
|
|
226
|
+
attr('tabindex', '-1');
|
|
227
|
+
host.setMaterial(options.material ?? 'glass');
|
|
228
|
+
if (!supplied) {
|
|
229
|
+
target.width = 0;
|
|
230
|
+
target.height = 0;
|
|
231
|
+
ownedWidth = 0;
|
|
232
|
+
ownedHeight = 0;
|
|
233
|
+
backingTouched = true;
|
|
234
|
+
element.prepend(target);
|
|
235
|
+
}
|
|
236
|
+
owners.set(element, host);
|
|
237
|
+
return host;
|
|
238
|
+
}
|
|
239
|
+
catch (cause) {
|
|
240
|
+
disposed = true;
|
|
241
|
+
restore();
|
|
242
|
+
throw cause;
|
|
243
|
+
}
|
|
244
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface Size {
|
|
2
|
+
readonly width: number;
|
|
3
|
+
readonly height: number;
|
|
4
|
+
}
|
|
5
|
+
export interface Rect extends Size {
|
|
6
|
+
readonly x: number;
|
|
7
|
+
readonly y: number;
|
|
8
|
+
}
|
|
9
|
+
export interface AxisTransform {
|
|
10
|
+
readonly a: number;
|
|
11
|
+
readonly b: number;
|
|
12
|
+
readonly c: number;
|
|
13
|
+
readonly d: number;
|
|
14
|
+
readonly e: number;
|
|
15
|
+
readonly f: number;
|
|
16
|
+
}
|
|
17
|
+
export interface MappingInput {
|
|
18
|
+
readonly source: Size;
|
|
19
|
+
/** Untransformed content box. x/y are viewport coordinates of its local origin. */
|
|
20
|
+
readonly box: Rect;
|
|
21
|
+
/** Desired output rectangle in viewport CSS pixels, including any clipped area. */
|
|
22
|
+
readonly target: Rect;
|
|
23
|
+
readonly fit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down';
|
|
24
|
+
/** Fractions of remaining space, plus local CSS pixel offsets; not a CSS string parser. */
|
|
25
|
+
readonly position?: {
|
|
26
|
+
readonly x: number;
|
|
27
|
+
readonly y: number;
|
|
28
|
+
readonly offsetX?: number;
|
|
29
|
+
readonly offsetY?: number;
|
|
30
|
+
};
|
|
31
|
+
/** Additional viewport clip. The content box also clips the painted object. */
|
|
32
|
+
readonly clip?: Rect;
|
|
33
|
+
/** Extra intrinsic source pixels around the visible sample, clamped to its bounds. */
|
|
34
|
+
readonly padding?: number;
|
|
35
|
+
readonly dpr?: number;
|
|
36
|
+
/** local -> viewport: box.origin + (a*x + e, d*y + f). Origin adjustments belong in e/f. */
|
|
37
|
+
readonly transform?: AxisTransform;
|
|
38
|
+
}
|
|
39
|
+
export type MappingResult = Readonly<{
|
|
40
|
+
ok: true;
|
|
41
|
+
/** Padded crop, suitable for a core source crop. */
|
|
42
|
+
crop: Rect;
|
|
43
|
+
/** Unpadded visible sample in intrinsic source coordinates. */
|
|
44
|
+
sample: Rect;
|
|
45
|
+
/** Sample origin relative to crop, so padding never stretches the visible output. */
|
|
46
|
+
sampleOffset: Readonly<{
|
|
47
|
+
x: number;
|
|
48
|
+
y: number;
|
|
49
|
+
}>;
|
|
50
|
+
/** Visible output region within backing, in backing pixels; may be fractional. */
|
|
51
|
+
output: Rect;
|
|
52
|
+
backing: Size;
|
|
53
|
+
visible: Rect;
|
|
54
|
+
}> | Readonly<{
|
|
55
|
+
ok: false;
|
|
56
|
+
code: 'INVALID_GEOMETRY' | 'UNSUPPORTED_TRANSFORM' | 'EMPTY_CLIP';
|
|
57
|
+
message: string;
|
|
58
|
+
}>;
|
|
59
|
+
/** Pure geometry; callers supply fresh viewport boxes on scroll/resize. No DOM capture. */
|
|
60
|
+
export declare function mapSourceRegion(input: MappingInput): MappingResult;
|
package/dist/mapping.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { MAX_PIXELS } from '@liquid-glass-forge/core';
|
|
2
|
+
const frozenRect = (r) => Object.freeze(r);
|
|
3
|
+
const fail = (code, message) => Object.freeze({ ok: false, code, message });
|
|
4
|
+
const positive = (n) => Number.isFinite(n) && n > 0;
|
|
5
|
+
function validRect(r) {
|
|
6
|
+
return !!r && Number.isFinite(r.x) && Number.isFinite(r.y) && positive(r.width) && positive(r.height) &&
|
|
7
|
+
Number.isFinite(r.x + r.width) && Number.isFinite(r.y + r.height);
|
|
8
|
+
}
|
|
9
|
+
function intersection(a, b) {
|
|
10
|
+
const x = Math.max(a.x, b.x), y = Math.max(a.y, b.y);
|
|
11
|
+
return { x, y, width: Math.max(0, Math.min(a.x + a.width, b.x + b.width) - x),
|
|
12
|
+
height: Math.max(0, Math.min(a.y + a.height, b.y + b.height) - y) };
|
|
13
|
+
}
|
|
14
|
+
/** Pure geometry; callers supply fresh viewport boxes on scroll/resize. No DOM capture. */
|
|
15
|
+
export function mapSourceRegion(input) {
|
|
16
|
+
if (!input || !input.source || !Number.isSafeInteger(input.source.width) || !Number.isSafeInteger(input.source.height) ||
|
|
17
|
+
!positive(input.source.width) || !positive(input.source.height) || input.source.width * input.source.height > MAX_PIXELS ||
|
|
18
|
+
!validRect(input.box) || !validRect(input.target) || (input.clip !== undefined && !validRect(input.clip))) {
|
|
19
|
+
return fail('INVALID_GEOMETRY', 'Expected bounded intrinsic dimensions and finite positive viewport rectangles');
|
|
20
|
+
}
|
|
21
|
+
const { source, box, target } = input;
|
|
22
|
+
const t = input.transform ?? { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
|
|
23
|
+
if (![t.a, t.b, t.c, t.d, t.e, t.f].every(Number.isFinite) || t.a <= 0 || t.d <= 0 || t.b !== 0 || t.c !== 0 ||
|
|
24
|
+
Object.keys(t).some(key => !['a', 'b', 'c', 'd', 'e', 'f'].includes(key))) {
|
|
25
|
+
return fail('UNSUPPORTED_TRANSFORM', 'Only finite positive axis scale and translation are supported');
|
|
26
|
+
}
|
|
27
|
+
const dpr = input.dpr ?? 1, padding = input.padding ?? 0;
|
|
28
|
+
const position = input.position ?? { x: 0.5, y: 0.5 };
|
|
29
|
+
if (!positive(dpr) || !Number.isFinite(padding) || padding < 0 ||
|
|
30
|
+
![position.x, position.y, position.offsetX ?? 0, position.offsetY ?? 0].every(Number.isFinite)) {
|
|
31
|
+
return fail('INVALID_GEOMETRY', 'Expected finite position, nonnegative source padding and positive DPR');
|
|
32
|
+
}
|
|
33
|
+
const backing = { width: Math.round(target.width * dpr), height: Math.round(target.height * dpr) };
|
|
34
|
+
if (!Number.isSafeInteger(backing.width) || !Number.isSafeInteger(backing.height) || backing.width < 1 || backing.height < 1 ||
|
|
35
|
+
backing.width * backing.height > MAX_PIXELS)
|
|
36
|
+
return fail('INVALID_GEOMETRY', 'Output backing exceeds the core pixel limit');
|
|
37
|
+
let width, height;
|
|
38
|
+
const contain = Math.min(box.width / source.width, box.height / source.height);
|
|
39
|
+
switch (input.fit ?? 'fill') {
|
|
40
|
+
case 'fill':
|
|
41
|
+
width = box.width;
|
|
42
|
+
height = box.height;
|
|
43
|
+
break;
|
|
44
|
+
case 'contain':
|
|
45
|
+
width = source.width * contain;
|
|
46
|
+
height = source.height * contain;
|
|
47
|
+
break;
|
|
48
|
+
case 'cover': {
|
|
49
|
+
const scale = Math.max(box.width / source.width, box.height / source.height);
|
|
50
|
+
width = source.width * scale;
|
|
51
|
+
height = source.height * scale;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
case 'none':
|
|
55
|
+
width = source.width;
|
|
56
|
+
height = source.height;
|
|
57
|
+
break;
|
|
58
|
+
case 'scale-down':
|
|
59
|
+
width = source.width * Math.min(1, contain);
|
|
60
|
+
height = source.height * Math.min(1, contain);
|
|
61
|
+
break;
|
|
62
|
+
default: return fail('INVALID_GEOMETRY', 'Unsupported object-fit');
|
|
63
|
+
}
|
|
64
|
+
const object = { x: box.x + t.e + ((box.width - width) * position.x + (position.offsetX ?? 0)) * t.a,
|
|
65
|
+
y: box.y + t.f + ((box.height - height) * position.y + (position.offsetY ?? 0)) * t.d,
|
|
66
|
+
width: width * t.a, height: height * t.d };
|
|
67
|
+
const viewportBox = { x: box.x + t.e, y: box.y + t.f, width: box.width * t.a, height: box.height * t.d };
|
|
68
|
+
if (!validRect(object) || !validRect(viewportBox))
|
|
69
|
+
return fail('INVALID_GEOMETRY', 'Transformed geometry overflow');
|
|
70
|
+
let visible = intersection(intersection(target, object), viewportBox);
|
|
71
|
+
if (input.clip)
|
|
72
|
+
visible = intersection(visible, input.clip);
|
|
73
|
+
if (visible.width <= 0 || visible.height <= 0)
|
|
74
|
+
return fail('EMPTY_CLIP', 'No readable source intersects the target');
|
|
75
|
+
const clampX = (x) => Math.min(source.width, Math.max(0, x));
|
|
76
|
+
const clampY = (y) => Math.min(source.height, Math.max(0, y));
|
|
77
|
+
const x = clampX((visible.x - object.x) / object.width * source.width);
|
|
78
|
+
const y = clampY((visible.y - object.y) / object.height * source.height);
|
|
79
|
+
const right = clampX((visible.x + visible.width - object.x) / object.width * source.width);
|
|
80
|
+
const bottom = clampY((visible.y + visible.height - object.y) / object.height * source.height);
|
|
81
|
+
const sample = { x, y, width: right - x, height: bottom - y };
|
|
82
|
+
if (sample.width <= 0 || sample.height <= 0)
|
|
83
|
+
return fail('EMPTY_CLIP', 'Source intersection has no representable area');
|
|
84
|
+
const cropX = clampX(x - padding), cropY = clampY(y - padding);
|
|
85
|
+
const crop = { x: cropX, y: cropY, width: clampX(right + padding) - cropX, height: clampY(bottom + padding) - cropY };
|
|
86
|
+
const output = { x: (visible.x - target.x) / target.width * backing.width,
|
|
87
|
+
y: (visible.y - target.y) / target.height * backing.height,
|
|
88
|
+
width: visible.width / target.width * backing.width, height: visible.height / target.height * backing.height };
|
|
89
|
+
return Object.freeze({ ok: true, crop: frozenRect(crop), sample: frozenRect(sample),
|
|
90
|
+
sampleOffset: Object.freeze({ x: x - cropX, y: y - cropY }), output: frozenRect(output),
|
|
91
|
+
backing: Object.freeze(backing), visible: frozenRect(visible) });
|
|
92
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type PixelBytes, type PixelSource } from '@liquid-glass-forge/core';
|
|
2
|
+
export type SourceErrorCode = 'INVALID_SOURCE' | 'UNSUPPORTED_SOURCE' | 'UNREADABLE_SOURCE' | 'UNAVAILABLE_SOURCE' | 'DECODE_FAILED' | 'ABORTED' | 'TIMEOUT' | 'SUPERSEDED' | 'PAUSED' | 'DESTROYED';
|
|
3
|
+
export declare class SourceError extends Error {
|
|
4
|
+
readonly code: SourceErrorCode;
|
|
5
|
+
constructor(code: SourceErrorCode, message: string, options?: ErrorOptions);
|
|
6
|
+
}
|
|
7
|
+
export type SourceSpec = {
|
|
8
|
+
readonly kind: 'pixels';
|
|
9
|
+
readonly width: number;
|
|
10
|
+
readonly height: number;
|
|
11
|
+
readonly data: PixelBytes;
|
|
12
|
+
} | {
|
|
13
|
+
readonly kind: 'image-data';
|
|
14
|
+
readonly value: ImageData;
|
|
15
|
+
} | {
|
|
16
|
+
readonly kind: 'bitmap';
|
|
17
|
+
readonly value: ImageBitmap;
|
|
18
|
+
} | {
|
|
19
|
+
readonly kind: 'canvas';
|
|
20
|
+
readonly value: HTMLCanvasElement | OffscreenCanvas;
|
|
21
|
+
} | {
|
|
22
|
+
readonly kind: 'video';
|
|
23
|
+
readonly value: HTMLVideoElement;
|
|
24
|
+
} | {
|
|
25
|
+
readonly kind: 'image';
|
|
26
|
+
readonly url: string;
|
|
27
|
+
readonly crossOrigin?: 'anonymous' | 'use-credentials' | 'none';
|
|
28
|
+
};
|
|
29
|
+
export interface SourceSnapshot {
|
|
30
|
+
readonly source: PixelSource;
|
|
31
|
+
readonly generation: number;
|
|
32
|
+
readonly revision: number;
|
|
33
|
+
}
|
|
34
|
+
export interface SourceProviderOptions {
|
|
35
|
+
readonly timeoutMs?: number;
|
|
36
|
+
}
|
|
37
|
+
/** Explicit borrowed inputs; read() returns a fresh owned snapshot. Latest read wins. */
|
|
38
|
+
export declare class SourceProvider {
|
|
39
|
+
#private;
|
|
40
|
+
constructor(spec: SourceSpec, options?: SourceProviderOptions);
|
|
41
|
+
get generation(): number;
|
|
42
|
+
get revision(): number;
|
|
43
|
+
get state(): 'ready' | 'paused' | 'destroyed';
|
|
44
|
+
/** Observe invalidation, not pixel delivery. Exceptions from listeners are isolated. */
|
|
45
|
+
subscribe(listener: () => void): () => void;
|
|
46
|
+
replace(spec: SourceSpec): void;
|
|
47
|
+
invalidate(): void;
|
|
48
|
+
pause(): void;
|
|
49
|
+
resume(): void;
|
|
50
|
+
destroy(): void;
|
|
51
|
+
read(options?: {
|
|
52
|
+
readonly signal?: AbortSignal;
|
|
53
|
+
}): Promise<SourceSnapshot>;
|
|
54
|
+
/** Full synchronous RGBA8 capture into caller-owned storage; never transferred or retained. */
|
|
55
|
+
readInto(target: PixelBytes, options?: {
|
|
56
|
+
readonly signal?: AbortSignal;
|
|
57
|
+
}): Promise<SourceSnapshot>;
|
|
58
|
+
}
|