react-x11 2.31.0 → 2.33.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,641 @@
1
+ // <video>: frames an application decodes (`frames`, a VideoFrames sink), or a
2
+ // file or URL the platform plays (`src`, where there is a player), fitted
3
+ // into the node's box (docs/architecture/video.md §6–7).
4
+ //
5
+ // A drawn node like `<image>`: it joins yoga, it is in the paint order and it
6
+ // is hit like anything else — which is the whole point. A video in a form
7
+ // sits under the form's popups and tooltips and inside its scroll panes, and
8
+ // a child window or a `<glarea>`'s layer can do neither.
9
+ //
10
+ // It is presented two ways, decided per frame:
11
+ //
12
+ // - **lifted** — it offers its picture as its one sprite part (`sprites()`),
13
+ // with the sink as the part's `contents`; a presenter that lifts sprites
14
+ // (the Cocoa surface presenter, over a bridge with video surfaces) puts
15
+ // the frames on a layer of their own, written by `push` itself, and this
16
+ // node leaves a hole where the picture is. No react-x11 frame per video
17
+ // frame.
18
+ // - **drawn** — everywhere else, and in every frame the presenter declines
19
+ // the part (something is painted over it, it fades, a rounded card does
20
+ // not hold it): the newest frame is copied into a surface of the node's
21
+ // own at paint, natively where the backend can (`app.writeVideoFrame`) and
22
+ // through `frameToRGBA` where it cannot, and composited at the picture's
23
+ // rect like an `<image>` whose pixels changed. A push claims the picture.
24
+ //
25
+ // `src` needs a platform player, which this backend has when
26
+ // `useSupports('mediaPlayback')` says so — the app's `createPlayer`, on macOS
27
+ // AVFoundation's (src/cocoa/player.js). The player is the part's `contents`
28
+ // the way a sink is: lifted, its own layer shows the picture; drawn, the
29
+ // frame showing is copied into the node's surface (`copyFrame`) for as long
30
+ // as it plays. Where there is no player the element says so once, through
31
+ // `onError`, and shows its poster — never a black box that looks like a
32
+ // video that has not started (#531's rule for `<foreign>`).
33
+
34
+ import { loadImageFile } from '../imagedecode.js';
35
+ import {
36
+ decodeImageSource,
37
+ freeImage,
38
+ isDirectImageSource,
39
+ isPathImageSource,
40
+ toLoadablePath,
41
+ } from '../imagesource.js';
42
+ import { canPlayMedia, NoMediaPlaybackError } from '../mediaplayback.js';
43
+ import { Surface } from '../offscreen.js';
44
+ import { frameToRGBA, isVideoFrames } from '../videoframes.js';
45
+ import { fitRect, objectFitOf } from './fit.js';
46
+ import { intrinsicSize } from './layout.js';
47
+ import { Node } from './node.js';
48
+ import { DEV } from './util.js';
49
+
50
+ // HTML's size for a video that has nothing to say about its own
51
+ const DEFAULT_WIDTH = 300;
52
+ const DEFAULT_HEIGHT = 150;
53
+
54
+ // How often a drawn player's frame is asked for while it plays: a display
55
+ // frame, since the copy answers null for one that has not changed and the
56
+ // claim follows only a new one.
57
+ const PULL_MS = 16;
58
+
59
+ // The player's settings among a `<video>`'s props, as the player takes them.
60
+ const PLAYER_PROPS = ['paused', 'muted', 'volume', 'loop', 'playbackRate'];
61
+
62
+ // Apps already told, by a <video src> with no `onError`, that this backend
63
+ // plays nothing: the answer is the backend's, so the second element to ask
64
+ // carries no news.
65
+ const warnedNoPlayback = new WeakSet();
66
+
67
+ /** Throws for a combination no `<video>` can be. */
68
+ export function validateVideoProps(props) {
69
+ if (props.frames != null && props.src != null) {
70
+ throw new Error(
71
+ 'react-x11: <video> takes frames or src, not both — frames are ' +
72
+ 'decoded by the application, src by the platform',
73
+ );
74
+ }
75
+ if (props.frames != null && !isVideoFrames(props.frames)) {
76
+ throw new Error(
77
+ 'react-x11: <video frames> must be a VideoFrames sink — from ' +
78
+ 'useVideoFrames({ width, height, format }) or createVideoFrames(app, …)',
79
+ );
80
+ }
81
+ }
82
+
83
+ export class VideoNode extends Node {
84
+ constructor(props, app) {
85
+ super('video', props, app);
86
+ validateVideoProps(props);
87
+ this.focusableByDefault = false;
88
+ /** the sink being shown, subscribed to from the first layout on */
89
+ this._sink = null;
90
+ this._unsubscribe = null;
91
+ /** whether the presenter has the picture on a layer (`spritesLifted`) */
92
+ this._lifted = false;
93
+ /** the drawn presentation: a surface at the frame's size, and which
94
+ * frame it holds */
95
+ this._surface = null;
96
+ this._drawn = -1;
97
+ this._rgba = null;
98
+ /** the poster, once loaded or decoded */
99
+ this._poster = null;
100
+ this._ownedPoster = null;
101
+ this._posterToken = 0;
102
+ this._refused = false;
103
+ /** `<video src>`'s player, from the first layout on, and what the drawn
104
+ * presentation has of it: whether a frame is in the surface, and the
105
+ * timer that asks for the next one while it plays */
106
+ this._player = null;
107
+ this._playerOff = null;
108
+ this._playerFrame = false;
109
+ this._pull = null;
110
+ // Like `<image>`, nothing is resolved in the constructor: it runs in the
111
+ // render phase, which React may discard, and a subscription made there
112
+ // would never be undone.
113
+ this._dirty = true;
114
+ }
115
+
116
+ // --- the source -------------------------------------------------------------
117
+
118
+ /** First layout or paint after a mount or a change: both after commit. */
119
+ _ensure() {
120
+ if (!this._dirty || this.destroyed) return;
121
+ this._dirty = false;
122
+ this._attach();
123
+ }
124
+
125
+ _attach() {
126
+ const { frames, src, poster } = this.props;
127
+ if (frames != null) {
128
+ this._sink = frames;
129
+ this._unsubscribe = frames._subscribe((frame) => this._onFrame(frame));
130
+ this._metadata();
131
+ } else if (src != null) {
132
+ if (canPlayMedia(this.app)) this._play(src);
133
+ else this._refuse();
134
+ }
135
+ if (poster != null) this._loadPoster(poster);
136
+ }
137
+
138
+ _detach() {
139
+ this._unsubscribe?.();
140
+ this._unsubscribe = null;
141
+ this._sink = null;
142
+ this._playerOff?.();
143
+ this._playerOff = null;
144
+ this._player?.release();
145
+ this._player = null;
146
+ this._playerFrame = false;
147
+ this._stopPull();
148
+ this._posterToken++;
149
+ if (this._ownedPoster) {
150
+ freeImage(this.app, this._ownedPoster);
151
+ this._ownedPoster = null;
152
+ }
153
+ this._poster = null;
154
+ this._refused = false;
155
+ }
156
+
157
+ /**
158
+ * A push, or a close. Lifted, the layer shows the frame and the window
159
+ * paints nothing; otherwise the picture is claimed, and the frame that
160
+ * claim brings asks for the parts again — which is how the first frame
161
+ * goes up onto a layer and a close takes the picture back down.
162
+ */
163
+ _onFrame(frame) {
164
+ if (this.destroyed) return;
165
+ if (frame === null || !this._lifted) {
166
+ this.root?.invalidate(false, this, 'content');
167
+ }
168
+ }
169
+
170
+ _metadata() {
171
+ const sink = this._sink;
172
+ const handler = this.props.onLoadedMetadata;
173
+ if (!handler || !sink) return;
174
+ // after the commit that mounted it: the ordinary answer to the news is
175
+ // to set state, which React refuses in the middle of a commit
176
+ queueMicrotask(() => {
177
+ if (this.destroyed || this._sink !== sink) return;
178
+ handler({
179
+ width: sink.width,
180
+ height: sink.height,
181
+ // a sink is a live source: HTML's duration for one
182
+ duration: Infinity,
183
+ node: this,
184
+ });
185
+ });
186
+ }
187
+
188
+ /** `src` on the platform's player, playing from the start if the props
189
+ * say so: a controlled `paused={false}`, or `autoPlay`. */
190
+ _play(src) {
191
+ const p = this.props;
192
+ const player = this.app.createPlayer(src, {
193
+ autoPlay:
194
+ p.paused !== undefined ? p.paused === false : Boolean(p.autoPlay),
195
+ loop: p.loop,
196
+ muted: p.muted,
197
+ volume: p.volume,
198
+ playbackRate: p.playbackRate,
199
+ });
200
+ this._player = player;
201
+ this._playerOff = player._subscribe((ev) => this._onPlayer(ev));
202
+ }
203
+
204
+ /** The player's news, as HTML's media events — after the frame they
205
+ * change has been asked for. */
206
+ _onPlayer(ev) {
207
+ if (this.destroyed) return;
208
+ const player = this._player;
209
+ const props = this.props;
210
+ switch (ev.type) {
211
+ case 'player-metadata':
212
+ // a size at last, or a new one: laid out again, and a part to offer
213
+ this.invalidateMeasure('content');
214
+ this.spritesChanged();
215
+ props.onLoadedMetadata?.({
216
+ width: player.width,
217
+ height: player.height,
218
+ duration: player.duration,
219
+ node: this,
220
+ });
221
+ return;
222
+ case 'player-state':
223
+ this._syncPull();
224
+ if (player.playing) props.onPlay?.();
225
+ else props.onPause?.();
226
+ return;
227
+ case 'player-time':
228
+ // a seek while paused shows another frame, which nothing else pulls
229
+ if (!player.playing) this._pullFrame();
230
+ props.onTimeUpdate?.({ currentTime: player.currentTime, node: this });
231
+ return;
232
+ case 'player-ended':
233
+ this._syncPull();
234
+ props.onEnded?.();
235
+ return;
236
+ case 'player-error':
237
+ this.error = player.error;
238
+ if (props.onError) props.onError(player.error);
239
+ else console.warn(player.error.message);
240
+ return;
241
+ }
242
+ }
243
+
244
+ /** While the picture is drawn and playing, the frame showing is asked for
245
+ * every display frame; lifted or paused, nothing is. */
246
+ _syncPull() {
247
+ const want =
248
+ this._player !== null &&
249
+ !this._lifted &&
250
+ this._player.playing &&
251
+ !this.destroyed;
252
+ if (want && this._pull === null) {
253
+ this._pull = setInterval(() => this._pullFrame(), PULL_MS);
254
+ this._pull.unref?.();
255
+ } else if (!want) {
256
+ this._stopPull();
257
+ }
258
+ }
259
+
260
+ _stopPull() {
261
+ if (this._pull !== null) clearInterval(this._pull);
262
+ this._pull = null;
263
+ }
264
+
265
+ /** The frame showing into the surface, and the picture claimed if it was
266
+ * a new one. */
267
+ _pullFrame() {
268
+ if (this._lifted || this.destroyed) return;
269
+ if (this._copyPlayerFrame()) this.root?.invalidate(false, this, 'content');
270
+ }
271
+
272
+ /** True when a new frame went into the surface. */
273
+ _copyPlayerFrame() {
274
+ const player = this._player;
275
+ if (!player || !(player.width > 0)) return false;
276
+ const surface = this._surfaceFor(player.width, player.height);
277
+ if (!surface) return false;
278
+ const copied = player.copyFrame(surface);
279
+ // another size than the surface: the metadata that says so remakes it
280
+ if (!copied?.written) return false;
281
+ this._playerFrame = true;
282
+ return true;
283
+ }
284
+
285
+ _refuse() {
286
+ if (canPlayMedia(this.app)) return;
287
+ this._refused = true;
288
+ const err = new NoMediaPlaybackError();
289
+ this.error = err;
290
+ queueMicrotask(() => {
291
+ if (this.destroyed || !this._refused) return;
292
+ if (this.props.onError) this.props.onError(err);
293
+ else if (this.app && !warnedNoPlayback.has(this.app)) {
294
+ warnedNoPlayback.add(this.app);
295
+ console.warn(err.message);
296
+ }
297
+ });
298
+ }
299
+
300
+ /** The poster through `<image>`'s own ladder: a file read, bytes decoded
301
+ * now or — WebP, or anything under Bun — later, raw RGBA or an Image as
302
+ * it is. A failure is a content failure: said once, and no poster. */
303
+ _loadPoster(src) {
304
+ if (isDirectImageSource(src)) {
305
+ this._poster = src;
306
+ return;
307
+ }
308
+ let decoded;
309
+ try {
310
+ decoded = isPathImageSource(src)
311
+ ? loadImageFile(toLoadablePath(src))
312
+ : decodeImageSource(src);
313
+ } catch (err) {
314
+ console.error('react-x11: <video poster> did not decode:', err.message);
315
+ return;
316
+ }
317
+ if (typeof decoded?.then !== 'function') {
318
+ this._ownedPoster = decoded;
319
+ this._poster = decoded;
320
+ return;
321
+ }
322
+ const token = ++this._posterToken;
323
+ decoded.then(
324
+ (image) => {
325
+ if (!image || token !== this._posterToken || this.destroyed) return;
326
+ this._ownedPoster = image;
327
+ this._poster = image;
328
+ if (!this._sink) this.invalidateMeasure('content');
329
+ else this.root?.invalidate(false, this, 'content');
330
+ },
331
+ (err) => {
332
+ if (token !== this._posterToken || this.destroyed) return;
333
+ console.error(
334
+ `react-x11: <video poster> ${isPathImageSource(src) ? src : 'bytes'} did not load:`,
335
+ err.message,
336
+ );
337
+ },
338
+ );
339
+ }
340
+
341
+ // --- layout -------------------------------------------------------------------
342
+
343
+ /** The picture's size in source pixels, which are logical pixels — the
344
+ * browser's rule, and `<image>`'s: a 1280x720 stream is 1280 logical px
345
+ * wide at any scale. The stream's size once it is known, then the
346
+ * poster's, then HTML's 300x150. */
347
+ _natural() {
348
+ const sink = this._sink;
349
+ if (sink) return { width: sink.width, height: sink.height };
350
+ const player = this._player;
351
+ if (player?.width > 0 && player?.height > 0) {
352
+ return { width: player.width, height: player.height };
353
+ }
354
+ const poster = this._poster;
355
+ if (poster?.width > 0 && poster?.height > 0) {
356
+ return { width: poster.width, height: poster.height };
357
+ }
358
+ return { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT };
359
+ }
360
+
361
+ /**
362
+ * HTML's video sizing: a style that names one axis gets the other from
363
+ * the picture's ratio — larger than the stream as well as smaller, which
364
+ * is where a video parts from `<image>` — and a style that names neither
365
+ * gets the stream's size, shrunk to fit what is on offer. Whole pixels:
366
+ * a fractional answer is an input yoga's line arithmetic cannot take.
367
+ */
368
+ measureContent(constraints) {
369
+ this._ensure();
370
+ const s = this.scale;
371
+ const n = this._natural();
372
+ const natural = { width: n.width * s, height: n.height * s };
373
+ const { width, height, widthMode, heightMode } = constraints;
374
+ if (widthMode === 'exactly' && heightMode !== 'exactly') {
375
+ return { width, height: Math.round((width * n.height) / n.width) };
376
+ }
377
+ if (heightMode === 'exactly' && widthMode !== 'exactly') {
378
+ return { width: Math.round((height * n.width) / n.height), height };
379
+ }
380
+ const size = intrinsicSize(natural, constraints);
381
+ return { width: Math.round(size.width), height: Math.round(size.height) };
382
+ }
383
+
384
+ /** Where the picture goes in the content box, device pixels, snapped to
385
+ * whole ones so the hole a lifted picture leaves and the layer that
386
+ * fills it meet without a seam. */
387
+ _pictureRect(natural) {
388
+ const s = this.scale;
389
+ return fitRect(
390
+ this.contentBox(),
391
+ natural.width * s,
392
+ natural.height * s,
393
+ objectFitOf(this.style, 'contain'),
394
+ );
395
+ }
396
+
397
+ // --- props --------------------------------------------------------------------
398
+
399
+ applyProps(newProps, oldProps) {
400
+ const before = oldProps ?? this.props;
401
+ const changed =
402
+ newProps.frames !== before.frames ||
403
+ newProps.src !== before.src ||
404
+ newProps.poster !== before.poster;
405
+ if (changed) validateVideoProps(newProps);
406
+ super.applyProps(newProps, oldProps);
407
+ if (!changed) {
408
+ // the same player, told what changed of how it plays
409
+ if (
410
+ this._player &&
411
+ PLAYER_PROPS.some((name) => newProps[name] !== before[name])
412
+ ) {
413
+ const settings = {};
414
+ for (const name of PLAYER_PROPS) {
415
+ if (newProps[name] !== before[name]) settings[name] = newProps[name];
416
+ }
417
+ this._player.set(settings);
418
+ }
419
+ return;
420
+ }
421
+ const was = this._natural();
422
+ this._detach();
423
+ this._dirty = false;
424
+ this._attach();
425
+ this._releaseSurface();
426
+ this.spritesChanged();
427
+ const now = this._natural();
428
+ if (was.width !== now.width || was.height !== now.height) {
429
+ this.invalidateMeasure('content');
430
+ }
431
+ }
432
+
433
+ destroySubtree() {
434
+ this.destroyed = true;
435
+ this._detach();
436
+ this._releaseSurface();
437
+ super.destroySubtree();
438
+ }
439
+
440
+ // --- sprites --------------------------------------------------------------------
441
+
442
+ /**
443
+ * The picture, as one part whose content is the sink: offered once there
444
+ * is a frame to show, and cut to the content box where `objectFit:
445
+ * 'cover'` makes it larger.
446
+ */
447
+ sprites() {
448
+ if (this.hidden) return null;
449
+ const sink = this._sink;
450
+ const player = this._player;
451
+ // a sink with a frame, or a player that knows its size and works
452
+ const contents = sink?.frame
453
+ ? sink
454
+ : player?.width > 0 && !player.error
455
+ ? player
456
+ : null;
457
+ if (contents === null) return null;
458
+ const rect = this._pictureRect(this._natural());
459
+ if (!(rect.width > 0 && rect.height > 0)) return null;
460
+ const part = { key: 'video', rect, contents };
461
+ const box = this.contentBox();
462
+ if (
463
+ rect.x < box.x ||
464
+ rect.y < box.y ||
465
+ rect.x + rect.width > box.x + box.width ||
466
+ rect.y + rect.height > box.y + box.height
467
+ ) {
468
+ part.clip = box;
469
+ }
470
+ return [part];
471
+ }
472
+
473
+ spritesLifted(keys) {
474
+ this._lifted = keys.has('video');
475
+ if (this._player) {
476
+ this._syncPull();
477
+ // given back: the frame showing, for the paint this frame owes it
478
+ if (!this._lifted) this._copyPlayerFrame();
479
+ }
480
+ }
481
+
482
+ // --- the imperative half: what a `ref` can do, as HTMLMediaElement can ----------
483
+
484
+ /** Play `src` — from its start if it has ended. */
485
+ play() {
486
+ this._ensure();
487
+ this._player?.play();
488
+ }
489
+
490
+ pause() {
491
+ this._ensure();
492
+ this._player?.pause();
493
+ }
494
+
495
+ /** To `seconds` into `src`, exactly. */
496
+ seek(seconds) {
497
+ this._ensure();
498
+ this._player?.seek(seconds);
499
+ }
500
+
501
+ /** Seconds into `src`; a sink's newest frame's time, or 0. */
502
+ get currentTime() {
503
+ if (this._player) return this._player.currentTime;
504
+ return this._sink?.frame?.time ?? 0;
505
+ }
506
+
507
+ /** Seconds; `Infinity` for a live stream and for a sink, NaN before it is
508
+ * known. */
509
+ get duration() {
510
+ if (this._player) return this._player.duration;
511
+ return this._sink ? Infinity : NaN;
512
+ }
513
+
514
+ get paused() {
515
+ return this._player ? !this._player.playing : true;
516
+ }
517
+
518
+ // --- paint ----------------------------------------------------------------------
519
+
520
+ paintContent(ctx) {
521
+ this._ensure();
522
+ const sink = this._sink;
523
+ if (sink?.frame) {
524
+ // the layer is showing it, and the bitmap under it is a hole
525
+ if (this._lifted) return;
526
+ if (this._drawFrame(ctx, sink)) return;
527
+ }
528
+ const player = this._player;
529
+ if (player?.width > 0 && !player.error) {
530
+ if (this._lifted) return;
531
+ if (this._playerFrame || this._copyPlayerFrame()) {
532
+ this._drawFitted(ctx, this._surface, {
533
+ width: player.width,
534
+ height: player.height,
535
+ });
536
+ return;
537
+ }
538
+ }
539
+ const poster = this._poster;
540
+ if (poster?.width > 0 && poster?.height > 0) {
541
+ this._drawFitted(ctx, poster, {
542
+ width: poster.width,
543
+ height: poster.height,
544
+ });
545
+ }
546
+ }
547
+
548
+ _drawFitted(ctx, image, natural) {
549
+ const rect = this._pictureRect(natural);
550
+ if (!(rect.width > 0 && rect.height > 0)) return;
551
+ const box = this.contentBox();
552
+ const overflows =
553
+ rect.x < box.x ||
554
+ rect.y < box.y ||
555
+ rect.x + rect.width > box.x + box.width ||
556
+ rect.y + rect.height > box.y + box.height;
557
+ if (overflows) {
558
+ ctx.save();
559
+ ctx.beginPath();
560
+ ctx.rect(box.x, box.y, box.width, box.height);
561
+ ctx.clip();
562
+ }
563
+ try {
564
+ ctx.drawImage(image, rect.x, rect.y, rect.width, rect.height);
565
+ } finally {
566
+ if (overflows) ctx.restore();
567
+ }
568
+ }
569
+
570
+ /** The newest frame into the node's surface, if it is not there yet, and
571
+ * the surface composited. False where there is no surface to be had —
572
+ * the headless mock — and the poster stands in. */
573
+ _drawFrame(ctx, sink) {
574
+ const frame = sink.frame;
575
+ const surface = this._surfaceFor(sink.width, sink.height);
576
+ if (!surface) return false;
577
+ if (this._drawn !== frame.version) {
578
+ try {
579
+ this._upload(surface, sink, frame);
580
+ } catch (err) {
581
+ if (DEV) {
582
+ console.warn(
583
+ `react-x11: <video> could not draw a frame: ${err?.message ?? err}`,
584
+ );
585
+ }
586
+ return false;
587
+ }
588
+ this._drawn = frame.version;
589
+ }
590
+ this._drawFitted(ctx, surface, { width: sink.width, height: sink.height });
591
+ return true;
592
+ }
593
+
594
+ _upload(surface, sink, frame) {
595
+ // natively where the backend converts in its own code (the Cocoa
596
+ // bridge's writeVideoSurface: a copy for BGRA, VideoToolbox for YCbCr,
597
+ // colour-matched to what a layer shows)
598
+ if (this.app?.writeVideoFrame?.(surface, sink, frame)) return;
599
+ const w = sink.width;
600
+ const h = sink.height;
601
+ if (this._rgba?.width !== w || this._rgba?.height !== h) {
602
+ this._rgba = {
603
+ width: w,
604
+ height: h,
605
+ data: new Uint8ClampedArray(w * h * 4),
606
+ };
607
+ }
608
+ frameToRGBA(sink, frame, this._rgba.data);
609
+ surface.render((sctx) => sctx.putImageData(this._rgba, 0, 0));
610
+ }
611
+
612
+ _surfaceFor(width, height) {
613
+ const kept = this._surface;
614
+ if (kept && kept.width === width && kept.height === height) return kept;
615
+ this._releaseSurface();
616
+ this._playerFrame = false;
617
+ const app = this.app;
618
+ if (!app?.display?.Render && typeof app?.createSurface !== 'function') {
619
+ return null;
620
+ }
621
+ try {
622
+ this._surface = new Surface(app, { width, height });
623
+ } catch {
624
+ this._surface = null;
625
+ }
626
+ this._drawn = -1;
627
+ return this._surface;
628
+ }
629
+
630
+ _releaseSurface() {
631
+ const surface = this._surface;
632
+ this._surface = null;
633
+ this._rgba = null;
634
+ this._drawn = -1;
635
+ try {
636
+ surface?.destroy();
637
+ } catch {
638
+ // gone with its connection
639
+ }
640
+ }
641
+ }
package/src/ntk.d.ts CHANGED
@@ -91,6 +91,32 @@ export interface Surface {
91
91
  [Symbol.dispose](): void;
92
92
  }
93
93
  export const Surface: new (app: unknown, options: SurfaceOptions) => Surface;
94
+ /** What `decodeImageBytes` answers: an ntk `Image` of straight RGBA. */
95
+ export interface DecodedImage {
96
+ readonly width: number;
97
+ readonly height: number;
98
+ /** `width × height × 4` bytes, straight (not premultiplied) RGBA. */
99
+ readonly data: Uint8Array;
100
+ }
101
+
102
+ /**
103
+ * Encoded image bytes → an ntk `Image`, through the decoder `<image>` uses:
104
+ * the runtime's own where there is one (`Bun.Image`, off the JavaScript
105
+ * thread, every format it reads), and JavaScript elsewhere — PNG and JPEG
106
+ * synchronously, WebP through a decoder imported the first time a WebP
107
+ * arrives. The format comes from the bytes.
108
+ *
109
+ * An `Image`, or a promise of one wherever the decode is not synchronous;
110
+ * it throws, or rejects, on bytes this runtime cannot decode, saying what to
111
+ * convert them to. The bytes are read after it returns where the answer is
112
+ * a promise, so they must not change until it settles. See
113
+ * [elements.md](../docs/elements.md#formats-and-what-decodes-them) for which
114
+ * runtime decodes what.
115
+ */
116
+ export function decodeImageBytes(
117
+ bytes: Uint8Array,
118
+ ): DecodedImage | Promise<DecodedImage>;
119
+
94
120
  /** `code` values on a failed GL setup — see `<glarea onError>`. */
95
121
  export const GLXError: {
96
122
  NO_EXTENSION: 'GLX_NO_EXTENSION';
package/src/ntk.js CHANGED
@@ -28,6 +28,16 @@
28
28
  // without knowing which backend it was mounted on. This subpath is where a
29
29
  // drawing-adjacent name gets its backend-neutral answer; the X-only names
30
30
  // (`createClient`, `Pixmap`, `Picture`, `XEmbedSocket`) stay X-only.
31
+ //
32
+ // One name is not ntk's at all. `decodeImageBytes` (src/imagedecode.js) is
33
+ // the decoder ladder behind `<image>`'s bytes and files — the runtime's own
34
+ // decoder where there is one, WebP where ntk reads only PNG and JPEG — and
35
+ // an element that draws images of its own (`<Html>`'s `<img>`, a CSS
36
+ // background) decodes through it so that an image shows there exactly when
37
+ // it would show in an `<image>`. It is a name of its own rather than ntk's
38
+ // `decodeImage` answered differently, because that one is synchronous and a
39
+ // caller may read the size off what it returns; this one answers a promise
40
+ // wherever the decode is not.
31
41
  import * as ntk from 'ntk';
32
42
 
33
43
  import { adoptNtk } from './ntkroot.js';
@@ -35,6 +45,7 @@ import { adoptNtk } from './ntkroot.js';
35
45
  export * from 'ntk';
36
46
  export { default } from 'ntk';
37
47
  export { Surface } from './offscreen.js';
48
+ export { decodeImageBytes } from './imagedecode.js';
38
49
 
39
50
  // what src/ntkroot.js would load, already loaded: handed over rather than
40
51
  // imported twice