@liquid-glass-forge/cpu 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nami Poursafargholi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,7 @@
1
+ Liquid Glass Forge
2
+ Copyright (c) 2026 Nami Poursafargholi. MIT licensed.
3
+
4
+ Independent implementation inspired by Apple's Liquid Glass design.
5
+ Not affiliated with, sponsored by or endorsed by Apple Inc.
6
+ Apple and its design/assets remain subject to their respective rights.
7
+ No Apple assets or upstream Liquid Glass/Sileo implementation is included.
package/README.md CHANGED
@@ -1,3 +1,43 @@
1
- # Temporary Holding Version
1
+ # @liquid-glass-forge/cpu
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Bounded JavaScript glass rendering with optional worker execution.
4
+
5
+ [![A live glass lens bends the lettering beneath it](https://raw.githubusercontent.com/namipsg/liquid-glass-forge/main/docs/assets/glass-playground.gif)](https://namipsg.github.io/liquid-glass-forge/#playground)
6
+
7
+ [Live showcase](https://namipsg.github.io/liquid-glass-forge/#playground) · [Examples](https://namipsg.github.io/liquid-glass-forge/examples.html) · [Documentation](https://github.com/namipsg/liquid-glass-forge/blob/main/docs/PHASE-5.md) · [Support](https://namipsg.github.io/liquid-glass-forge/support.html) · [Install by framework](https://github.com/namipsg/liquid-glass-forge/blob/main/docs/QUICKSTART.md)
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install @liquid-glass-forge/cpu@0.1.0-rc.2
13
+ ```
14
+
15
+ Keep the Forge packages at matching versions. This is an experimental preview.
16
+ The [support guide](https://namipsg.github.io/liquid-glass-forge/support.html) describes browser and device qualification.
17
+
18
+ ## Usage
19
+
20
+ Experimental preview JavaScript CPU optics, version `0.1.0-rc.2`.
21
+ Uses explicit core RGBA8 sources. Imports do not acquire browser resources.
22
+ `prepareCpuSource` snapshots pixels into private linear premultiplied storage;
23
+ `renderCpu` implements `rounded-refraction-v1`, optionally extending it with
24
+ the documented `rounded-material-v1` blur, dispersion, tint and highlights.
25
+ `disposeCpuSource` releases prepared storage. Reusable caller output is optional.
26
+
27
+ `CpuExecutor` snapshots borrowed input before yielding, shares a bounded pool
28
+ between output buffers and two private input buffers, and optionally retains one
29
+ prepared source with `cacheSources:true`. Advance source revision/generation when
30
+ pixels or the source change. Release returned batches when finished.
31
+ Optional `notBefore`/`onStart` schedule actual execution after the owned capture.
32
+ Prepared geometry and exact 2× source resampling share a 270,000-byte allowance;
33
+ changing optical strength still computes refraction on every frame.
34
+
35
+ See `docs/PHASE-5.md` in the project for equations, acceptance and current limits.
36
+ Implementation tests do not establish new physical-device admission.
37
+
38
+
39
+ The exported `@liquid-glass-forge/cpu/worker.js` entry registers the worker message
40
+ handler and is marked as side-effectful for bundlers. The package root remains
41
+ safe to import on the server. A consumer-owned worker entry may import that public
42
+ subpath and pass a statically bundled `Worker` through `workerFactory`. Phase8's
43
+ packed Vue/Nuxt tests exercise cancellation of a real completed worker frame.
@@ -0,0 +1,63 @@
1
+ import type { FrameDescriptor, PixelSource } from '@liquid-glass-forge/core';
2
+ import type { CpuEffects } from './kernel.js';
3
+ import type { BatchPixels } from './protocol.js';
4
+ export declare class CpuError extends Error {
5
+ readonly code: string;
6
+ constructor(code: string, message: string, options?: ErrorOptions);
7
+ }
8
+ /** Active-only deadline delivery. No idle timers/messages; lazy ports close on disposal. */
9
+ export declare class CpuDeadlineScheduler {
10
+ #private;
11
+ waitUntil(deadline: number, signal?: AbortSignal): Promise<number>;
12
+ dispose(): void;
13
+ }
14
+ export interface CpuWorker {
15
+ onmessage: ((event: MessageEvent) => void) | null;
16
+ onerror: ((event: ErrorEvent) => void) | null;
17
+ onmessageerror: ((event: MessageEvent) => void) | null;
18
+ postMessage(message: unknown, transfer: Transferable[]): void;
19
+ terminate(): void;
20
+ }
21
+ export interface CpuExecutorOptions {
22
+ readonly cacheSources?: boolean;
23
+ readonly worker?: 'auto' | 'required' | 'off';
24
+ readonly workerFactory?: () => CpuWorker;
25
+ readonly timeoutMs?: number;
26
+ readonly maxPoolBytes?: number;
27
+ readonly maxOwnedBytes?: number;
28
+ }
29
+ export interface CpuBatch extends BatchPixels {
30
+ release(): void;
31
+ }
32
+ /** No eager worker or idle clock. Each accepted request snapshots borrowed input before yielding. */
33
+ export declare class CpuExecutor {
34
+ #private;
35
+ constructor(options?: CpuExecutorOptions);
36
+ get diagnostics(): Readonly<{
37
+ execution: "main" | "worker";
38
+ inFlight: 0 | 1;
39
+ pending: 0 | 1;
40
+ mainCachedBytes: number;
41
+ workerCachedBytes: number;
42
+ sharedKernelBytes: number;
43
+ fixedKernelReservationBytes: number;
44
+ pooledBytes: number;
45
+ inputPooledBytes: number;
46
+ inputPooledBuffers: number;
47
+ leasedBytes: number;
48
+ reservedBytes: number;
49
+ lostBytes: number;
50
+ completedBatches: number;
51
+ discardedBatches: number;
52
+ disposed: boolean;
53
+ fallbackReason?: string;
54
+ }>;
55
+ render(source: PixelSource, frame: FrameDescriptor, options?: {
56
+ readonly effects?: CpuEffects;
57
+ readonly signal?: AbortSignal;
58
+ /** Monotonic performance.now() deadline, at most 60 seconds ahead. Capture precedes this wait. */
59
+ readonly notBefore?: number;
60
+ readonly onStart?: (startedAt: number) => void;
61
+ }): Promise<CpuBatch>;
62
+ dispose(): void;
63
+ }
@@ -0,0 +1,492 @@
1
+ import { createSource, validateSource } from '@liquid-glass-forge/core';
2
+ import { snapshotCpuEffects, CPU_LOOKUP_BYTES, cpuKernelStorageBytes, CPU_GEOMETRY_MAX_BYTES } from './kernel.js';
3
+ import { checkedFrame, runCpuBatch, CpuBatchSession } from './protocol.js';
4
+ export class CpuError extends Error {
5
+ code;
6
+ constructor(code, message, options) {
7
+ super(message, options);
8
+ this.code = code;
9
+ this.name = 'CpuError';
10
+ }
11
+ }
12
+ /** Active-only deadline delivery. No idle timers/messages; lazy ports close on disposal. */
13
+ export class CpuDeadlineScheduler {
14
+ #channel;
15
+ #generation = 0;
16
+ #disposed = false;
17
+ #active;
18
+ #cancel;
19
+ waitUntil(deadline, signal) {
20
+ if (this.#disposed)
21
+ return Promise.reject(new CpuError('DISPOSED', 'Deadline scheduler disposed'));
22
+ if (typeof deadline !== 'number' || !Number.isFinite(deadline) || deadline < 0 || deadline > performance.now() + 60000)
23
+ return Promise.reject(new CpuError('INVALID_OPTIONS', 'Invalid deadline'));
24
+ if (signal !== undefined && (!signal || typeof signal.addEventListener !== 'function' || typeof signal.removeEventListener !== 'function'))
25
+ return Promise.reject(new CpuError('INVALID_OPTIONS', 'Invalid deadline signal'));
26
+ if (signal?.aborted)
27
+ return Promise.reject(signal.reason);
28
+ this.#cancel?.(new CpuError('SUPERSEDED', 'Deadline wait superseded'));
29
+ if (deadline <= performance.now())
30
+ return Promise.resolve(0);
31
+ const id = ++this.#generation, start = performance.now();
32
+ return new Promise((resolve, reject) => {
33
+ let timer, done = false;
34
+ const finish = (cause) => {
35
+ if (done)
36
+ return;
37
+ done = true;
38
+ clearTimeout(timer);
39
+ signal?.removeEventListener('abort', abort);
40
+ if (this.#active?.id === id) {
41
+ this.#active = undefined;
42
+ this.#cancel = undefined;
43
+ }
44
+ if (cause !== undefined)
45
+ reject(cause);
46
+ else
47
+ resolve(performance.now() - start);
48
+ };
49
+ const abort = () => finish(signal?.reason ?? new CpuError('ABORTED', 'Deadline wait aborted'));
50
+ const check = () => {
51
+ if (done)
52
+ return;
53
+ clearTimeout(timer);
54
+ timer = undefined;
55
+ if (signal?.aborted) {
56
+ abort();
57
+ return;
58
+ }
59
+ const remaining = deadline - performance.now();
60
+ if (remaining <= 0) {
61
+ finish();
62
+ return;
63
+ }
64
+ if (remaining > 8 || typeof MessageChannel !== 'function') {
65
+ // Leave room for browser timer coalescing before the active-only
66
+ // posted-task tail. No ports or callbacks run once a wait settles.
67
+ timer = setTimeout(check, Math.max(1, Math.floor(remaining - 8)));
68
+ return;
69
+ }
70
+ if (!this.#channel) {
71
+ this.#channel = new MessageChannel();
72
+ this.#channel.port1.onmessage = event => { const active = this.#active; if (active && active.id === event.data)
73
+ active.check(); };
74
+ }
75
+ this.#channel.port2.postMessage(id);
76
+ };
77
+ this.#active = { id, check };
78
+ this.#cancel = finish;
79
+ signal?.addEventListener('abort', abort, { once: true });
80
+ check();
81
+ });
82
+ }
83
+ dispose() {
84
+ if (this.#disposed)
85
+ return;
86
+ this.#disposed = true;
87
+ this.#cancel?.(new CpuError('DISPOSED', 'Deadline scheduler disposed'));
88
+ const channel = this.#channel;
89
+ this.#channel = undefined;
90
+ if (channel) {
91
+ channel.port1.onmessage = null;
92
+ try {
93
+ channel.port1.close();
94
+ }
95
+ catch { /* Close the other port independently. */ }
96
+ try {
97
+ channel.port2.close();
98
+ }
99
+ catch { /* No new messages are posted after disposal. */ }
100
+ }
101
+ }
102
+ }
103
+ function integer(value, low, high) { return Number.isSafeInteger(value) && value >= low && value <= high; }
104
+ /** No eager worker or idle clock. Each accepted request snapshots borrowed input before yielding. */
105
+ export class CpuExecutor {
106
+ #options;
107
+ #cache = new CpuBatchSession();
108
+ #workerCachedBytes = 0;
109
+ #scheduler = new CpuDeadlineScheduler();
110
+ #worker;
111
+ #broken = false;
112
+ #disposed = false;
113
+ #fallback;
114
+ #active;
115
+ #pending;
116
+ #id = 0;
117
+ #latest;
118
+ #pool = [];
119
+ #poolBytes = 0;
120
+ #inputPool = [];
121
+ #leased = 0;
122
+ #reserved = 0;
123
+ #lost = 0;
124
+ #remote = 0;
125
+ #completed = 0;
126
+ #discarded = 0;
127
+ #execution = 'main';
128
+ #workerReject;
129
+ constructor(options = {}) {
130
+ if (!options || typeof options !== 'object' || Array.isArray(options))
131
+ throw new CpuError('INVALID_OPTIONS', 'Invalid CPU executor options');
132
+ const worker = options.worker === undefined ? 'auto' : options.worker, timeoutMs = options.timeoutMs === undefined ? 5000 : options.timeoutMs;
133
+ const maxOwnedBytes = options.maxOwnedBytes === undefined ? 64 * 1024 * 1024 : options.maxOwnedBytes, maxPoolBytes = options.maxPoolBytes === undefined ? Math.min(8 * 1024 * 1024, maxOwnedBytes) : options.maxPoolBytes;
134
+ if (!options || typeof options !== 'object' || Object.keys(options).some(k => !['cacheSources', 'worker', 'workerFactory', 'timeoutMs', 'maxOwnedBytes', 'maxPoolBytes'].includes(k)) ||
135
+ !['auto', 'required', 'off'].includes(worker) || !Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > 60000 ||
136
+ !integer(maxOwnedBytes, 1, 256 * 1024 * 1024) || !integer(maxPoolBytes, 0, maxOwnedBytes) ||
137
+ (options.workerFactory !== undefined && typeof options.workerFactory !== 'function'))
138
+ throw new CpuError('INVALID_OPTIONS', 'Invalid CPU executor options');
139
+ if (options.cacheSources !== undefined && typeof options.cacheSources !== 'boolean')
140
+ throw new CpuError('INVALID_OPTIONS', 'Invalid source cache policy');
141
+ this.#options = { ...options, cacheSources: options.cacheSources ?? false, worker, timeoutMs, maxOwnedBytes, maxPoolBytes };
142
+ }
143
+ get diagnostics() {
144
+ return Object.freeze({ execution: this.#execution, inFlight: this.#active ? 1 : 0, pending: this.#pending ? 1 : 0,
145
+ mainCachedBytes: this.#cache.storageBytes, workerCachedBytes: this.#workerCachedBytes,
146
+ sharedKernelBytes: cpuKernelStorageBytes(), fixedKernelReservationBytes: CPU_LOOKUP_BYTES * (this.#options.worker === 'off' ? 1 : 2),
147
+ pooledBytes: this.#poolBytes + this.#inputPoolBytes(), inputPooledBytes: this.#inputPoolBytes(), inputPooledBuffers: this.#inputPool.length,
148
+ leasedBytes: this.#leased, reservedBytes: this.#reserved, lostBytes: this.#lost,
149
+ completedBatches: this.#completed, discardedBatches: this.#discarded, disposed: this.#disposed,
150
+ ...(this.#fallback === undefined ? {} : { fallbackReason: this.#fallback }) });
151
+ }
152
+ #reject(job, cause) { if (!job.settled) {
153
+ job.settled = true;
154
+ job.reject(cause);
155
+ } }
156
+ #cancel(job, code) { job.cancelled = true; this.#reject(job, new CpuError(code, `CPU batch ${code.toLowerCase()}`)); job.cancelWait?.(); }
157
+ #free(job, incomingOutputBytes = 0) {
158
+ if (job.abort)
159
+ job.signal?.removeEventListener('abort', job.abort);
160
+ this.#reserved -= job.reserved;
161
+ if (job.source)
162
+ this.#recycleInput(job.source.data, incomingOutputBytes);
163
+ job.source = undefined;
164
+ job.effects = undefined;
165
+ job.onStart = undefined;
166
+ }
167
+ #inputPoolBytes() { return this.#inputPool.reduce((n, p) => n + p.byteLength, 0); }
168
+ #recycleInput(data, incomingOutputBytes = 0) {
169
+ if (this.#disposed || this.#inputPool.length >= 2 || !data.byteLength || this.#inputPool.some(p => p.buffer === data.buffer))
170
+ return;
171
+ const pooled = this.#poolBytes + this.#inputPoolBytes() + data.byteLength;
172
+ if (pooled <= this.#options.maxPoolBytes &&
173
+ this.#cache.storageBytes + this.#workerCachedBytes + CPU_LOOKUP_BYTES * (this.#options.worker === 'off' ? 1 : 2) +
174
+ this.#reserved + this.#leased + incomingOutputBytes + pooled <= this.#options.maxOwnedBytes)
175
+ this.#inputPool.push(data);
176
+ }
177
+ #snapshotBytes(source) {
178
+ const index = this.#inputPool.findIndex(p => p.byteLength === source.data.byteLength);
179
+ if (index >= 0) {
180
+ const pooled = this.#inputPool.splice(index, 1)[0];
181
+ pooled.set(new Uint8Array(source.data.buffer, source.data.byteOffset, source.data.byteLength));
182
+ return pooled;
183
+ }
184
+ return new Uint8Array(source.data.buffer, source.data.byteOffset, source.data.byteLength).slice();
185
+ }
186
+ #recycle(outputs) {
187
+ for (const data of outputs)
188
+ if (!this.#disposed && data.byteLength && this.#poolBytes + this.#inputPoolBytes() + data.byteLength <= this.#options.maxPoolBytes &&
189
+ this.#cache.storageBytes + this.#workerCachedBytes + CPU_LOOKUP_BYTES * (this.#options.worker === 'off' ? 1 : 2) + this.#reserved + this.#leased + this.#poolBytes + this.#inputPoolBytes() + data.byteLength <= this.#options.maxOwnedBytes) {
190
+ this.#pool.push(data);
191
+ this.#poolBytes += data.byteLength;
192
+ }
193
+ }
194
+ #outputs(frame) {
195
+ return frame.surfaces.map(s => {
196
+ const bytes = s.width * s.height * 4, index = this.#pool.findIndex(p => p.byteLength === bytes);
197
+ if (index < 0)
198
+ return new Uint8ClampedArray(bytes);
199
+ const [data] = this.#pool.splice(index, 1);
200
+ this.#poolBytes -= bytes;
201
+ return data;
202
+ });
203
+ }
204
+ render(source, frame, options = {}) {
205
+ let job;
206
+ try {
207
+ if (this.#disposed)
208
+ throw new CpuError('DISPOSED', 'CPU executor disposed');
209
+ if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(k => !['effects', 'signal', 'notBefore', 'onStart'].includes(k)))
210
+ throw new CpuError('INVALID_OPTIONS', 'Invalid batch options');
211
+ const notBefore = options.notBefore === undefined ? 0 : options.notBefore, onStart = options.onStart;
212
+ if (typeof notBefore !== 'number' || !Number.isFinite(notBefore) || notBefore < 0 || notBefore > performance.now() + 60000 ||
213
+ (onStart !== undefined && typeof onStart !== 'function'))
214
+ throw new CpuError('INVALID_OPTIONS', 'Invalid execution schedule');
215
+ if (options.signal?.aborted)
216
+ throw new CpuError('ABORTED', 'CPU batch aborted');
217
+ if (options.signal !== undefined && (typeof options.signal.addEventListener !== 'function' || typeof options.signal.removeEventListener !== 'function'))
218
+ throw new CpuError('INVALID_OPTIONS', 'Invalid abort signal');
219
+ validateSource(source);
220
+ const frozen = checkedFrame(source, frame), effects = snapshotCpuEffects(options.effects);
221
+ const latest = this.#latest;
222
+ if (latest && (frozen.generation < latest.generation || (frozen.generation === latest.generation && (frozen.revision < latest.revision || frozen.sourceRevision < latest.sourceRevision))))
223
+ throw new CpuError('STALE', 'CPU frame is older than latest request');
224
+ const outputBytes = frozen.surfaces.reduce((n, s) => n + s.width * s.height * 4, 0);
225
+ const reserved = source.data.byteLength * ((effects?.blurRadius ?? 0) > 0 ? 12 : 4) + source.data.byteLength * (this.#options.worker === 'off' ? 1 : 2) + outputBytes + (frozen.surfaces.some(s => s.width * s.height >= 4096) ? CPU_GEOMETRY_MAX_BYTES : 0);
226
+ const oldPending = this.#pending?.reserved ?? 0;
227
+ const total = () => this.#cache.storageBytes + this.#workerCachedBytes + CPU_LOOKUP_BYTES * (this.#options.worker === 'off' ? 1 : 2) +
228
+ this.#reserved - oldPending + this.#leased + this.#poolBytes + this.#inputPoolBytes() + reserved;
229
+ while (this.#pool.length && total() > this.#options.maxOwnedBytes)
230
+ this.#poolBytes -= this.#pool.shift().byteLength;
231
+ while (this.#inputPool.length && total() > this.#options.maxOwnedBytes)
232
+ this.#inputPool.shift();
233
+ if (total() > this.#options.maxOwnedBytes)
234
+ throw new CpuError('BUDGET', 'CPU live allocation reservation exceeds maxOwnedBytes');
235
+ const copyStart = performance.now(), snapshot = createSource({ ...source,
236
+ data: this.#snapshotBytes(source), ownership: 'borrowed' });
237
+ const snapshotMs = performance.now() - copyStart;
238
+ let resolve, reject;
239
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
240
+ void promise.catch(() => { });
241
+ job = { id: ++this.#id, source: snapshot, frame: frozen, effects, reserved, snapshotMs, signal: options.signal, abort: undefined,
242
+ notBefore, onStart, cancelWait: undefined, cancelled: false, settled: false, resolve, reject };
243
+ if (this.#pending) {
244
+ this.#cancel(this.#pending, 'SUPERSEDED');
245
+ this.#free(this.#pending);
246
+ this.#discarded++;
247
+ }
248
+ if (this.#active)
249
+ this.#cancel(this.#active, 'SUPERSEDED');
250
+ this.#latest = frozen;
251
+ this.#reserved += reserved;
252
+ this.#pending = job;
253
+ if (options.signal) {
254
+ job.abort = () => {
255
+ this.#cancel(job, 'ABORTED');
256
+ if (this.#pending === job) {
257
+ this.#pending = undefined;
258
+ this.#free(job);
259
+ this.#discarded++;
260
+ }
261
+ };
262
+ options.signal.addEventListener('abort', job.abort, { once: true });
263
+ if (options.signal.aborted)
264
+ job.abort();
265
+ }
266
+ this.#drive();
267
+ return promise;
268
+ }
269
+ catch (cause) {
270
+ return Promise.reject(cause);
271
+ }
272
+ }
273
+ #drive() {
274
+ if (this.#disposed || this.#active || !this.#pending)
275
+ return;
276
+ const job = this.#pending;
277
+ this.#pending = undefined;
278
+ this.#active = job;
279
+ void Promise.resolve().then(() => this.#execute(job)).then(result => {
280
+ if (job.cancelled || this.#disposed) {
281
+ this.#discarded++;
282
+ this.#free(job, result.surfaces.reduce((n, s) => n + s.data.byteLength, 0));
283
+ this.#recycle(result.surfaces.map(s => s.data));
284
+ return;
285
+ }
286
+ const bytes = result.surfaces.reduce((n, s) => n + s.data.byteLength, 0);
287
+ this.#free(job, bytes);
288
+ this.#leased += bytes;
289
+ this.#completed++;
290
+ let released = false;
291
+ const batch = Object.freeze({ ...result, surfaces: Object.freeze(result.surfaces.map(s => Object.freeze(s))), release: () => {
292
+ if (released)
293
+ return;
294
+ released = true;
295
+ this.#leased -= bytes;
296
+ this.#recycle(result.surfaces.map(s => s.data));
297
+ } });
298
+ job.settled = true;
299
+ job.resolve(batch);
300
+ }, cause => { this.#free(job); this.#reject(job, cause); }).finally(() => {
301
+ if (this.#active === job)
302
+ this.#active = undefined;
303
+ this.#drive();
304
+ });
305
+ }
306
+ #terminate(cause) {
307
+ this.#broken = true;
308
+ const worker = this.#worker;
309
+ this.#worker = undefined;
310
+ if (worker) {
311
+ worker.onmessage = null;
312
+ worker.onerror = null;
313
+ worker.onmessageerror = null;
314
+ try {
315
+ worker.terminate();
316
+ }
317
+ catch { /* Independent bookkeeping still runs. */ }
318
+ }
319
+ // The old retained source and the new in-flight staging can be distinct.
320
+ // Charging both is conservative even when this request reused that source.
321
+ this.#lost += this.#remote + this.#workerCachedBytes;
322
+ this.#workerCachedBytes = 0;
323
+ this.#remote = 0;
324
+ const reject = this.#workerReject;
325
+ this.#workerReject = undefined;
326
+ reject?.(cause);
327
+ }
328
+ async #execute(job) {
329
+ if (job.cancelled || this.#disposed)
330
+ throw new CpuError('ABORTED', 'CPU batch no longer current');
331
+ let scheduledWaitMs = 0;
332
+ if (job.notBefore > performance.now()) {
333
+ const controller = new AbortController();
334
+ job.cancelWait = () => controller.abort(new CpuError('ABORTED', 'Deferred CPU batch cancelled'));
335
+ try {
336
+ scheduledWaitMs = await this.#scheduler.waitUntil(job.notBefore, controller.signal);
337
+ }
338
+ finally {
339
+ job.cancelWait = undefined;
340
+ }
341
+ }
342
+ if (job.cancelled || this.#disposed)
343
+ throw new CpuError('ABORTED', 'CPU batch no longer current');
344
+ const startedAt = performance.now();
345
+ job.onStart?.(startedAt);
346
+ if (job.cancelled || this.#disposed)
347
+ throw new CpuError('ABORTED', 'CPU batch no longer current');
348
+ const scheduled = (result) => ({ ...result, timings: { ...result.timings, scheduledWaitMs, startedAt } });
349
+ if (this.#options.worker !== 'off' && !this.#broken) {
350
+ try {
351
+ return scheduled(await this.#remoteBatch(job));
352
+ }
353
+ catch (cause) {
354
+ this.#terminate(cause);
355
+ if (this.#options.worker === 'required' || job.cancelled || this.#disposed)
356
+ throw cause instanceof CpuError ? cause : new CpuError('WORKER_FAILED', 'CPU worker unavailable', { cause });
357
+ this.#fallback = cause instanceof CpuError ? cause.code : 'WORKER_FAILED';
358
+ }
359
+ }
360
+ else if (this.#options.worker === 'required')
361
+ throw new CpuError('WORKER_FAILED', 'Required worker is no longer available');
362
+ if (job.cancelled || this.#disposed)
363
+ throw new CpuError('ABORTED', 'CPU batch no longer current');
364
+ this.#execution = 'main';
365
+ const start = performance.now(), outputs = this.#outputs(job.frame);
366
+ try {
367
+ const result = runCpuBatch({ type: 'render', id: job.id, source: job.source, frame: job.frame, ...(job.effects ? { effects: job.effects } : {}), outputs }, this.#options.cacheSources ? this.#cache : undefined);
368
+ return scheduled({ ...result, timings: { ...result.timings, snapshotMs: job.snapshotMs, roundTripMs: performance.now() - start } });
369
+ }
370
+ catch (cause) {
371
+ this.#recycle(outputs);
372
+ throw cause;
373
+ }
374
+ }
375
+ async #remoteBatch(job) {
376
+ const worker = this.#worker ??= this.#options.workerFactory ? this.#options.workerFactory() : new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
377
+ if (!worker || typeof worker.postMessage !== 'function' || typeof worker.terminate !== 'function')
378
+ throw new CpuError('WORKER_UNAVAILABLE', 'Worker unavailable');
379
+ const snapshot = job.source, sourceBytes = snapshot.data.byteLength;
380
+ // Required execution never falls back. Its owned capture can cross the
381
+ // boundary directly; auto keeps an independent capture for main fallback.
382
+ const directInput = this.#options.worker === 'required', copyStart = performance.now();
383
+ const source = createSource({ ...snapshot, data: directInput ? snapshot.data : this.#snapshotBytes(snapshot), ownership: 'transfer' });
384
+ const transferCopyMs = directInput ? 0 : performance.now() - copyStart;
385
+ const outputs = this.#outputs(job.frame), work = { cacheSources: this.#options.cacheSources, type: 'render', id: job.id, source, frame: job.frame, ...(job.effects ? { effects: job.effects } : {}), outputs };
386
+ this.#execution = 'worker';
387
+ this.#remote = job.reserved - (directInput ? 0 : sourceBytes);
388
+ // Without a copy stage, charge dispatch preparation to round-trip time.
389
+ const start = directInput ? copyStart : performance.now();
390
+ let submit = 0, timer, received = false;
391
+ try {
392
+ return await new Promise((resolve, reject) => {
393
+ this.#workerReject = reject;
394
+ worker.onmessage = event => {
395
+ if (received)
396
+ return;
397
+ received = true;
398
+ try {
399
+ if (this.#disposed || this.#worker !== worker || this.#active !== job)
400
+ throw new CpuError('STALE', 'Worker response is no longer current');
401
+ const message = event.data;
402
+ if (message?.type !== 'result' || message.id !== job.id)
403
+ throw new CpuError('WORKER_PROTOCOL', 'Unexpected worker response');
404
+ const result = message.result;
405
+ if (!result || result.generation !== job.frame.generation || result.revision !== job.frame.revision || result.sourceRevision !== job.frame.sourceRevision ||
406
+ !Array.isArray(result.surfaces) || result.surfaces.length !== job.frame.surfaces.length)
407
+ throw new CpuError('WORKER_PROTOCOL', 'Worker frame identity mismatch');
408
+ const buffers = new Set();
409
+ result.surfaces.forEach((s, i) => {
410
+ const expected = job.frame.surfaces[i];
411
+ if (s.id !== expected.id || s.width !== expected.width || s.height !== expected.height || !(s.data instanceof Uint8ClampedArray) ||
412
+ s.data.byteOffset !== 0 || s.data.byteLength !== s.width * s.height * 4 || buffers.has(s.data.buffer))
413
+ throw new CpuError('WORKER_PROTOCOL', 'Invalid worker pixels');
414
+ validateSource(createSource({ contractVersion: 1, width: s.width, height: s.height, revision: 0, ownership: 'transfer', data: s.data, colorSpace: 'srgb', alphaMode: 'straight' }));
415
+ buffers.add(s.data.buffer);
416
+ });
417
+ if (!result.timings || ![result.timings.prepareMs, result.timings.kernelMs].every(n => Number.isFinite(n) && n >= 0))
418
+ throw new CpuError('WORKER_PROTOCOL', 'Invalid worker timings');
419
+ // A cached worker must report its prepared source. Previous blur or
420
+ // geometry arrays may survive a cheaper request for the same source;
421
+ // those bytes were reserved independently before this job started.
422
+ const minimumRetained = this.#options.cacheSources ? sourceBytes * 4 : 0;
423
+ const maximumRetained = this.#workerCachedBytes + job.reserved;
424
+ if ((this.#options.cacheSources && result.retainedBytes === undefined) ||
425
+ (result.retainedBytes !== undefined && (!Number.isSafeInteger(result.retainedBytes) ||
426
+ result.retainedBytes < minimumRetained || result.retainedBytes > maximumRetained))) {
427
+ throw new CpuError('WORKER_PROTOCOL', 'Invalid retained source bytes');
428
+ }
429
+ this.#workerCachedBytes = result.retainedBytes ?? 0;
430
+ if (directInput && message.returnedInput === undefined)
431
+ throw new CpuError('WORKER_PROTOCOL', 'Missing returned input buffer');
432
+ if (message.returnedInput !== undefined) {
433
+ const input = message.returnedInput;
434
+ if (!(input instanceof Uint8Array) || buffers.has(input.buffer))
435
+ throw new CpuError('WORKER_PROTOCOL', 'Invalid returned input buffer');
436
+ try {
437
+ validateSource(createSource({ ...snapshot, data: input, ownership: 'transfer' }));
438
+ }
439
+ catch (cause) {
440
+ throw new CpuError('WORKER_PROTOCOL', 'Invalid returned input buffer', { cause });
441
+ }
442
+ this.#recycleInput(input);
443
+ }
444
+ this.#remote = 0;
445
+ resolve({ ...result, timings: { ...result.timings, snapshotMs: job.snapshotMs, transferCopyMs, transferSubmitMs: submit, roundTripMs: performance.now() - start } });
446
+ }
447
+ catch (cause) {
448
+ reject(cause);
449
+ }
450
+ };
451
+ worker.onerror = event => { received = true; event.preventDefault?.(); reject(new CpuError('WORKER_FAILED', 'CPU worker error')); };
452
+ worker.onmessageerror = () => { received = true; reject(new CpuError('WORKER_PROTOCOL', 'CPU worker message decode failed')); };
453
+ timer = setTimeout(() => { received = true; reject(new CpuError('TIMEOUT', 'CPU worker timed out')); }, this.#options.timeoutMs);
454
+ try {
455
+ const post = performance.now();
456
+ worker.postMessage(work, [source.data.buffer, ...outputs.map(o => o.buffer)]);
457
+ submit = performance.now() - post;
458
+ }
459
+ catch (cause) {
460
+ received = true;
461
+ reject(new CpuError('WORKER_FAILED', 'CPU worker post failed', { cause }));
462
+ }
463
+ });
464
+ }
465
+ finally {
466
+ clearTimeout(timer);
467
+ this.#workerReject = undefined;
468
+ worker.onmessage = null;
469
+ worker.onerror = null;
470
+ worker.onmessageerror = null;
471
+ }
472
+ }
473
+ dispose() {
474
+ if (this.#disposed)
475
+ return;
476
+ this.#disposed = true;
477
+ if (this.#pending) {
478
+ this.#cancel(this.#pending, 'DISPOSED');
479
+ this.#free(this.#pending);
480
+ this.#pending = undefined;
481
+ this.#discarded++;
482
+ }
483
+ if (this.#active)
484
+ this.#cancel(this.#active, 'DISPOSED');
485
+ this.#terminate(new CpuError('DISPOSED', 'CPU executor disposed'));
486
+ this.#pool = [];
487
+ this.#poolBytes = 0;
488
+ this.#inputPool = [];
489
+ this.#cache.dispose();
490
+ this.#scheduler.dispose();
491
+ }
492
+ }
@@ -0,0 +1,3 @@
1
+ export * from './kernel.js';
2
+ export * from './executor.js';
3
+ export type { CpuSurfacePixels, CpuTimings } from './protocol.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from './kernel.js';
2
+ export * from './executor.js';