@liquid-glass-forge/cpu 0.0.0-stage → 0.1.0-rc.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/dist/kernel.js ADDED
@@ -0,0 +1,487 @@
1
+ import { ContractError, MAX_PIXELS, decodeSrgb, encodeSrgb, validateSource } from '@liquid-glass-forge/core';
2
+ export const CPU_MATERIAL_MODEL = 'rounded-material-v1';
3
+ const storage = new WeakMap();
4
+ const ENCODING_STEPS = 4096;
5
+ export const CPU_LOOKUP_BYTES = 12296;
6
+ let colourTables;
7
+ /** Shared fixed storage per realm, separate from prepared-source storage. Lazy at first preparation. */
8
+ export function cpuKernelStorageBytes() { return colourTables === undefined ? 0 : CPU_LOOKUP_BYTES; }
9
+ function getColourTables() {
10
+ if (colourTables === undefined) {
11
+ const decoded = new Float64Array(256), bytes = new Uint16Array(ENCODING_STEPS), thresholds = new Float64Array(257);
12
+ for (let byte = 0; byte < decoded.length; byte++)
13
+ decoded[byte] = decodeSrgb(byte / 255);
14
+ for (let byte = 1; byte <= 255; byte++)
15
+ thresholds[byte] = decodeSrgb((byte - .5) / 255);
16
+ thresholds[256] = Infinity;
17
+ for (let i = 0; i < bytes.length; i++) {
18
+ const guess = Math.round(encodeSrgb((i + .5) / ENCODING_STEPS) * 255);
19
+ const low = i / ENCODING_STEPS, high = (i + 1) / ENCODING_STEPS, guard = 8 * Number.EPSILON;
20
+ const sensitive = (low <= thresholds[guess] + guard && high >= thresholds[guess] - guard)
21
+ || (low <= thresholds[guess + 1] + guard && high >= thresholds[guess + 1] - guard);
22
+ bytes[i] = guess | (sensitive ? 256 : 0);
23
+ }
24
+ colourTables = { decoded, bytes, thresholds };
25
+ }
26
+ return colourTables;
27
+ }
28
+ // A bucket spans less than one byte at the maximum sRGB slope. Correct the
29
+ // midpoint guess using inverse thresholds, preserving the original floating
30
+ // encode/round decision when an inverse lands extremely close to a half byte.
31
+ function encodeSrgbByte(value, table) {
32
+ if (value >= 1)
33
+ return 255;
34
+ const entry = table.bytes[Math.floor(value * ENCODING_STEPS)];
35
+ if (entry < 256)
36
+ return entry;
37
+ let byte = entry & 255;
38
+ if (value >= table.thresholds[byte + 1])
39
+ byte++;
40
+ else if (value < table.thresholds[byte])
41
+ byte--;
42
+ if (Math.abs(value - table.thresholds[byte]) <= 8 * Number.EPSILON
43
+ || Math.abs(value - table.thresholds[byte + 1]) <= 8 * Number.EPSILON)
44
+ return Math.round(encodeSrgb(value) * 255);
45
+ return byte;
46
+ }
47
+ const clamp = (v, low = 0, high = 1) => Math.min(high, Math.max(low, v));
48
+ function object(value, allowed, label) {
49
+ if (!value || typeof value !== 'object' || Array.isArray(value))
50
+ throw new ContractError(`${label} must be an object`);
51
+ if (Object.keys(value).some(key => !allowed.includes(key)))
52
+ throw new ContractError(`Unknown ${label} field`);
53
+ }
54
+ function finite(value, low, high, label) {
55
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < low || value > high)
56
+ throw new ContractError(`Invalid ${label}`);
57
+ }
58
+ function dimensions(width, height) {
59
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1 || width * height > MAX_PIXELS)
60
+ throw new ContractError('Invalid CPU dimensions');
61
+ }
62
+ function live(prepared) {
63
+ const entry = storage.get(prepared);
64
+ if (!entry)
65
+ throw new ContractError('Unknown or disposed prepared source');
66
+ return entry;
67
+ }
68
+ /** Snapshots all caller bytes. Only opaque metadata escapes; no caller resource is transferred. */
69
+ export function prepareCpuSource(source) {
70
+ validateSource(source);
71
+ const data = new Float32Array(source.data.length);
72
+ const decoded = getColourTables().decoded;
73
+ let opaque = true;
74
+ for (let i = 0; i < data.length; i += 4) {
75
+ const a = source.data[i + 3] / 255;
76
+ data[i] = decoded[source.data[i]] * a;
77
+ data[i + 1] = decoded[source.data[i + 1]] * a;
78
+ data[i + 2] = decoded[source.data[i + 2]] * a;
79
+ data[i + 3] = a;
80
+ if (a !== 1)
81
+ opaque = false;
82
+ }
83
+ const prepared = Object.freeze({ width: source.width, height: source.height, revision: source.revision });
84
+ storage.set(prepared, { data, opaque });
85
+ return prepared;
86
+ }
87
+ export function disposeCpuSource(prepared) { storage.delete(prepared); }
88
+ export function preparedStorageBytes(prepared) {
89
+ const s = storage.get(prepared);
90
+ return s ? s.data.byteLength + (s.blur?.byteLength ?? 0) + (s.scratch?.byteLength ?? 0) + (s.geometry?.bytes ?? 0) : 0;
91
+ }
92
+ export const CPU_GEOMETRY_MAX_BYTES = 270000;
93
+ function geometryCache(s, w, h, r, b, preferResampling = false) {
94
+ const n = Math.ceil(Math.max(0, r - .5)), bytes = (w + h + n * n * 4) * 8;
95
+ if (w * h < 4096 || bytes > CPU_GEOMETRY_MAX_BYTES) {
96
+ delete s.geometry;
97
+ return undefined;
98
+ }
99
+ const identityBytes = Math.ceil(w * h / 8);
100
+ const sourceResampling = preferResampling && bytes + w * h * 3 + identityBytes <= CPU_GEOMETRY_MAX_BYTES;
101
+ const key = `${w}/${h}/${r}/${b}/${sourceResampling}`;
102
+ if (s.geometry?.key === key)
103
+ return s.geometry;
104
+ const xs = new Float64Array(w), ys = new Float64Array(h), corners = new Float64Array(n * n * 4);
105
+ for (let x = 0; x < w; x++)
106
+ xs[x] = geometry(w, h, r, b, x, Math.floor(h / 2)).weight;
107
+ for (let y = 0; y < h; y++)
108
+ ys[y] = geometry(w, h, r, b, Math.floor(w / 2), y).weight;
109
+ for (let y = 0; y < n; y++)
110
+ for (let x = 0; x < n; x++) {
111
+ const g = geometry(w, h, r, b, w - 1 - x, h - 1 - y), i = (y * n + x) * 4;
112
+ corners[i] = g.coverage;
113
+ corners[i + 1] = g.normalX;
114
+ corners[i + 2] = g.normalY;
115
+ corners[i + 3] = g.weight;
116
+ }
117
+ const identity = sourceResampling ? new Uint8Array(identityBytes) : undefined;
118
+ if (identity)
119
+ for (let y = 0; y < h; y++)
120
+ for (let x = 0; x < w; x++) {
121
+ const g = geometry(w, h, r, b, x, y), pixel = y * w + x;
122
+ if (g.coverage === 1 && g.weight === 0)
123
+ identity[pixel >> 3] |= 1 << (pixel & 7);
124
+ }
125
+ const scratchBytes = (w * 2 + h) * Float64Array.BYTES_PER_ELEMENT;
126
+ const classes = !sourceResampling && s.opaque && bytes + w * h + scratchBytes <= CPU_GEOMETRY_MAX_BYTES ? new Uint8Array(w * h) : undefined;
127
+ if (classes)
128
+ for (let y = 0; y < h; y++)
129
+ for (let x = 0; x < w; x++) {
130
+ const px = x + .5 - w / 2, py = y + .5 - h / 2, ex = Math.min(x, w - 1 - x), ey = Math.min(y, h - 1 - y);
131
+ let kind = 1; // Exact identity region, including a zero-normal center.
132
+ if (ex < n && ey < n)
133
+ kind = Math.round(corners[(ey * n + ex) * 4] * 255) === 0 ? 0 : 6;
134
+ else if (Math.abs(px) - w / 2 > Math.abs(py) - h / 2) {
135
+ if (xs[x] !== 0 && px !== 0)
136
+ kind = px < 0 ? 2 : 3;
137
+ }
138
+ else if (ys[y] !== 0 && py !== 0)
139
+ kind = py < 0 ? 4 : 5;
140
+ classes[y * w + x] = kind;
141
+ }
142
+ return s.geometry = { key, bytes: bytes + (classes?.byteLength ?? 0) + (classes ? scratchBytes : 0) + (identity?.byteLength ?? 0), n, corners, xs, ys, sourceResampling,
143
+ ...(identity ? { identity } : {}),
144
+ ...(classes ? { classes, axes: { baseX: new Float64Array(w), dx: new Float64Array(w), dy: new Float64Array(h) } } : {}) };
145
+ }
146
+ /** Exact source resampling only: independent of mask, coverage, strength and material. */
147
+ function resampledSourceRgb(prepared, data, surface, geo, encoding) {
148
+ const { width, height, sourceRect: rect } = surface;
149
+ const key = `${rect.x}/${rect.y}/${rect.width}/${rect.height}`;
150
+ if (geo.resampled?.key === key)
151
+ return geo.resampled.rgb;
152
+ delete geo.resampled;
153
+ geo.bytes = geo.corners.byteLength + geo.xs.byteLength + geo.ys.byteLength + (geo.identity?.byteLength ?? 0);
154
+ const rgb = new Uint8Array(width * height * 3), stride = prepared.width * 4;
155
+ for (let y = 0; y < height; y++)
156
+ for (let x = 0; x < width; x++) {
157
+ const a = ((rect.y + y * 2) * prepared.width + rect.x + x * 2) * 4, b = a + 4, c = a + stride, d = c + 4;
158
+ const index = (y * width + x) * 3;
159
+ // Same exact Float32-domain half/half mean proved in the classified path.
160
+ for (let k = 0; k < 3; k++)
161
+ rgb[index + k] = encodeSrgbByte((data[a + k] + data[b + k] + data[c + k] + data[d + k]) * .25, encoding);
162
+ }
163
+ geo.bytes += rgb.byteLength;
164
+ geo.resampled = { key, rgb };
165
+ return rgb;
166
+ }
167
+ function cachedGeometry(c, w, h, r, b, x, y) {
168
+ if (!c)
169
+ return geometry(w, h, r, b, x, y);
170
+ const px = x + .5 - w / 2, py = y + .5 - h / 2, ex = Math.min(x, w - 1 - x), ey = Math.min(y, h - 1 - y);
171
+ if (ex < c.n && ey < c.n) {
172
+ const i = (ey * c.n + ex) * 4;
173
+ return { distance: 0, coverage: c.corners[i], normalX: c.corners[i + 1] * Math.sign(px), normalY: c.corners[i + 2] * Math.sign(py), weight: c.corners[i + 3] };
174
+ }
175
+ const dominantX = Math.abs(px) - w / 2 > Math.abs(py) - h / 2;
176
+ return { distance: 0, coverage: 1, normalX: dominantX ? Math.sign(px) : 0, normalY: dominantX ? 0 : Math.sign(py), weight: dominantX ? c.xs[x] : c.ys[y] };
177
+ }
178
+ function geometry(width, height, radius, bevel, x, y) {
179
+ const px = x + .5 - width / 2, py = y + .5 - height / 2;
180
+ const qx = Math.abs(px) - (width / 2 - radius), qy = Math.abs(py) - (height / 2 - radius);
181
+ const ax = Math.max(qx, 0), ay = Math.max(qy, 0), length = ax === 0 ? ay : ay === 0 ? ax : Math.hypot(ax, ay);
182
+ const distance = length + Math.min(Math.max(qx, qy), 0) - radius;
183
+ let normalX = 0, normalY = 0;
184
+ if (length > 0) {
185
+ normalX = ax / length * Math.sign(px);
186
+ normalY = ay / length * Math.sign(py);
187
+ }
188
+ else if (Math.abs(px) - width / 2 > Math.abs(py) - height / 2)
189
+ normalX = Math.sign(px);
190
+ else
191
+ normalY = Math.sign(py);
192
+ return { distance, normalX, normalY, coverage: clamp(.5 - distance), weight: clamp(1 + distance / bevel) ** 2 };
193
+ }
194
+ export function sampleGeometry(width, height, radius, bevel, x, y) {
195
+ dimensions(width, height);
196
+ finite(radius, 0, Math.min(width, height) / 2, 'radius');
197
+ finite(bevel, Number.MIN_VALUE, 1_000_000, 'bevel');
198
+ if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x >= width || y >= height)
199
+ throw new ContractError('Invalid geometry pixel');
200
+ return Object.freeze(geometry(width, height, radius, bevel, x, y));
201
+ }
202
+ function surfaceOptions(input, source) {
203
+ object(input, ['id', 'width', 'height', 'radius', 'bevel', 'strength', 'sourceRect'], 'surface');
204
+ const { width, height } = input;
205
+ dimensions(width, height);
206
+ if (typeof input.id !== 'string' || input.id.length < 1 || input.id.length > 128)
207
+ throw new ContractError('Invalid surface id');
208
+ const radius = input.radius === undefined ? Math.min(width, height) / 4 : input.radius;
209
+ const bevel = input.bevel === undefined ? Math.min(width, height) / 5 : input.bevel, strength = input.strength === undefined ? 12 : input.strength;
210
+ finite(radius, 0, Math.min(width, height) / 2, 'radius');
211
+ finite(bevel, Number.MIN_VALUE, 1_000_000, 'bevel');
212
+ finite(strength, 0, 1_000_000, 'strength');
213
+ const rect = input.sourceRect === undefined ? { x: 0, y: 0, width: source.width, height: source.height } : input.sourceRect;
214
+ object(rect, ['x', 'y', 'width', 'height'], 'sourceRect');
215
+ finite(rect.x, 0, source.width, 'sourceRect.x');
216
+ finite(rect.y, 0, source.height, 'sourceRect.y');
217
+ finite(rect.width, Number.MIN_VALUE, source.width, 'sourceRect.width');
218
+ finite(rect.height, Number.MIN_VALUE, source.height, 'sourceRect.height');
219
+ if (rect.x + rect.width > source.width || rect.y + rect.height > source.height)
220
+ throw new ContractError('Source rectangle exceeds source');
221
+ return { id: input.id, width, height, radius, bevel, strength, sourceRect: rect };
222
+ }
223
+ function effectOptions(input) {
224
+ if (input !== undefined) {
225
+ object(input, ['model', 'blurRadius', 'dispersion', 'tint', 'rim', 'specular', 'light'], 'effects');
226
+ if (input.model !== CPU_MATERIAL_MODEL)
227
+ throw new ContractError('Unsupported CPU material model');
228
+ }
229
+ const blurRadius = input?.blurRadius === undefined ? 0 : input.blurRadius, dispersion = input?.dispersion === undefined ? 0 : input.dispersion;
230
+ const rim = input?.rim === undefined ? 0 : input.rim, specular = input?.specular === undefined ? 0 : input.specular;
231
+ finite(blurRadius, 0, 16, 'blurRadius');
232
+ if (!Number.isInteger(blurRadius))
233
+ throw new ContractError('blurRadius must be an integer');
234
+ finite(dispersion, 0, 1, 'dispersion');
235
+ finite(rim, 0, 1, 'rim');
236
+ finite(specular, 0, 1, 'specular');
237
+ const tint = input?.tint === undefined ? [0, 0, 0, 0] : input.tint;
238
+ const light = input?.light === undefined ? [-Math.SQRT1_2, -Math.SQRT1_2] : input.light;
239
+ if (!Array.isArray(tint) || tint.length !== 4)
240
+ throw new ContractError('Tint requires four normalized channels');
241
+ for (let i = 0; i < 4; i++)
242
+ finite(tint[i], 0, 1, 'tint');
243
+ if (!Array.isArray(light) || light.length !== 2)
244
+ throw new ContractError('Light requires a nonzero finite direction');
245
+ for (let i = 0; i < 2; i++)
246
+ finite(light[i], -Number.MAX_VALUE, Number.MAX_VALUE, 'light');
247
+ const scale = Math.max(Math.abs(light[0]), Math.abs(light[1]));
248
+ if (scale === 0)
249
+ throw new ContractError('Light direction must be nonzero');
250
+ const lx = light[0] / scale, ly = light[1] / scale, length = Math.hypot(lx, ly);
251
+ return { blurRadius, dispersion, tint: [decodeSrgb(tint[0]), decodeSrgb(tint[1]), decodeSrgb(tint[2]), tint[3]], rim, specular, light: [lx / length, ly / length] };
252
+ }
253
+ /** Validate and freeze a data-only snapshot before asynchronous execution. */
254
+ export function snapshotCpuEffects(input) {
255
+ effectOptions(input);
256
+ if (input === undefined)
257
+ return undefined;
258
+ return Object.freeze({ ...input,
259
+ ...(input.tint === undefined ? {} : { tint: Object.freeze([...input.tint]) }),
260
+ ...(input.light === undefined ? {} : { light: Object.freeze([...input.light]) }) });
261
+ }
262
+ /** Two sliding box passes; storage is reused for the next radius, never accumulated. */
263
+ function blurred(source, s, radius) {
264
+ if (radius === 0)
265
+ return s.data;
266
+ if (s.radius === radius)
267
+ return s.blur;
268
+ s.scratch ??= new Float32Array(s.data.length);
269
+ s.blur ??= new Float32Array(s.data.length);
270
+ const width = source.width, height = source.height, divisor = 2 * radius + 1;
271
+ for (let y = 0; y < height; y++)
272
+ for (let c = 0; c < 4; c++) {
273
+ let sum = 0;
274
+ for (let k = -radius; k <= radius; k++)
275
+ sum += s.data[(y * width + clamp(k, 0, width - 1)) * 4 + c];
276
+ for (let x = 0; x < width; x++) {
277
+ s.scratch[(y * width + x) * 4 + c] = sum / divisor;
278
+ sum += s.data[(y * width + clamp(x + radius + 1, 0, width - 1)) * 4 + c] - s.data[(y * width + clamp(x - radius, 0, width - 1)) * 4 + c];
279
+ }
280
+ }
281
+ for (let x = 0; x < width; x++)
282
+ for (let c = 0; c < 4; c++) {
283
+ let sum = 0;
284
+ for (let k = -radius; k <= radius; k++)
285
+ sum += s.scratch[(clamp(k, 0, height - 1) * width + x) * 4 + c];
286
+ for (let y = 0; y < height; y++) {
287
+ s.blur[(y * width + x) * 4 + c] = sum / divisor;
288
+ sum += s.scratch[(clamp(y + radius + 1, 0, height - 1) * width + x) * 4 + c] - s.scratch[(clamp(y - radius, 0, height - 1) * width + x) * 4 + c];
289
+ }
290
+ }
291
+ s.radius = radius;
292
+ return s.blur;
293
+ }
294
+ function sample(data, width, height, x, y, out) {
295
+ const sx = clamp(x, 0, width - 1), sy = clamp(y, 0, height - 1), x0 = Math.floor(sx), y0 = Math.floor(sy);
296
+ const tx = sx - x0, ty = sy - y0;
297
+ const a = (y0 * width + x0) * 4, b = (y0 * width + Math.min(x0 + 1, width - 1)) * 4;
298
+ const c = (Math.min(y0 + 1, height - 1) * width + x0) * 4, d = (Math.min(y0 + 1, height - 1) * width + Math.min(x0 + 1, width - 1)) * 4;
299
+ for (let k = 0; k < 4; k++)
300
+ out[k] = (data[a + k] * (1 - tx) + data[b + k] * tx) * (1 - ty) + (data[c + k] * (1 - tx) + data[d + k] * tx) * ty;
301
+ }
302
+ const byteBrand = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(Uint8Array.prototype), Symbol.toStringTag).get;
303
+ const byteLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength').get;
304
+ /** Classification caches geometry decisions only; optical strength is evaluated on every call. */
305
+ function renderClassifiedOpaque(prepared, surface, data, geo, encoding, output) {
306
+ const { width, height, strength, sourceRect: rect } = surface;
307
+ const sourceWidth = prepared.width, maxX = sourceWidth - 1, maxY = prepared.height - 1;
308
+ const scaleX = rect.width / width, scaleY = rect.height / height, classes = geo.classes, axes = geo.axes;
309
+ const halfTexels = scaleX === 2 && scaleY === 2 && Number.isInteger(rect.x) && Number.isInteger(rect.y);
310
+ const sourceStride = sourceWidth * 4;
311
+ // Scratch is recomputed for current optical state, rather than cached across
312
+ // strength/crop changes. It contains coordinates, never sampled source pixels.
313
+ for (let x = 0; x < width; x++) {
314
+ axes.baseX[x] = rect.x + (x + .5) * scaleX - .5;
315
+ axes.dx[x] = Math.sign(x + .5 - width / 2) * strength * geo.xs[x] * scaleX;
316
+ }
317
+ for (let y = 0; y < height; y++)
318
+ axes.dy[y] = Math.sign(y + .5 - height / 2) * strength * geo.ys[y] * scaleY;
319
+ let index = 0;
320
+ for (let y = 0; y < height; y++) {
321
+ const baseY = rect.y + (y + .5) * scaleY - .5;
322
+ const halfRow = ((rect.y + y * 2) * sourceWidth + rect.x) * 4;
323
+ for (let x = 0; x < width; x++, index += 4) {
324
+ const kind = classes[index / 4];
325
+ if (kind === 0) {
326
+ output[index] = 0;
327
+ output[index + 1] = 0;
328
+ output[index + 2] = 0;
329
+ output[index + 3] = 0;
330
+ continue;
331
+ }
332
+ if (halfTexels && kind === 1) {
333
+ // Opaque sRGB8-decoded Float32 values are multiples of 2^-35.
334
+ // Four sum to <=4, so every addition fits binary64 exactly (<38 bits).
335
+ // This mean equals half/half bilinear interpolation, including byte ties.
336
+ const a = halfRow + x * 8, b = a + 4, c = a + sourceStride, d = c + 4;
337
+ output[index] = encodeSrgbByte((data[a] + data[b] + data[c] + data[d]) * .25, encoding);
338
+ output[index + 1] = encodeSrgbByte((data[a + 1] + data[b + 1] + data[c + 1] + data[d + 1]) * .25, encoding);
339
+ output[index + 2] = encodeSrgbByte((data[a + 2] + data[b + 2] + data[c + 2] + data[d + 2]) * .25, encoding);
340
+ output[index + 3] = 255;
341
+ continue;
342
+ }
343
+ let dx = 0, dy = 0, alphaByte = 255;
344
+ if (kind === 2 || kind === 3)
345
+ dx = axes.dx[x];
346
+ else if (kind === 4 || kind === 5)
347
+ dy = axes.dy[y];
348
+ else if (kind === 6) {
349
+ const ex = Math.min(x, width - 1 - x), ey = Math.min(y, height - 1 - y), at = (ey * geo.n + ex) * 4;
350
+ const nx = geo.corners[at + 1] * Math.sign(x + .5 - width / 2), ny = geo.corners[at + 2] * Math.sign(y + .5 - height / 2);
351
+ const weight = geo.corners[at + 3];
352
+ dx = nx * strength * weight * scaleX;
353
+ dy = ny * strength * weight * scaleY;
354
+ alphaByte = Math.round(geo.corners[at] * 255);
355
+ }
356
+ const baseX = axes.baseX[x];
357
+ const rawX = baseX + dx, rawY = baseY + dy;
358
+ const sx = rawX < 0 ? 0 : rawX > maxX ? maxX : rawX, sy = rawY < 0 ? 0 : rawY > maxY ? maxY : rawY;
359
+ // Validated source dimensions total at most MAX_PIXELS (four million).
360
+ // Clamped nonnegative coordinates therefore fit exact signed32 truncation.
361
+ const x0 = sx | 0, y0 = sy | 0, x1 = x0 < maxX ? x0 + 1 : x0, y1 = y0 < maxY ? y0 + 1 : y0;
362
+ const tx = sx - x0, ty = sy - y0, oneX = 1 - tx, oneY = 1 - ty;
363
+ const a = (y0 * sourceWidth + x0) * 4, b = (y0 * sourceWidth + x1) * 4;
364
+ const c = (y1 * sourceWidth + x0) * 4, d = (y1 * sourceWidth + x1) * 4;
365
+ const red = (data[a] * oneX + data[b] * tx) * oneY + (data[c] * oneX + data[d] * tx) * ty;
366
+ const green = (data[a + 1] * oneX + data[b + 1] * tx) * oneY + (data[c + 1] * oneX + data[d + 1] * tx) * ty;
367
+ const blue = (data[a + 2] * oneX + data[b + 2] * tx) * oneY + (data[c + 2] * oneX + data[d + 2] * tx) * ty;
368
+ output[index + 3] = alphaByte;
369
+ // Convex interpolation is nonnegative; encodeSrgbByte already saturates >=1.
370
+ output[index] = encodeSrgbByte(red, encoding);
371
+ output[index + 1] = encodeSrgbByte(green, encoding);
372
+ output[index + 2] = encodeSrgbByte(blue, encoding);
373
+ }
374
+ }
375
+ return output;
376
+ }
377
+ /** Pure JavaScript optics, straight-alpha sRGB RGBA8 output. No Canvas or WebGL access. */
378
+ export function renderCpu(prepared, input, effects, output) {
379
+ const s = live(prepared), surface = surfaceOptions(input, prepared), e = effectOptions(effects);
380
+ const { width, height, radius, bevel, strength, sourceRect: rect } = surface;
381
+ if (output === undefined)
382
+ output = new Uint8ClampedArray(width * height * 4);
383
+ try {
384
+ byteLength.call(output.buffer);
385
+ }
386
+ catch {
387
+ throw new ContractError('CPU output requires an attached unshared buffer');
388
+ }
389
+ if (byteBrand.call(output) !== 'Uint8ClampedArray' || output.byteLength !== width * height * 4)
390
+ throw new ContractError('Invalid CPU output storage');
391
+ const data = blurred(prepared, s, e.blurRadius), scaleX = rect.width / width, scaleY = rect.height / height;
392
+ const encoding = getColourTables();
393
+ const baseline = e.blurRadius === 0 && e.dispersion === 0 && e.tint[3] === 0 && e.rim === 0 && e.specular === 0;
394
+ const preferResampling = baseline && s.opaque && scaleX === 2 && scaleY === 2 && Number.isInteger(rect.x) && Number.isInteger(rect.y);
395
+ const geo = baseline ? geometryCache(s, width, height, radius, bevel, preferResampling) : undefined;
396
+ const sourceRgb = geo?.sourceResampling ? resampledSourceRgb(prepared, data, surface, geo, encoding) : undefined;
397
+ if (baseline && s.opaque && geo?.classes)
398
+ return renderClassifiedOpaque(prepared, surface, data, geo, encoding, output);
399
+ const center = [0, 0, 0, 0], shifted = [0, 0, 0, 0], rgb = [0, 0, 0];
400
+ for (let y = 0; y < height; y++)
401
+ for (let x = 0; x < width; x++) {
402
+ const index = (y * width + x) * 4;
403
+ const pixel = y * width + x;
404
+ if (sourceRgb && (geo.identity[pixel >> 3] & (1 << (pixel & 7)))) {
405
+ const at = pixel * 3;
406
+ output[index] = sourceRgb[at];
407
+ output[index + 1] = sourceRgb[at + 1];
408
+ output[index + 2] = sourceRgb[at + 2];
409
+ output[index + 3] = 255;
410
+ continue;
411
+ }
412
+ let coverage = 1, normalX = 0, normalY = 0, weight = 0;
413
+ if (baseline && geo) {
414
+ const px = x + .5 - width / 2, py = y + .5 - height / 2, ex = Math.min(x, width - 1 - x), ey = Math.min(y, height - 1 - y);
415
+ if (ex < geo.n && ey < geo.n) {
416
+ const i = (ey * geo.n + ex) * 4;
417
+ coverage = geo.corners[i];
418
+ normalX = geo.corners[i + 1] * Math.sign(px);
419
+ normalY = geo.corners[i + 2] * Math.sign(py);
420
+ weight = geo.corners[i + 3];
421
+ }
422
+ else if (Math.abs(px) - width / 2 > Math.abs(py) - height / 2) {
423
+ normalX = Math.sign(px);
424
+ weight = geo.xs[x];
425
+ }
426
+ else {
427
+ normalY = Math.sign(py);
428
+ weight = geo.ys[y];
429
+ }
430
+ }
431
+ else {
432
+ const g = geometry(width, height, radius, bevel, x, y);
433
+ coverage = g.coverage;
434
+ normalX = g.normalX;
435
+ normalY = g.normalY;
436
+ weight = g.weight;
437
+ }
438
+ if (coverage === 0) {
439
+ output.fill(0, index, index + 4);
440
+ continue;
441
+ }
442
+ const baseX = rect.x + (x + .5) * scaleX - .5, baseY = rect.y + (y + .5) * scaleY - .5;
443
+ const dx = normalX * strength * weight * scaleX, dy = normalY * strength * weight * scaleY;
444
+ if (sourceRgb && dx === 0 && dy === 0) {
445
+ output[index + 3] = Math.round(coverage * 255);
446
+ const at = (y * width + x) * 3;
447
+ output[index] = output[index + 3] === 0 ? 0 : sourceRgb[at];
448
+ output[index + 1] = output[index + 3] === 0 ? 0 : sourceRgb[at + 1];
449
+ output[index + 2] = output[index + 3] === 0 ? 0 : sourceRgb[at + 2];
450
+ continue;
451
+ }
452
+ if (baseline && s.opaque) {
453
+ const sx = clamp(baseX + dx, 0, prepared.width - 1), sy = clamp(baseY + dy, 0, prepared.height - 1), x0 = Math.floor(sx), y0 = Math.floor(sy), tx = sx - x0, ty = sy - y0;
454
+ const a = (y0 * prepared.width + x0) * 4, b = (y0 * prepared.width + Math.min(x0 + 1, prepared.width - 1)) * 4, c = (Math.min(y0 + 1, prepared.height - 1) * prepared.width + x0) * 4, d = (Math.min(y0 + 1, prepared.height - 1) * prepared.width + Math.min(x0 + 1, prepared.width - 1)) * 4;
455
+ output[index + 3] = Math.round(coverage * 255);
456
+ for (let k = 0; k < 3; k++)
457
+ output[index + k] = output[index + 3] === 0 ? 0 : encodeSrgbByte(clamp((data[a + k] * (1 - tx) + data[b + k] * tx) * (1 - ty) + (data[c + k] * (1 - tx) + data[d + k] * tx) * ty), encoding);
458
+ continue;
459
+ }
460
+ sample(data, prepared.width, prepared.height, baseX + dx, baseY + dy, center);
461
+ const alpha = center[3];
462
+ output[index + 3] = Math.round(clamp(alpha) * coverage * 255);
463
+ if (output[index + 3] === 0) {
464
+ output.fill(0, index, index + 3);
465
+ continue;
466
+ }
467
+ for (let c = 0; c < 3; c++)
468
+ rgb[c] = alpha > 0 ? clamp(center[c] / alpha) : 0;
469
+ if (e.dispersion > 0)
470
+ for (const c of [0, 2]) {
471
+ const multiplier = c === 0 ? 1 + e.dispersion : 1 - e.dispersion;
472
+ sample(data, prepared.width, prepared.height, baseX + dx * multiplier, baseY + dy * multiplier, shifted);
473
+ rgb[c] = shifted[3] > 0 ? clamp(shifted[c] / shifted[3]) : 0;
474
+ }
475
+ if (baseline) {
476
+ for (let c = 0; c < 3; c++)
477
+ output[index + c] = encodeSrgbByte(rgb[c], encoding);
478
+ continue;
479
+ }
480
+ const highlight = clamp(weight * (e.rim + e.specular * Math.max(normalX * e.light[0] + normalY * e.light[1], 0) ** 8));
481
+ for (let c = 0; c < 3; c++) {
482
+ const tinted = rgb[c] * (1 - e.tint[3]) + e.tint[c] * e.tint[3];
483
+ output[index + c] = encodeSrgbByte(clamp(tinted + (1 - tinted) * highlight), encoding);
484
+ }
485
+ }
486
+ return output;
487
+ }
@@ -0,0 +1,45 @@
1
+ import type { FrameDescriptor, PixelSource } from '@liquid-glass-forge/core';
2
+ import type { CpuEffects, PreparedCpuSource } from './kernel.js';
3
+ export interface CpuSurfacePixels {
4
+ readonly id: string;
5
+ readonly width: number;
6
+ readonly height: number;
7
+ readonly data: Uint8ClampedArray;
8
+ }
9
+ export interface CpuTimings {
10
+ readonly scheduledWaitMs?: number;
11
+ readonly startedAt?: number;
12
+ readonly snapshotMs: number;
13
+ readonly transferCopyMs: number;
14
+ readonly prepareMs: number;
15
+ readonly kernelMs: number;
16
+ readonly transferSubmitMs: number;
17
+ readonly roundTripMs: number;
18
+ }
19
+ export interface BatchPixels {
20
+ readonly generation: number;
21
+ readonly revision: number;
22
+ readonly sourceRevision: number;
23
+ readonly retainedBytes?: number;
24
+ readonly surfaces: readonly CpuSurfacePixels[];
25
+ readonly timings: CpuTimings;
26
+ }
27
+ export interface CpuWork {
28
+ readonly cacheSources?: boolean;
29
+ readonly type: 'render';
30
+ readonly id: number;
31
+ readonly source: PixelSource;
32
+ readonly frame: FrameDescriptor;
33
+ readonly effects?: CpuEffects;
34
+ readonly outputs: readonly Uint8ClampedArray[];
35
+ }
36
+ export declare function checkedFrame(source: PixelSource, frame: FrameDescriptor): FrameDescriptor;
37
+ /** Same batch implementation is used in the main thread and the standalone worker graph. */
38
+ export declare class CpuBatchSession {
39
+ #private;
40
+ get storageBytes(): number;
41
+ prepare(source: PixelSource, frame: FrameDescriptor): PreparedCpuSource;
42
+ run(work: CpuWork): BatchPixels;
43
+ dispose(): void;
44
+ }
45
+ export declare function runCpuBatch(work: CpuWork, session?: CpuBatchSession): BatchPixels;
@@ -0,0 +1,42 @@
1
+ import { createFrame, OPTICAL_MODEL, validateSource } from '@liquid-glass-forge/core';
2
+ import { prepareCpuSource, renderCpu, disposeCpuSource, preparedStorageBytes } from './kernel.js';
3
+ export function checkedFrame(source, frame) {
4
+ validateSource(source);
5
+ if (!frame || frame.opticalModel !== OPTICAL_MODEL || Object.keys(frame).some(k => !['contractVersion', 'opticalModel', 'generation', 'revision', 'sourceRevision', 'surfaces'].includes(k)))
6
+ throw new TypeError('Invalid CPU frame model');
7
+ return createFrame({ contractVersion: frame.contractVersion, generation: frame.generation, revision: frame.revision, sourceRevision: frame.sourceRevision, surfaces: frame.surfaces }, source);
8
+ }
9
+ /** Same batch implementation is used in the main thread and the standalone worker graph. */
10
+ export class CpuBatchSession {
11
+ #key;
12
+ #source;
13
+ get storageBytes() { return this.#source ? preparedStorageBytes(this.#source) : 0; }
14
+ prepare(source, frame) {
15
+ const key = `${frame.generation}/${frame.sourceRevision}/${source.width}/${source.height}`;
16
+ if (this.#key !== key) {
17
+ this.dispose();
18
+ this.#source = prepareCpuSource(source);
19
+ this.#key = key;
20
+ }
21
+ return this.#source;
22
+ }
23
+ run(work) { return runCpuBatch(work, this); }
24
+ dispose() { if (this.#source)
25
+ disposeCpuSource(this.#source); this.#source = undefined; this.#key = undefined; }
26
+ }
27
+ export function runCpuBatch(work, session) {
28
+ const frame = checkedFrame(work.source, work.frame), start = performance.now(), prepared = session ? session.prepare(work.source, frame) : prepareCpuSource(work.source);
29
+ const prepareMs = performance.now() - start, kernelStart = performance.now();
30
+ try {
31
+ if (work.outputs.length !== frame.surfaces.length)
32
+ throw new TypeError('Invalid CPU output count');
33
+ const surfaces = frame.surfaces.map((surface, i) => ({ id: surface.id, width: surface.width, height: surface.height,
34
+ data: renderCpu(prepared, surface, work.effects, work.outputs[i]) }));
35
+ return { generation: frame.generation, revision: frame.revision, sourceRevision: frame.sourceRevision, surfaces, retainedBytes: session ? preparedStorageBytes(prepared) : 0,
36
+ timings: { snapshotMs: 0, transferCopyMs: 0, prepareMs, kernelMs: performance.now() - kernelStart, transferSubmitMs: 0, roundTripMs: 0 } };
37
+ }
38
+ finally {
39
+ if (!session)
40
+ disposeCpuSource(prepared);
41
+ }
42
+ }