react-x11 2.53.0 → 2.54.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/docs/elements.md +6 -1
- package/package.json +1 -1
- package/src/imagedecode-worker.js +32 -0
- package/src/imagedecode.js +140 -2
- package/src/ntk.d.ts +12 -0
package/docs/elements.md
CHANGED
|
@@ -1465,7 +1465,12 @@ const logo = <image src={decodeImage(bytes)} />;
|
|
|
1465
1465
|
`decodeImageBytes` from `react-x11/ntk` is the same ladder for an element
|
|
1466
1466
|
that draws its own images (a document's `<img>`, a CSS background): an
|
|
1467
1467
|
`Image`, or a promise of one where decoding is async; it throws or rejects,
|
|
1468
|
-
naming what to convert to, where `<image>` would show nothing.
|
|
1468
|
+
naming what to convert to, where `<image>` would show nothing. One that
|
|
1469
|
+
waits for a promise anyway passes `{ offThread: true }`, and under Node the
|
|
1470
|
+
same JavaScript decoders run on a worker thread for bytes of 8 KB or more —
|
|
1471
|
+
a PNG and a JPEG then land a moment later too, as under Bun, and the
|
|
1472
|
+
JavaScript thread decodes none of a page's photographs. `<image>` does not
|
|
1473
|
+
ask, so the bytes it is handed keep their first frame.
|
|
1469
1474
|
|
|
1470
1475
|
Between the runtimes:
|
|
1471
1476
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The other end of `decodeImageBytes`'s `offThread` (imagedecode.js): the
|
|
2
|
+
// JavaScript rung, run where it holds no frame. A decode's pixels go back
|
|
3
|
+
// handed over, never copied, and its error as the message the caller's
|
|
4
|
+
// rejection carries.
|
|
5
|
+
import { parentPort } from 'node:worker_threads';
|
|
6
|
+
|
|
7
|
+
import { decodeHere } from './imagedecode.js';
|
|
8
|
+
|
|
9
|
+
parentPort.on('message', async ({ id, bytes }) => {
|
|
10
|
+
try {
|
|
11
|
+
const image = await decodeHere(bytes);
|
|
12
|
+
let data = image.data;
|
|
13
|
+
// a view of a larger buffer — a pooled Buffer — is copied out first,
|
|
14
|
+
// so what is handed over is the pixels and nothing beside them
|
|
15
|
+
if (
|
|
16
|
+
!(data.buffer instanceof ArrayBuffer) ||
|
|
17
|
+
data.byteOffset !== 0 ||
|
|
18
|
+
data.byteLength !== data.buffer.byteLength
|
|
19
|
+
) {
|
|
20
|
+
data = new Uint8Array(data);
|
|
21
|
+
}
|
|
22
|
+
parentPort.postMessage(
|
|
23
|
+
{ id, width: image.width, height: image.height, data: data.buffer },
|
|
24
|
+
[data.buffer],
|
|
25
|
+
);
|
|
26
|
+
} catch (err) {
|
|
27
|
+
parentPort.postMessage({
|
|
28
|
+
id,
|
|
29
|
+
error: err instanceof Error ? err.message : String(err),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
});
|
package/src/imagedecode.js
CHANGED
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
// 2. JavaScript. ntk's PNG and JPEG decoders (pngjs, jpeg-js), which are
|
|
14
14
|
// synchronous, and WebP through image-in-browser's decoder — imported
|
|
15
15
|
// the first time a WebP arrives, because evaluating it takes ~140ms
|
|
16
|
-
// that an app showing no WebP should never pay at startup.
|
|
16
|
+
// that an app showing no WebP should never pay at startup. A caller
|
|
17
|
+
// that takes a promise anyway asks for `offThread`, and the same
|
|
18
|
+
// decoders run on a worker thread (`imagedecode-worker.js`) for bytes
|
|
19
|
+
// large enough to pay for the trip: a document's images, which held
|
|
20
|
+
// the JavaScript thread for half of the Guardian's load.
|
|
17
21
|
//
|
|
18
22
|
// Two things here are easy to undo:
|
|
19
23
|
//
|
|
@@ -115,17 +119,41 @@ const hex = (bytes) =>
|
|
|
115
119
|
* elsewhere). Throws — or rejects — on bytes that are not an image this
|
|
116
120
|
* runtime can decode, with what to do about it.
|
|
117
121
|
*
|
|
122
|
+
* `options.offThread` asks for the decode to run on a worker thread where
|
|
123
|
+
* the runtime decodes in JavaScript, which it does for bytes long enough
|
|
124
|
+
* (`OFF_THREAD_BYTES`): a PNG or a JPEG then lands as a promise too. A
|
|
125
|
+
* caller that waits for a promise anyway — a document's images — asks for
|
|
126
|
+
* it; `<image>` does not, so a PNG it is handed still shows in the first
|
|
127
|
+
* frame.
|
|
128
|
+
*
|
|
118
129
|
* The bytes are read, never written, and under Bun they are read on another
|
|
119
130
|
* thread after this returns: the caller must not change them meanwhile,
|
|
120
131
|
* which `<image>`'s contract (a source is immutable content) already says.
|
|
121
132
|
*/
|
|
122
|
-
export function decodeImageBytes(bytes) {
|
|
133
|
+
export function decodeImageBytes(bytes, options) {
|
|
123
134
|
if (bytes.length === 0) {
|
|
124
135
|
throw new Error('react-x11: an empty buffer is not an image.');
|
|
125
136
|
}
|
|
126
137
|
const decoder = runtimeDecoder();
|
|
127
138
|
if (decoder) return decodeWithRuntime(decoder, bytes);
|
|
128
139
|
const format = imageFormat(bytes);
|
|
140
|
+
if (
|
|
141
|
+
options?.offThread &&
|
|
142
|
+
bytes.length >= OFF_THREAD_BYTES &&
|
|
143
|
+
(format === 'png' || format === 'jpeg' || format === 'webp')
|
|
144
|
+
) {
|
|
145
|
+
const pool = decodePool();
|
|
146
|
+
if (pool) return pool.decode(bytes, format);
|
|
147
|
+
}
|
|
148
|
+
return decodeHere(bytes, format);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The JavaScript rung, on whichever thread runs it: what `decodeImageBytes`
|
|
153
|
+
* does where the runtime has no decoder of its own, and what its worker
|
|
154
|
+
* does with the bytes it is handed.
|
|
155
|
+
*/
|
|
156
|
+
export function decodeHere(bytes, format = imageFormat(bytes)) {
|
|
129
157
|
if (format === 'png' || format === 'jpeg') return decodeImage(bytes);
|
|
130
158
|
if (format === 'webp') return decodeWebP(bytes);
|
|
131
159
|
if (format) {
|
|
@@ -142,6 +170,116 @@ export function decodeImageBytes(bytes) {
|
|
|
142
170
|
);
|
|
143
171
|
}
|
|
144
172
|
|
|
173
|
+
/**
|
|
174
|
+
* How long encoded bytes are before a decode asked to run off the
|
|
175
|
+
* JavaScript thread is handed to a worker: under it, the message costs
|
|
176
|
+
* about what the decode does.
|
|
177
|
+
*/
|
|
178
|
+
const OFF_THREAD_BYTES = 8 * 1024;
|
|
179
|
+
|
|
180
|
+
/** The most decode workers a process starts. */
|
|
181
|
+
const MAX_WORKERS = 2;
|
|
182
|
+
|
|
183
|
+
/** The pool `offThread` decodes on: undefined until asked, null where the
|
|
184
|
+
* runtime has no worker threads. */
|
|
185
|
+
let pool;
|
|
186
|
+
|
|
187
|
+
function decodePool() {
|
|
188
|
+
if (pool !== undefined) return pool;
|
|
189
|
+
const threads = globalThis.process?.getBuiltinModule?.('node:worker_threads');
|
|
190
|
+
pool = threads?.Worker ? new DecodePool(threads.Worker) : null;
|
|
191
|
+
return pool;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Workers that decode with the JavaScript rung (`decodeHere`), started as
|
|
196
|
+
* decodes arrive, up to `MAX_WORKERS`. A worker holds the process open only
|
|
197
|
+
* while it has a decode to answer. One that cannot start, or dies, has its
|
|
198
|
+
* decodes made here, and none starts after it: a bundle that left the
|
|
199
|
+
* worker's file behind decodes as it always did.
|
|
200
|
+
*/
|
|
201
|
+
class DecodePool {
|
|
202
|
+
constructor(Worker) {
|
|
203
|
+
this._Worker = Worker;
|
|
204
|
+
this._workers = [];
|
|
205
|
+
this._jobs = new Map();
|
|
206
|
+
this._next = 1;
|
|
207
|
+
this._broken = false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
decode(bytes, format) {
|
|
211
|
+
const worker = this._worker();
|
|
212
|
+
if (!worker) return decodeHere(bytes, format);
|
|
213
|
+
const id = this._next++;
|
|
214
|
+
// a copy, handed over: the caller's bytes stay where they are, and a
|
|
215
|
+
// Buffer's `slice` is a view of them
|
|
216
|
+
const copy = new Uint8Array(bytes);
|
|
217
|
+
try {
|
|
218
|
+
worker.thread.postMessage({ id, bytes: copy }, [copy.buffer]);
|
|
219
|
+
} catch {
|
|
220
|
+
return decodeHere(bytes, format);
|
|
221
|
+
}
|
|
222
|
+
if (worker.pending++ === 0) worker.thread.ref();
|
|
223
|
+
return new Promise((resolve, reject) => {
|
|
224
|
+
this._jobs.set(id, { resolve, reject, worker, bytes, format });
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The least busy worker, or a new one while every one is busy. */
|
|
229
|
+
_worker() {
|
|
230
|
+
if (this._broken) return null;
|
|
231
|
+
let best = null;
|
|
232
|
+
for (const w of this._workers) {
|
|
233
|
+
if (!best || w.pending < best.pending) best = w;
|
|
234
|
+
}
|
|
235
|
+
if (best && (best.pending === 0 || this._workers.length >= MAX_WORKERS)) {
|
|
236
|
+
return best;
|
|
237
|
+
}
|
|
238
|
+
let thread;
|
|
239
|
+
try {
|
|
240
|
+
thread = new this._Worker(
|
|
241
|
+
new URL('./imagedecode-worker.js', import.meta.url),
|
|
242
|
+
);
|
|
243
|
+
} catch {
|
|
244
|
+
this._broken = true;
|
|
245
|
+
return best;
|
|
246
|
+
}
|
|
247
|
+
const w = { thread, pending: 0 };
|
|
248
|
+
thread.unref();
|
|
249
|
+
thread.on('message', (message) => this._answer(w, message));
|
|
250
|
+
thread.on('error', () => this._lost(w));
|
|
251
|
+
thread.on('exit', () => this._lost(w));
|
|
252
|
+
this._workers.push(w);
|
|
253
|
+
return w;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
_answer(w, { id, width, height, data, error }) {
|
|
257
|
+
const job = this._jobs.get(id);
|
|
258
|
+
if (!job) return;
|
|
259
|
+
this._jobs.delete(id);
|
|
260
|
+
if (--w.pending === 0) w.thread.unref();
|
|
261
|
+
if (error !== undefined) job.reject(new Error(error));
|
|
262
|
+
else job.resolve(new Image({ width, height, data: new Uint8Array(data) }));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
_lost(w) {
|
|
266
|
+
this._broken = true;
|
|
267
|
+
this._workers = this._workers.filter((other) => other !== w);
|
|
268
|
+
for (const [id, job] of this._jobs) {
|
|
269
|
+
if (job.worker !== w) continue;
|
|
270
|
+
this._jobs.delete(id);
|
|
271
|
+
try {
|
|
272
|
+
Promise.resolve(decodeHere(job.bytes, job.format)).then(
|
|
273
|
+
job.resolve,
|
|
274
|
+
job.reject,
|
|
275
|
+
);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
job.reject(err);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
145
283
|
/** Read a file path or file URL and decode it. */
|
|
146
284
|
export async function loadImageFile(path) {
|
|
147
285
|
const { readFile } = await import('node:fs/promises');
|
package/src/ntk.d.ts
CHANGED
|
@@ -115,8 +115,20 @@ export interface DecodedImage {
|
|
|
115
115
|
*/
|
|
116
116
|
export function decodeImageBytes(
|
|
117
117
|
bytes: Uint8Array,
|
|
118
|
+
options?: DecodeImageOptions,
|
|
118
119
|
): DecodedImage | Promise<DecodedImage>;
|
|
119
120
|
|
|
121
|
+
/** How `decodeImageBytes` decodes. */
|
|
122
|
+
export interface DecodeImageOptions {
|
|
123
|
+
/**
|
|
124
|
+
* Decode on a worker thread where the runtime decodes in JavaScript (Node),
|
|
125
|
+
* for bytes long enough to pay for the trip: the answer is then a promise
|
|
126
|
+
* for a PNG and a JPEG too. Under Bun every decode is off the thread
|
|
127
|
+
* already.
|
|
128
|
+
*/
|
|
129
|
+
offThread?: boolean;
|
|
130
|
+
}
|
|
131
|
+
|
|
120
132
|
/** `code` values on a failed GL setup — see `<glarea onError>`. */
|
|
121
133
|
export const GLXError: {
|
|
122
134
|
NO_EXTENSION: 'GLX_NO_EXTENSION';
|