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.
- package/package.json +1 -1
- package/src/Reconciler.js +5 -0
- package/src/a11y.js +4 -0
- package/src/appcontext.js +16 -1
- package/src/cocoa/app.js +54 -1
- package/src/cocoa/fonts.js +30 -6
- package/src/cocoa/player.js +177 -0
- package/src/cocoa/promotion.js +4 -0
- package/src/cocoa/sprites.js +97 -18
- package/src/cocoa/video.js +122 -0
- package/src/host.js +1 -0
- package/src/index.d.ts +14 -1
- package/src/index.js +7 -0
- package/src/mediaplayback.js +37 -0
- package/src/node.d.ts +17 -2
- package/src/nodes/fit.js +46 -0
- package/src/nodes/image.js +37 -6
- package/src/nodes/kinds.js +1 -0
- package/src/nodes/layouthost.js +1 -0
- package/src/nodes/video.js +641 -0
- package/src/ntk.d.ts +26 -0
- package/src/ntk.js +11 -0
- package/src/styles.js +19 -4
- package/src/types/elements.d.ts +54 -0
- package/src/types/nodes.d.ts +19 -0
- package/src/types/style.d.ts +11 -0
- package/src/types/video.d.ts +123 -0
- package/src/videoframes.js +375 -0
- package/src/videohooks.js +29 -0
|
@@ -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
|