@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 +21 -0
- package/NOTICE +7 -0
- package/README.md +42 -2
- package/dist/executor.d.ts +63 -0
- package/dist/executor.js +492 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/kernel.d.ts +37 -0
- package/dist/kernel.js +501 -0
- package/dist/protocol.d.ts +45 -0
- package/dist/protocol.js +42 -0
- package/dist/worker-core.js +187 -0
- package/dist/worker-kernel.js +501 -0
- package/dist/worker-protocol.js +42 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +18 -0
- package/package.json +44 -4
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
|
-
#
|
|
1
|
+
# @liquid-glass-forge/cpu
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Bounded JavaScript glass rendering with optional worker execution.
|
|
4
|
+
|
|
5
|
+
[](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
|
+
}
|
package/dist/executor.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED