arcane-os 0.60.0 → 0.62.0
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/CHANGELOG.md +40 -0
- package/README.md +1 -1
- package/browser-runtime/ai/core-image.mjs +392 -0
- package/docs/reference/local-image-editing.md +102 -16
- package/docs/reference/local-image-generation.md +267 -0
- package/docs/reference/model-assets.md +14 -11
- package/docs/reference/sdk-api.md +32 -0
- package/package.json +6 -1
- package/schemas/arcane-app.schema.json +2 -2
- package/schemas/arcane-package.schema.json +2 -2
- package/src/core/services/image.mjs +605 -0
- package/src/core/services/local-ai.mjs +8 -3
- package/src/import-map.mjs +2 -0
- package/src/local-ai/config.mjs +41 -1
- package/src/local-ai/dev.mjs +46 -9
- package/src/local-ai/image-library.mjs +985 -0
- package/src/local-ai/image-models/sd14.mjs +15 -0
- package/src/local-ai/image-png.mjs +74 -0
- package/src/local-ai/image-runtime.mjs +358 -0
- package/src/local-ai/image-worker.mjs +232 -0
- package/src/local-ai/install.mjs +326 -10
- package/src/native/portable-layout.mjs +28 -2
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Official complete checkpoint; acquisition belongs to the model asset owner. */
|
|
2
|
+
export const SD14_MODEL = {
|
|
3
|
+
id: 'sd14',
|
|
4
|
+
name: 'Stable Diffusion 1.4',
|
|
5
|
+
family: 'sd',
|
|
6
|
+
resources: {
|
|
7
|
+
model: {
|
|
8
|
+
url: 'https://huggingface.co/CompVis/stable-diffusion-v-1-4-original/resolve/c96766dd476698a0ece589565635fafc88ebecae/sd-v1-4.ckpt',
|
|
9
|
+
filename: 'sd-v1-4.ckpt'
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
context: {},
|
|
13
|
+
defaults: {},
|
|
14
|
+
operations: ['txt2img', 'img2img']
|
|
15
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {Buffer} from 'node:buffer';
|
|
2
|
+
import {crc32, deflate} from 'node:zlib';
|
|
3
|
+
|
|
4
|
+
/** Encode complete native RGB/RGBA pixels in the owning image worker. */
|
|
5
|
+
export async function encodePNG(
|
|
6
|
+
{width, height, channel, data, signal}
|
|
7
|
+
) {
|
|
8
|
+
signal?.throwIfAborted();
|
|
9
|
+
if (channel !== 3 && channel !== 4) {
|
|
10
|
+
throw new TypeError('PNG image output requires RGB or RGBA pixels.');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const pixels = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
14
|
+
const rowLength = width * channel;
|
|
15
|
+
// PNG scanlines must represent every supplied pixel exactly once.
|
|
16
|
+
if (pixels.length !== rowLength * height) {
|
|
17
|
+
throw new TypeError('The pixel data does not match the image dimensions.');
|
|
18
|
+
}
|
|
19
|
+
const scanlines = Buffer.alloc((rowLength + 1) * height);
|
|
20
|
+
for (let row = 0; row < height; row++) {
|
|
21
|
+
signal?.throwIfAborted();
|
|
22
|
+
const start = row * rowLength;
|
|
23
|
+
pixels.copy(scanlines, row * (rowLength + 1) + 1, start, start + rowLength);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The zero filter prefix preserves each channel; zlib runs asynchronously.
|
|
27
|
+
const compressed = await new Promise(
|
|
28
|
+
function compressImage(resolve, reject) {
|
|
29
|
+
deflate(
|
|
30
|
+
scanlines,
|
|
31
|
+
function imageCompressed(error, result) {
|
|
32
|
+
if (error) {
|
|
33
|
+
reject(error);
|
|
34
|
+
} else {
|
|
35
|
+
resolve(result);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
signal?.throwIfAborted();
|
|
42
|
+
|
|
43
|
+
const header = Buffer.alloc(13);
|
|
44
|
+
header.writeUInt32BE(width, 0);
|
|
45
|
+
header.writeUInt32BE(height, 4);
|
|
46
|
+
header[8] = 8;
|
|
47
|
+
header[9] = channel === 3 ? 2 : 6;
|
|
48
|
+
const chunks = [
|
|
49
|
+
Buffer.from(
|
|
50
|
+
[137, 80, 78, 71, 13, 10, 26, 10]
|
|
51
|
+
),
|
|
52
|
+
pngChunk('IHDR', header)
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// PNG limits each chunk's framing field; continuation retains all data.
|
|
56
|
+
for (let offset = 0; offset < compressed.length; offset += 0x7fffffff) {
|
|
57
|
+
signal?.throwIfAborted();
|
|
58
|
+
chunks.push(pngChunk('IDAT', compressed.subarray(offset, offset + 0x7fffffff)));
|
|
59
|
+
}
|
|
60
|
+
chunks.push(pngChunk('IEND', Buffer.alloc(0)));
|
|
61
|
+
return Buffer.concat(chunks);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function pngChunk(name, data) {
|
|
65
|
+
const type = Buffer.from(name, 'ascii');
|
|
66
|
+
const length = Buffer.alloc(4);
|
|
67
|
+
length.writeUInt32BE(data.length);
|
|
68
|
+
// CRC is required PNG framing, confined to this image encoder.
|
|
69
|
+
const crc = Buffer.alloc(4);
|
|
70
|
+
crc.writeUInt32BE(crc32(data, crc32(type)));
|
|
71
|
+
return Buffer.concat(
|
|
72
|
+
[length, type, data, crc]
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import {Worker} from 'node:worker_threads';
|
|
2
|
+
import {isDeepStrictEqual} from 'node:util';
|
|
3
|
+
import Is from 'strong-type';
|
|
4
|
+
import {createArcaneEventSource} from '../event-manager.mjs';
|
|
5
|
+
|
|
6
|
+
const is = new Is(false);
|
|
7
|
+
const brokers = new Map();
|
|
8
|
+
|
|
9
|
+
/** Share the native library owner between Core services in this host. */
|
|
10
|
+
export function createImageRuntime(
|
|
11
|
+
{libraryPath, bindingModulePath, variants = [], models = [], backend = 'auto', signal, onEvent} = {}
|
|
12
|
+
) {
|
|
13
|
+
let broker = brokers.get(libraryPath);
|
|
14
|
+
if (!broker || broker.closed || broker.closing) {
|
|
15
|
+
const predecessor = broker && !broker.closed ? broker.close() : undefined;
|
|
16
|
+
broker = createBroker({libraryPath, bindingModulePath, variants, backend}, predecessor);
|
|
17
|
+
brokers.set(libraryPath, broker);
|
|
18
|
+
}
|
|
19
|
+
broker.references++;
|
|
20
|
+
const lifetime = new AbortController();
|
|
21
|
+
const lifetimeSignal = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal;
|
|
22
|
+
const operations = new Set();
|
|
23
|
+
const definitions = new Map();
|
|
24
|
+
const selections = new WeakMap();
|
|
25
|
+
let closing;
|
|
26
|
+
for (const model of models) definitions.set(model.id, model);
|
|
27
|
+
|
|
28
|
+
const stopDiagnostic = broker.events.on(
|
|
29
|
+
'image.diagnostic',
|
|
30
|
+
function runtimeDiagnostic(event) {
|
|
31
|
+
if (onEvent) {
|
|
32
|
+
observe(onEvent, event.detail);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
function selectedModel(model) {
|
|
38
|
+
let definition = typeof model === 'string' ? definitions.get(model) : model;
|
|
39
|
+
if (!definition) {
|
|
40
|
+
throw failure('LOCAL_IMAGE_MODEL_UNAVAILABLE', `The image model ${String(model)} has not been prepared.`);
|
|
41
|
+
}
|
|
42
|
+
const previous = definitions.get(definition.id);
|
|
43
|
+
if (previous && isDeepStrictEqual(previous, definition)) definition = previous;
|
|
44
|
+
definitions.set(definition.id, definition);
|
|
45
|
+
let selection = selections.get(definition);
|
|
46
|
+
if (!selection) {
|
|
47
|
+
selection = {model: definition, backend, selection: ++broker.nextSelection};
|
|
48
|
+
selections.set(definition, selection);
|
|
49
|
+
}
|
|
50
|
+
return selection;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function operation(name, parameters, operationSignal, onProgress) {
|
|
54
|
+
if (closing) return Promise.reject(failure('LOCAL_IMAGE_CLOSED', 'The image runtime is closing.'));
|
|
55
|
+
const combined = operationSignal ? AbortSignal.any([lifetimeSignal, operationSignal]) : lifetimeSignal;
|
|
56
|
+
combined.throwIfAborted();
|
|
57
|
+
if (name === 'unload') {
|
|
58
|
+
broker.cancelPending(failure('LOCAL_IMAGE_UNLOADED', 'The image model is being unloaded.'));
|
|
59
|
+
}
|
|
60
|
+
const task = broker.enqueue(name, parameters, combined, onProgress);
|
|
61
|
+
operations.add(task);
|
|
62
|
+
function settled() { operations.delete(task); }
|
|
63
|
+
task.then(settled, settled);
|
|
64
|
+
return task;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function load({model, context, signal: operationSignal} = {}) {
|
|
68
|
+
let selected = selectedModel(model);
|
|
69
|
+
if (context !== undefined) {
|
|
70
|
+
selected = selectedModel(
|
|
71
|
+
{...selected.model, context: {...selected.model.context, ...context}}
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return operation('load', selected, operationSignal);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function generate({model, prompt, parameters, signal: operationSignal, onProgress} = {}) {
|
|
78
|
+
return operation(
|
|
79
|
+
'generate',
|
|
80
|
+
{...selectedModel(model), prompt, parameters},
|
|
81
|
+
operationSignal,
|
|
82
|
+
onProgress
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function edit({model, image, prompt, strength, parameters, signal: operationSignal, onProgress} = {}) {
|
|
87
|
+
if (!is.number(strength) || !is.finite(strength)) {
|
|
88
|
+
throw new TypeError('Image editing requires an explicit finite strength.');
|
|
89
|
+
}
|
|
90
|
+
if (strength < 0 || strength > 1) {
|
|
91
|
+
throw new RangeError('Image editing strength must be between 0 and 1.');
|
|
92
|
+
}
|
|
93
|
+
// The accepted input must survive caller reuse while this request queues.
|
|
94
|
+
const input = {
|
|
95
|
+
...image,
|
|
96
|
+
data: image?.data instanceof Uint8Array ? new Uint8Array(image.data) : image?.data
|
|
97
|
+
};
|
|
98
|
+
return operation(
|
|
99
|
+
'edit',
|
|
100
|
+
{...selectedModel(model), image: input, prompt, strength, parameters},
|
|
101
|
+
operationSignal,
|
|
102
|
+
onProgress
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function unload({signal: operationSignal} = {}) {
|
|
107
|
+
return operation('unload', {}, operationSignal);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function subscribe(listener, {replay = true} = {}) {
|
|
111
|
+
const stop = broker.events.on('image.state', function stateChanged(event) { observe(listener, event.detail); });
|
|
112
|
+
if (replay) observe(listener, broker.current());
|
|
113
|
+
return stop;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function close() {
|
|
117
|
+
if (closing) return closing;
|
|
118
|
+
closing = Promise.resolve().then(async function releaseRuntime() {
|
|
119
|
+
await Promise.allSettled([...operations]);
|
|
120
|
+
const errors = [];
|
|
121
|
+
try {
|
|
122
|
+
// Native accessor closure releases the shared model before its
|
|
123
|
+
// service may release working files. Browser accessor closure
|
|
124
|
+
// does not invoke this native ownership boundary.
|
|
125
|
+
if (broker.current().available && !broker.closing) {
|
|
126
|
+
await broker.enqueue('unload', {});
|
|
127
|
+
} else {
|
|
128
|
+
await broker.close();
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
errors.push(error);
|
|
132
|
+
}
|
|
133
|
+
stopDiagnostic();
|
|
134
|
+
lifetimeSignal.removeEventListener('abort', abortRuntime);
|
|
135
|
+
broker.references--;
|
|
136
|
+
if (broker.references === 0 || errors.length) {
|
|
137
|
+
try {
|
|
138
|
+
await broker.close();
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (!errors.includes(error)) errors.push(error);
|
|
141
|
+
} finally {
|
|
142
|
+
if (brokers.get(libraryPath) === broker) brokers.delete(libraryPath);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (errors.length === 1) throw errors[0];
|
|
146
|
+
if (errors.length > 1) {
|
|
147
|
+
throw new AggregateError(errors, 'Image model and native owner cleanup failed.', {cause: errors[0]});
|
|
148
|
+
}
|
|
149
|
+
return broker.current();
|
|
150
|
+
});
|
|
151
|
+
lifetime.abort();
|
|
152
|
+
return closing;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function abortRuntime() { close().catch(reportError); }
|
|
156
|
+
lifetimeSignal.addEventListener('abort', abortRuntime, {once: true});
|
|
157
|
+
if (lifetimeSignal.aborted) abortRuntime();
|
|
158
|
+
return {load, generate, edit, unload, current: broker.current, subscribe, close};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function createBroker(configuration, predecessor) {
|
|
162
|
+
const events = createArcaneEventSource(
|
|
163
|
+
{},
|
|
164
|
+
{source: 'local-image', eventTypes: ['image.state', 'image.diagnostic']}
|
|
165
|
+
);
|
|
166
|
+
const queue = [];
|
|
167
|
+
let state = {
|
|
168
|
+
state: 'unloaded', available: true, selectedModel: null, loaded: false,
|
|
169
|
+
backend: null, devices: [], resources: {}, progress: null, error: null, closed: false
|
|
170
|
+
};
|
|
171
|
+
let worker;
|
|
172
|
+
let starting;
|
|
173
|
+
let workerReady = false;
|
|
174
|
+
let workerExited = false;
|
|
175
|
+
let terminalError;
|
|
176
|
+
let workerExit;
|
|
177
|
+
let active;
|
|
178
|
+
let nextRequest = 0;
|
|
179
|
+
let closeTask;
|
|
180
|
+
const broker = {references: 0, nextSelection: 0, closing: false, closed: false, events, current, enqueue, cancelPending, close};
|
|
181
|
+
|
|
182
|
+
function current() { return structuredClone(state); }
|
|
183
|
+
|
|
184
|
+
function publish(fields) {
|
|
185
|
+
state = {...state, ...fields};
|
|
186
|
+
events.dispatch('image.state', current());
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function finish(operation, error, result) {
|
|
190
|
+
if (operation.settled) return;
|
|
191
|
+
operation.settled = true;
|
|
192
|
+
operation.signal?.removeEventListener('abort', operation.abort);
|
|
193
|
+
if (active === operation) active = undefined;
|
|
194
|
+
if (error) operation.reject(error);
|
|
195
|
+
else operation.resolve(result);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function failed(error) {
|
|
199
|
+
terminalError = error;
|
|
200
|
+
publish({state: 'error', available: false, error: describeError(error)});
|
|
201
|
+
if (active) finish(active, error);
|
|
202
|
+
for (const operation of queue.splice(0)) finish(operation, error);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function startWorker() {
|
|
206
|
+
if (predecessor) {
|
|
207
|
+
try {
|
|
208
|
+
await predecessor;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
// Retirement has joined the prior native worker even when its
|
|
211
|
+
// cleanup reports an error. Preserve that diagnostic while the
|
|
212
|
+
// new owner starts its independent lifetime.
|
|
213
|
+
events.dispatch('image.diagnostic', {type: 'local-image.previous-close.failed', error: describeError(error)});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
worker = new Worker(new URL('./image-worker.mjs', import.meta.url), {workerData: configuration});
|
|
217
|
+
workerExit = new Promise(function awaitWorkerExit(resolve) {
|
|
218
|
+
worker.once('exit', function imageWorkerExited(code) {
|
|
219
|
+
workerExited = true;
|
|
220
|
+
workerReady = false;
|
|
221
|
+
if (!broker.closed && !closeTask) {
|
|
222
|
+
failed(failure('LOCAL_IMAGE_WORKER_EXITED', `The native image worker exited with code ${code}.`));
|
|
223
|
+
}
|
|
224
|
+
resolve();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
worker.on('error', failed);
|
|
228
|
+
worker.on('message', function workerMessage(message) {
|
|
229
|
+
if (message.type === 'ready') {
|
|
230
|
+
workerReady = true;
|
|
231
|
+
publish(message.state);
|
|
232
|
+
pump();
|
|
233
|
+
} else if (message.type === 'state') {
|
|
234
|
+
publish(message.state);
|
|
235
|
+
} else if (message.type === 'diagnostic') {
|
|
236
|
+
events.dispatch('image.diagnostic', message.event);
|
|
237
|
+
} else if (message.type === 'progress') {
|
|
238
|
+
publish({progress: message.progress});
|
|
239
|
+
if (active?.id === message.id && active.onProgress) {
|
|
240
|
+
try {
|
|
241
|
+
Promise.resolve(active.onProgress(message.progress)).catch(reportError);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
reportError(error);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} else if (message.type === 'result') {
|
|
247
|
+
if (active?.id !== message.id) return;
|
|
248
|
+
finish(active, message.error ? restoreError(message.error) : null, message.result);
|
|
249
|
+
pump();
|
|
250
|
+
} else if (message.type === 'fatal') {
|
|
251
|
+
failed(restoreError(message.error));
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function pump() {
|
|
257
|
+
if (active || !queue.length) return;
|
|
258
|
+
if (!worker) {
|
|
259
|
+
if (!starting) {
|
|
260
|
+
publish({state: 'starting'});
|
|
261
|
+
starting = startWorker();
|
|
262
|
+
starting.catch(failed);
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (!workerReady) return;
|
|
267
|
+
active = queue.shift();
|
|
268
|
+
if (active.signal?.aborted) {
|
|
269
|
+
finish(active, active.signal.reason);
|
|
270
|
+
pump();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
worker.postMessage({type: 'operation', id: active.id, operation: active.name, parameters: active.parameters});
|
|
275
|
+
} catch (error) {
|
|
276
|
+
finish(active, error);
|
|
277
|
+
pump();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function enqueue(name, parameters, signal, onProgress) {
|
|
282
|
+
if (broker.closed) return Promise.reject(failure('LOCAL_IMAGE_CLOSED', 'The image runtime is closed.'));
|
|
283
|
+
if (broker.closing && name !== 'close') {
|
|
284
|
+
return Promise.reject(failure('LOCAL_IMAGE_CLOSED', 'The image runtime is closing.'));
|
|
285
|
+
}
|
|
286
|
+
if (terminalError) return Promise.reject(terminalError);
|
|
287
|
+
return new Promise(function enqueueImage(resolve, reject) {
|
|
288
|
+
const operation = {id: ++nextRequest, name, parameters, signal, onProgress, resolve, reject, settled: false};
|
|
289
|
+
operation.abort = function cancelOperation() {
|
|
290
|
+
if (active === operation) {
|
|
291
|
+
publish({state: 'cancelling'});
|
|
292
|
+
worker.postMessage({type: 'cancel', id: operation.id});
|
|
293
|
+
} else {
|
|
294
|
+
const index = queue.indexOf(operation);
|
|
295
|
+
if (index !== -1) queue.splice(index, 1);
|
|
296
|
+
finish(operation, signal.reason);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
signal?.addEventListener('abort', operation.abort, {once: true});
|
|
300
|
+
queue.push(operation);
|
|
301
|
+
if (signal?.aborted) operation.abort();
|
|
302
|
+
pump();
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function cancelPending(reason) {
|
|
307
|
+
for (const operation of queue.splice(0)) finish(operation, reason);
|
|
308
|
+
if (active) {
|
|
309
|
+
publish({state: 'cancelling'});
|
|
310
|
+
worker.postMessage({type: 'cancel', id: active.id});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function close() {
|
|
315
|
+
if (closeTask) return closeTask;
|
|
316
|
+
broker.closing = true;
|
|
317
|
+
closeTask = (async function closeImageOwner() {
|
|
318
|
+
try {
|
|
319
|
+
if (worker || starting) {
|
|
320
|
+
if (!workerExited && !terminalError) await enqueue('close', {});
|
|
321
|
+
}
|
|
322
|
+
} finally {
|
|
323
|
+
await starting?.catch(function startupClosed() {});
|
|
324
|
+
await workerExit;
|
|
325
|
+
broker.closed = true;
|
|
326
|
+
publish({state: 'closed', available: false, loaded: false, resources: {}, closed: true});
|
|
327
|
+
events.dispose();
|
|
328
|
+
}
|
|
329
|
+
})();
|
|
330
|
+
return closeTask;
|
|
331
|
+
}
|
|
332
|
+
return broker;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function failure(code, message) {
|
|
336
|
+
return Object.assign(new Error(message), {code});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function describeError(error) {
|
|
340
|
+
if (!(error instanceof Error)) return {name: 'Error', message: String(error), cause: error};
|
|
341
|
+
return {
|
|
342
|
+
...error, name: error.name, message: error.message, stack: error.stack,
|
|
343
|
+
...(error.cause !== undefined ? {cause: describeError(error.cause)} : {}),
|
|
344
|
+
...(error instanceof AggregateError ? {errors: error.errors.map(describeError)} : {})
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function restoreError(record) { return Object.assign(new Error(record.message), record); }
|
|
349
|
+
|
|
350
|
+
function reportError(error) { console.error('Local image observation failed.', error); }
|
|
351
|
+
|
|
352
|
+
function observe(listener, detail) {
|
|
353
|
+
try {
|
|
354
|
+
Promise.resolve(listener(detail)).catch(reportError);
|
|
355
|
+
} catch (error) {
|
|
356
|
+
reportError(error);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import {parentPort, workerData} from 'node:worker_threads';
|
|
2
|
+
import {createImageLibrary} from './image-library.mjs';
|
|
3
|
+
import {encodePNG} from './image-png.mjs';
|
|
4
|
+
import {decodeImage} from './image-editing.mjs';
|
|
5
|
+
|
|
6
|
+
let library;
|
|
7
|
+
let active;
|
|
8
|
+
let selection;
|
|
9
|
+
let selectedBackend;
|
|
10
|
+
let state = {
|
|
11
|
+
state: 'unloaded', available: true, selectedModel: null, loaded: false,
|
|
12
|
+
backend: null, devices: [], resources: {}, progress: null, error: null, closed: false
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function publish(fields) {
|
|
16
|
+
state = {...state, ...fields};
|
|
17
|
+
parentPort.postMessage({type: 'state', state});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function diagnostic(event) { parentPort.postMessage({type: 'diagnostic', event}); }
|
|
21
|
+
|
|
22
|
+
function progress(value) {
|
|
23
|
+
if (!active) return;
|
|
24
|
+
parentPort.postMessage({type: 'progress', id: active.id, progress: value});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function initialize() {
|
|
28
|
+
const variants = workerData.variants.length ? workerData.variants : [
|
|
29
|
+
{backend: workerData.backend, libraryPath: workerData.libraryPath}
|
|
30
|
+
];
|
|
31
|
+
const failures = [];
|
|
32
|
+
for (const variant of variants) {
|
|
33
|
+
try {
|
|
34
|
+
library = createImageLibrary(
|
|
35
|
+
{
|
|
36
|
+
libraryPath: variant.libraryPath,
|
|
37
|
+
bindingModulePath: workerData.bindingModulePath,
|
|
38
|
+
onLog: diagnostic,
|
|
39
|
+
onProgress: progress
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code !== 'LOCAL_IMAGE_LIBRARY_UNAVAILABLE') throw error;
|
|
44
|
+
failures.push(error);
|
|
45
|
+
diagnostic({type: 'local-image.backend.unavailable', backend: variant.backend, error: describeError(error)});
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const inventory = await library.listDevices();
|
|
49
|
+
const requested = workerData.backend;
|
|
50
|
+
const device = inventory.devices.find(function preferredDevice(value) {
|
|
51
|
+
if (requested === 'cpu' || variant.backend === 'cpu') return /^cpu/iu.test(value.name);
|
|
52
|
+
return /^(cuda|vulkan|metal)/iu.test(value.name);
|
|
53
|
+
});
|
|
54
|
+
if (device) {
|
|
55
|
+
selectedBackend = device.name;
|
|
56
|
+
state = {...state, backend: selectedBackend, devices: inventory.devices};
|
|
57
|
+
diagnostic({type: 'local-image.devices', ...inventory, backend: selectedBackend});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
await library.close();
|
|
61
|
+
library = undefined;
|
|
62
|
+
diagnostic({type: 'local-image.backend.unavailable', backend: variant.backend, ...inventory});
|
|
63
|
+
}
|
|
64
|
+
if (failures.length) throw new AggregateError(failures, 'The selected image runtime libraries could not load.');
|
|
65
|
+
throw Object.assign(new Error('No compatible image generation device is available.'), {code: 'LOCAL_IMAGE_BACKEND_UNAVAILABLE'});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function unload() {
|
|
69
|
+
if (!state.loaded) return;
|
|
70
|
+
publish({state: 'unloading'});
|
|
71
|
+
await library.unload();
|
|
72
|
+
selection = undefined;
|
|
73
|
+
publish({state: 'unloaded', selectedModel: null, loaded: false, resources: {}, progress: null});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function load(parameters, signal) {
|
|
77
|
+
signal.throwIfAborted();
|
|
78
|
+
if (selection === parameters.selection && state.loaded) return;
|
|
79
|
+
await unload();
|
|
80
|
+
signal.throwIfAborted();
|
|
81
|
+
const model = parameters.model;
|
|
82
|
+
const requestedBackend = parameters.backend ?? 'auto';
|
|
83
|
+
const requestedDevice = state.devices.find(function requestedModelDevice(device) {
|
|
84
|
+
if (requestedBackend === 'auto') return device.name === selectedBackend;
|
|
85
|
+
return device.name.toLowerCase().startsWith(requestedBackend.toLowerCase());
|
|
86
|
+
}) ?? state.devices.find(function fallbackCPU(device) {
|
|
87
|
+
return /^cpu/iu.test(device.name);
|
|
88
|
+
});
|
|
89
|
+
if (!requestedDevice && model.context?.backend === undefined) {
|
|
90
|
+
throw Object.assign(new Error(`No available device can run the requested ${requestedBackend} image backend.`), {code: 'LOCAL_IMAGE_BACKEND_UNAVAILABLE'});
|
|
91
|
+
}
|
|
92
|
+
publish({state: 'loading', selectedModel: model.id, error: null});
|
|
93
|
+
try {
|
|
94
|
+
const loaded = await library.load(
|
|
95
|
+
{
|
|
96
|
+
resources: model.resources,
|
|
97
|
+
context: {...model.context, backend: model.context?.backend ?? requestedDevice.name},
|
|
98
|
+
signal
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
signal.throwIfAborted();
|
|
102
|
+
selection = parameters.selection;
|
|
103
|
+
publish(
|
|
104
|
+
{
|
|
105
|
+
state: 'ready', loaded: true, resources: model.resources,
|
|
106
|
+
backend: loaded.backend ?? selectedBackend, modelVersion: loaded.modelVersion
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
await library.unload();
|
|
111
|
+
selection = undefined;
|
|
112
|
+
publish({state: signal.aborted ? 'unloaded' : 'error', loaded: false, resources: {}, error: describeError(error)});
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function execute(message, signal) {
|
|
118
|
+
if (message.operation === 'close') {
|
|
119
|
+
const errors = [];
|
|
120
|
+
try {
|
|
121
|
+
await unload();
|
|
122
|
+
} catch (error) {
|
|
123
|
+
errors.push(error);
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
await library.close();
|
|
127
|
+
} catch (error) {
|
|
128
|
+
errors.push(error);
|
|
129
|
+
}
|
|
130
|
+
if (errors.length === 1) throw errors[0];
|
|
131
|
+
if (errors.length > 1) throw new AggregateError(errors, 'The native image engine could not finish cleanup.');
|
|
132
|
+
publish({state: 'closed', available: false, closed: true});
|
|
133
|
+
return state;
|
|
134
|
+
}
|
|
135
|
+
if (message.operation === 'unload') {
|
|
136
|
+
await unload();
|
|
137
|
+
return state;
|
|
138
|
+
}
|
|
139
|
+
const parameters = message.parameters;
|
|
140
|
+
let image;
|
|
141
|
+
if (message.operation === 'edit') {
|
|
142
|
+
try {
|
|
143
|
+
image = await decodeImage({...parameters.image, signal});
|
|
144
|
+
} catch (error) {
|
|
145
|
+
// Decoding leaves the selected context unchanged. Replay its actual
|
|
146
|
+
// state so a broker cancellation cannot remain visibly unsettled.
|
|
147
|
+
publish({error: describeError(error)});
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
await load(parameters, signal);
|
|
152
|
+
if (message.operation === 'load') return state;
|
|
153
|
+
signal.throwIfAborted();
|
|
154
|
+
publish({state: 'generating', progress: null, error: null});
|
|
155
|
+
try {
|
|
156
|
+
const defaults = parameters.model.defaults ?? {};
|
|
157
|
+
const supplied = parameters.parameters ?? {};
|
|
158
|
+
const rasterImages = await library.generate(
|
|
159
|
+
{
|
|
160
|
+
prompt: parameters.prompt,
|
|
161
|
+
parameters: {
|
|
162
|
+
...defaults, ...supplied,
|
|
163
|
+
...(message.operation === 'edit' ? {strength: parameters.strength} : {}),
|
|
164
|
+
sample_params: {
|
|
165
|
+
...defaults.sample_params, ...supplied.sample_params,
|
|
166
|
+
guidance: {...defaults.sample_params?.guidance, ...supplied.sample_params?.guidance}
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
image,
|
|
170
|
+
signal
|
|
171
|
+
}
|
|
172
|
+
);
|
|
173
|
+
signal.throwIfAborted();
|
|
174
|
+
const images = [];
|
|
175
|
+
for (const image of rasterImages) {
|
|
176
|
+
const data = await encodePNG({...image, signal});
|
|
177
|
+
images.push({data, mediaType: 'image/png', width: image.width, height: image.height});
|
|
178
|
+
}
|
|
179
|
+
signal.throwIfAborted();
|
|
180
|
+
publish({state: 'ready', progress: null});
|
|
181
|
+
return {images};
|
|
182
|
+
} catch (error) {
|
|
183
|
+
publish({state: state.loaded ? 'ready' : 'error', progress: null, error: describeError(error)});
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
parentPort.on('message', function incoming(message) {
|
|
189
|
+
if (message.type === 'cancel') {
|
|
190
|
+
if (active?.id === message.id) active.controller.abort();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (message.type !== 'operation') return;
|
|
194
|
+
const controller = new AbortController();
|
|
195
|
+
active = {id: message.id, controller};
|
|
196
|
+
const task = execute(message, controller.signal);
|
|
197
|
+
task.then(
|
|
198
|
+
function completed(result) {
|
|
199
|
+
active = undefined;
|
|
200
|
+
parentPort.postMessage({type: 'result', id: message.id, result});
|
|
201
|
+
if (message.operation === 'close') parentPort.close();
|
|
202
|
+
},
|
|
203
|
+
function operationFailed(error) {
|
|
204
|
+
active = undefined;
|
|
205
|
+
parentPort.postMessage({type: 'result', id: message.id, error: describeError(error)});
|
|
206
|
+
if (message.operation === 'close') parentPort.close();
|
|
207
|
+
}
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
function describeError(error) {
|
|
212
|
+
if (!(error instanceof Error)) return {name: 'Error', message: String(error), cause: error};
|
|
213
|
+
return {
|
|
214
|
+
...error, name: error.name, message: error.message, stack: error.stack,
|
|
215
|
+
...(error.cause !== undefined ? {cause: describeError(error.cause)} : {}),
|
|
216
|
+
...(error instanceof AggregateError ? {errors: error.errors.map(describeError)} : {})
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
await initialize();
|
|
222
|
+
parentPort.postMessage({type: 'ready', state});
|
|
223
|
+
} catch (error) {
|
|
224
|
+
let failure = error;
|
|
225
|
+
try {
|
|
226
|
+
await library?.close();
|
|
227
|
+
} catch (cleanupError) {
|
|
228
|
+
failure = new AggregateError([error, cleanupError], 'Image runtime initialization and cleanup failed.');
|
|
229
|
+
}
|
|
230
|
+
parentPort.postMessage({type: 'fatal', error: describeError(failure)});
|
|
231
|
+
parentPort.close();
|
|
232
|
+
}
|