arcane-os 0.59.0 → 0.60.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.
@@ -0,0 +1,267 @@
1
+ import {mkdir, mkdtemp, open, rm} from 'node:fs/promises';
2
+ import {Buffer} from 'node:buffer';
3
+ import path from 'node:path';
4
+ import Is from 'strong-type';
5
+ import {CoreError, serializeCoreError} from '../../../browser-runtime/core/contracts.mjs';
6
+
7
+ const is = new Is(false);
8
+
9
+ function failure(code, message) {
10
+ return new CoreError({code, message});
11
+ }
12
+
13
+ /** Working files for native engines. The browser's stored assets stay authoritative. */
14
+ export function createModelAssetService({appRoot = process.cwd()} = {}) {
15
+ const projections = new Map();
16
+ let context;
17
+ let closing;
18
+
19
+ function snapshot(record) {
20
+ return {
21
+ id: record.id, directory: record.directory, state: record.state,
22
+ members: record.members.map(function memberLocation(member) {
23
+ return {path: member.path, nativePath: member.nativePath};
24
+ }),
25
+ preparationOwned: record.preparationOwned, uses: record.uses.size,
26
+ error: record.error ? serializeCoreError(record.error) : null
27
+ };
28
+ }
29
+
30
+ function current() {
31
+ return {closing: Boolean(closing), projections: [...projections.values()].map(snapshot)};
32
+ }
33
+
34
+ function publish(record) {
35
+ context?.emit('modelAssets.state', snapshot(record));
36
+ }
37
+
38
+ function requireProjection(id) {
39
+ const record = projections.get(id);
40
+ if (!record) throw failure('MODEL_ASSET_PROJECTION_UNAVAILABLE', `Model asset projection ${id} is unavailable.`);
41
+ return record;
42
+ }
43
+
44
+ async function closeMembers(record) {
45
+ const results = await Promise.allSettled(record.members.map(async function closeMember(member) {
46
+ await member.tail;
47
+ if (!member.handle) return;
48
+ const handle = member.handle;
49
+ await handle.close();
50
+ member.handle = null;
51
+ }));
52
+ const errors = results.filter(function rejected(result) { return result.status === 'rejected'; })
53
+ .map(function reason(result) { return result.reason; });
54
+ if (errors.length) throw new AggregateError(errors, 'Closing model asset files failed.');
55
+ }
56
+
57
+ function cleanup(record) {
58
+ if (record.preparationOwned || record.uses.size) return Promise.resolve(snapshot(record));
59
+ if (record.cleanup) return record.cleanup;
60
+ record.state = 'releasing';
61
+ // Retain the cleanup task before events can trigger another release.
62
+ record.cleanup = Promise.resolve().then(async function removeWorkingProjection() {
63
+ const errors = [];
64
+ let filesClosed = false;
65
+ try { await record.opening; } catch (error) { record.error ??= error; }
66
+ try { await record.finishing; } catch (error) { record.error ??= error; }
67
+ try { await closeMembers(record); filesClosed = true; } catch (error) { errors.push(error); }
68
+ if (record.directory && filesClosed) {
69
+ try { await rm(record.directory, {recursive: true}); }
70
+ catch (error) { if (error.code !== 'ENOENT') errors.push(error); }
71
+ }
72
+ if (errors.length) throw new AggregateError(errors, 'Releasing model asset working files failed.');
73
+ record.state = 'released';
74
+ projections.delete(record.id);
75
+ publish(record);
76
+ return snapshot(record);
77
+ });
78
+ record.cleanup.then(record.resolveReleased, function cleanupFailed(error) {
79
+ record.error = error;
80
+ record.state = 'error';
81
+ publish(record);
82
+ record.rejectReleased(error);
83
+ });
84
+ publish(record);
85
+ return record.cleanup;
86
+ }
87
+
88
+ function releasePreparation(record) {
89
+ record.preparationOwned = false;
90
+ publish(record);
91
+ return cleanup(record);
92
+ }
93
+
94
+ async function failedOperation(record, error) {
95
+ record.error = error;
96
+ record.state = 'error';
97
+ publish(record);
98
+ try { await releasePreparation(record); }
99
+ catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Preparing model assets failed and cleanup failed.'); }
100
+ throw error;
101
+ }
102
+
103
+ async function openProjection({id, workingDirectory, members}, request) {
104
+ request.signal.throwIfAborted();
105
+ if (closing) throw failure('MODEL_ASSETS_CLOSING', 'Model asset preparation is closing.');
106
+ if (!is.string(id) || !id) throw new TypeError('A model asset projection needs an operation id.');
107
+ if (!is.string(workingDirectory) || !workingDirectory) {
108
+ throw new TypeError('Model asset preparation needs an explicit working directory.');
109
+ }
110
+ if (!is.array(members) || !members.length) throw new TypeError('Select the complete model asset members.');
111
+ if (projections.has(id)) throw failure('MODEL_ASSET_PROJECTION_ACTIVE', `Model asset projection ${id} already exists.`);
112
+ const record = {
113
+ id, state: 'preparing', directory: null, members: [], uses: new Set(),
114
+ preparationOwned: true, error: null, opening: null, finishing: null, cleanup: null
115
+ };
116
+ record.released = new Promise(function projectionReleased(resolve, reject) {
117
+ record.resolveReleased = resolve;
118
+ record.rejectReleased = reject;
119
+ });
120
+ record.released.catch(function observeReleaseFailure() {});
121
+ projections.set(id, record);
122
+ record.opening = Promise.resolve().then(async function createWorkingFiles() {
123
+ const selectedDirectory = path.resolve(appRoot, workingDirectory);
124
+ await mkdir(selectedDirectory, {recursive: true});
125
+ request.signal.throwIfAborted();
126
+ record.directory = await mkdtemp(path.join(selectedDirectory, 'arcane-model-'));
127
+ record.members = members.map(function memberRecord(member) {
128
+ const nativePath = path.resolve(record.directory, member.path);
129
+ const relative = path.relative(record.directory, nativePath);
130
+ // A projection consists of files under its one owned directory.
131
+ // Absolute or escaping members cannot participate in that lifetime.
132
+ if (path.isAbsolute(member.path) || !relative || relative === '..'
133
+ || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
134
+ throw new TypeError('Model asset member paths must be relative files within their projection.');
135
+ }
136
+ return {path: member.path, nativePath, handle: null, tail: Promise.resolve()};
137
+ });
138
+ const results = await Promise.allSettled(record.members.map(async function createMember(member) {
139
+ request.signal.throwIfAborted();
140
+ await mkdir(path.dirname(member.nativePath), {recursive: true});
141
+ request.signal.throwIfAborted();
142
+ member.handle = await open(member.nativePath, 'wx');
143
+ request.signal.throwIfAborted();
144
+ }));
145
+ const errors = results.filter(function rejected(result) { return result.status === 'rejected'; })
146
+ .map(function reason(result) { return result.reason; });
147
+ if (errors.length) throw new AggregateError(errors, 'Opening model asset working files failed.');
148
+ });
149
+ publish(record);
150
+ try {
151
+ await record.opening;
152
+ request.signal.throwIfAborted();
153
+ if (!record.preparationOwned) throw failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was released.');
154
+ return snapshot(record);
155
+ } catch (error) { return failedOperation(record, error); }
156
+ }
157
+
158
+ async function writeMember({id, memberIndex, contentBase64}, request) {
159
+ const record = requireProjection(id);
160
+ await record.opening;
161
+ request.signal.throwIfAborted();
162
+ if (record.state !== 'preparing' || !record.preparationOwned) {
163
+ throw failure('MODEL_ASSET_PROJECTION_NOT_WRITABLE', 'Model asset preparation is no longer accepting content.');
164
+ }
165
+ const member = record.members[memberIndex];
166
+ if (!member) throw new TypeError('Select an existing model asset member.');
167
+ try {
168
+ const task = member.tail.then(async function appendOriginalContent() {
169
+ request.signal.throwIfAborted();
170
+ if (!record.preparationOwned) throw failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was released.');
171
+ await member.handle.appendFile(Buffer.from(contentBase64, 'base64'));
172
+ request.signal.throwIfAborted();
173
+ });
174
+ // File writes remain ordered; unrelated member files write independently.
175
+ member.tail = task.catch(function observeWriteFailure() {});
176
+ await task;
177
+ return {id, memberIndex, written: true};
178
+ } catch (error) { return failedOperation(record, error); }
179
+ }
180
+
181
+ async function completeProjection({id}, request) {
182
+ const record = requireProjection(id);
183
+ await record.opening;
184
+ request.signal.throwIfAborted();
185
+ if (record.state === 'completing') {
186
+ await record.finishing;
187
+ request.signal.throwIfAborted();
188
+ }
189
+ if (record.state === 'ready') return snapshot(record);
190
+ if (record.state !== 'preparing' || !record.preparationOwned) {
191
+ throw failure('MODEL_ASSET_PROJECTION_NOT_WRITABLE', 'Model asset preparation cannot be completed in its current state.');
192
+ }
193
+ record.state = 'completing';
194
+ record.finishing = Promise.resolve().then(async function finishProjection() {
195
+ await closeMembers(record);
196
+ request.signal.throwIfAborted();
197
+ if (!record.preparationOwned || record.error) {
198
+ throw record.error ?? failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was released.');
199
+ }
200
+ record.state = 'ready';
201
+ publish(record);
202
+ return snapshot(record);
203
+ });
204
+ try { return await record.finishing; }
205
+ catch (error) { return failedOperation(record, error); }
206
+ }
207
+
208
+ function retain(id) {
209
+ const record = requireProjection(id);
210
+ if (closing || record.state !== 'ready') {
211
+ throw failure('MODEL_ASSET_PROJECTION_NOT_READY', 'The complete native model asset projection is not ready.');
212
+ }
213
+ const use = {};
214
+ record.uses.add(use);
215
+ let released;
216
+ publish(record);
217
+ return {
218
+ id: record.id, directory: record.directory, members: snapshot(record).members,
219
+ release() {
220
+ if (released) return released;
221
+ record.uses.delete(use);
222
+ // Assign before publishing so reentrant release remains idempotent.
223
+ released = Promise.resolve().then(function releaseEngineUse() { return cleanup(record); });
224
+ publish(record);
225
+ return released;
226
+ }
227
+ };
228
+ }
229
+
230
+ function dispose() {
231
+ if (closing) return closing;
232
+ closing = Promise.resolve().then(async function releaseAllProjections() {
233
+ const records = [...projections.values()];
234
+ for (const record of records) {
235
+ releasePreparation(record).catch(function observeCleanupFailure() {});
236
+ }
237
+ // Core closes services concurrently. Engine retain handles keep files
238
+ // alive until those owners finish actual native unload/worker exit.
239
+ const results = await Promise.allSettled(records.map(function awaitRelease(record) { return record.released; }));
240
+ const errors = results.filter(function rejected(result) { return result.status === 'rejected'; })
241
+ .map(function reason(result) { return result.reason; });
242
+ if (errors.length) throw new AggregateError(errors, 'Closing model asset preparation failed.');
243
+ });
244
+ return closing;
245
+ }
246
+
247
+ return {
248
+ name: 'model-assets', current, retain,
249
+ start(serviceContext) { context = serviceContext; },
250
+ methods: {
251
+ 'modelAssets.open': openProjection,
252
+ 'modelAssets.write': writeMember,
253
+ 'modelAssets.complete': completeProjection,
254
+ 'modelAssets.status': function status() { return current(); },
255
+ 'modelAssets.release': {
256
+ lifetime: 'service',
257
+ handle({id}) {
258
+ const record = projections.get(id);
259
+ return record ? releasePreparation(record) : {id, state: 'released'};
260
+ }
261
+ }
262
+ },
263
+ dispose
264
+ };
265
+ }
266
+
267
+ export default createModelAssetService;
@@ -0,0 +1,159 @@
1
+ import {inflate} from 'node:zlib';
2
+ import {promisify} from 'node:util';
3
+ import {setImmediate} from 'node:timers/promises';
4
+
5
+ const inflatePNG = promisify(inflate);
6
+
7
+ function imageError(code, message) {
8
+ const error = new Error(message);
9
+ error.code = code;
10
+ return error;
11
+ }
12
+
13
+ function malformed(message) {
14
+ return imageError('ARCANE_IMAGE_DECODE_FAILED', message);
15
+ }
16
+
17
+ function unsupported(message) {
18
+ return imageError('ARCANE_IMAGE_INPUT_UNSUPPORTED', message);
19
+ }
20
+
21
+ /**
22
+ * Decode a complete encoded PNG in the shared image runtime's owning worker.
23
+ * The returned pixels are a separate allocation retained through native completion.
24
+ */
25
+ export async function decodeImage({data, mediaType, signal} = {}) {
26
+ signal?.throwIfAborted();
27
+ if (!(data instanceof Uint8Array)) {
28
+ throw new TypeError('Image decoding requires encoded Uint8Array data.');
29
+ }
30
+ if (mediaType !== undefined && mediaType !== '' && mediaType !== 'image/png') {
31
+ throw unsupported('Local image editing currently accepts PNG input.');
32
+ }
33
+
34
+ const encoded = Buffer.from(data);
35
+ const signature = [137, 80, 78, 71, 13, 10, 26, 10];
36
+ for (const [index, value] of signature.entries()) {
37
+ if (encoded[index] !== value) throw unsupported('The supplied image is not a PNG.');
38
+ }
39
+
40
+ const imageData = [];
41
+ let header;
42
+ let transparency;
43
+ let ended = false;
44
+ let offset = signature.length;
45
+ while (offset < encoded.length) {
46
+ signal?.throwIfAborted();
47
+ // These lengths describe PNG chunk framing, not image admission limits.
48
+ if (encoded.length - offset < 12) throw malformed('The PNG ends inside a chunk.');
49
+ const length = encoded.readUInt32BE(offset);
50
+ const type = encoded.toString('ascii', offset + 4, offset + 8);
51
+ const start = offset + 8;
52
+ const end = start + length;
53
+ if (end + 4 > encoded.length) throw malformed(`The PNG ${type} chunk is incomplete.`);
54
+ if (!header && type !== 'IHDR') throw malformed('The PNG is missing its initial IHDR chunk.');
55
+
56
+ if (type === 'IHDR') {
57
+ if (header || length !== 13) throw malformed('The PNG IHDR chunk is invalid.');
58
+ header = {
59
+ width: encoded.readUInt32BE(start),
60
+ height: encoded.readUInt32BE(start + 4),
61
+ depth: encoded[start + 8],
62
+ color: encoded[start + 9],
63
+ compression: encoded[start + 10],
64
+ filter: encoded[start + 11],
65
+ interlace: encoded[start + 12]
66
+ };
67
+ if (!header.width || !header.height) throw malformed('PNG dimensions must be nonzero.');
68
+ if (header.depth !== 8 || (header.color !== 2 && header.color !== 6)
69
+ || header.compression !== 0 || header.filter !== 0 || header.interlace !== 0) {
70
+ throw unsupported('Local image editing accepts non-interlaced 8-bit RGB or RGBA PNG images.');
71
+ }
72
+ } else if (type === 'IDAT') {
73
+ imageData.push(encoded.subarray(start, end));
74
+ } else if (type === 'tRNS') {
75
+ if (header.color !== 2 || length !== 6 || transparency) {
76
+ throw malformed('The PNG transparency chunk is invalid for its color type.');
77
+ }
78
+ // PNG uses only the low sample bits for an 8-bit transparent color.
79
+ transparency = [
80
+ encoded.readUInt16BE(start) & 255,
81
+ encoded.readUInt16BE(start + 2) & 255,
82
+ encoded.readUInt16BE(start + 4) & 255
83
+ ];
84
+ } else if (type === 'acTL' || type === 'fcTL' || type === 'fdAT') {
85
+ throw unsupported('Local image editing requires a still PNG; animated PNG input is unsupported.');
86
+ } else if (type === 'IEND') {
87
+ if (length !== 0) throw malformed('The PNG IEND chunk is invalid.');
88
+ ended = true;
89
+ break;
90
+ } else if (type !== 'PLTE' && (encoded[offset + 4] & 32) === 0) {
91
+ throw unsupported(`The PNG critical chunk ${type} is unsupported.`);
92
+ }
93
+ offset = end + 4;
94
+ }
95
+ if (!header || !ended || !imageData.length) throw malformed('The PNG image stream is incomplete.');
96
+
97
+ signal?.throwIfAborted();
98
+ // All IDAT chunks form one zlib stream. An abort still joins its completion.
99
+ const filtered = await inflatePNG(Buffer.concat(imageData));
100
+ signal?.throwIfAborted();
101
+ const {width, height} = header;
102
+ const sourceChannels = header.color === 6 ? 4 : 3;
103
+ const channel = transparency ? 4 : sourceChannels;
104
+ const rowLength = width * sourceChannels;
105
+ const pixels = new Uint8Array(width * height * channel);
106
+ let previous = new Uint8Array(rowLength);
107
+ let row = new Uint8Array(rowLength);
108
+ let position = 0;
109
+
110
+ for (let y = 0; y < height; y += 1) {
111
+ signal?.throwIfAborted();
112
+ if (position + 1 + rowLength > filtered.length) throw malformed('The PNG ends inside an image row.');
113
+ const filter = filtered[position++];
114
+ if (filter > 4) throw malformed(`The PNG row filter ${filter} is unsupported.`);
115
+ for (let x = 0; x < rowLength; x += 1) {
116
+ const left = x >= sourceChannels ? row[x - sourceChannels] : 0;
117
+ const above = previous[x];
118
+ const upperLeft = x >= sourceChannels ? previous[x - sourceChannels] : 0;
119
+ let prediction = 0;
120
+ if (filter === 1) prediction = left;
121
+ else if (filter === 2) prediction = above;
122
+ else if (filter === 3) prediction = Math.floor((left + above) / 2);
123
+ else if (filter === 4) prediction = paeth(left, above, upperLeft);
124
+ row[x] = (filtered[position++] + prediction) & 255;
125
+ }
126
+
127
+ if (transparency) {
128
+ for (let x = 0; x < width; x += 1) {
129
+ const source = x * sourceChannels;
130
+ const target = (y * width + x) * channel;
131
+ pixels[target] = row[source];
132
+ pixels[target + 1] = row[source + 1];
133
+ pixels[target + 2] = row[source + 2];
134
+ pixels[target + 3] = row[source] === transparency[0]
135
+ && row[source + 1] === transparency[1]
136
+ && row[source + 2] === transparency[2] ? 0 : 255;
137
+ }
138
+ } else {
139
+ pixels.set(row, y * width * channel);
140
+ }
141
+ const reusable = previous;
142
+ previous = row;
143
+ row = reusable;
144
+ // Let the owning worker receive cancellation between scanlines.
145
+ await setImmediate(undefined, {signal});
146
+ }
147
+ if (position !== filtered.length) throw malformed('The PNG contains data beyond its declared image rows.');
148
+ signal?.throwIfAborted();
149
+ return {width, height, channel, data: pixels};
150
+ }
151
+
152
+ function paeth(left, above, upperLeft) {
153
+ const prediction = left + above - upperLeft;
154
+ const fromLeft = Math.abs(prediction - left);
155
+ const fromAbove = Math.abs(prediction - above);
156
+ const fromUpperLeft = Math.abs(prediction - upperLeft);
157
+ if (fromLeft <= fromAbove && fromLeft <= fromUpperLeft) return left;
158
+ return fromAbove <= fromUpperLeft ? above : upperLeft;
159
+ }