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
package/src/cocoa/sprites.js
CHANGED
|
@@ -17,7 +17,12 @@
|
|
|
17
17
|
// A part's content is a raster the presenter paints by calling the part's
|
|
18
18
|
// own `paint`, at opacity 1 and untransformed, and paints again when its
|
|
19
19
|
// `version` changes; its opacity and its transform are the layer's, and so
|
|
20
|
-
// are its animations, keyframes the bridge hands to Core Animation.
|
|
20
|
+
// are its animations, keyframes the bridge hands to Core Animation. Or it is
|
|
21
|
+
// a **source**, the part's `contents` — a `<video>`'s frames, or its player —
|
|
22
|
+
// which the presenter does not paint at all: the source shows itself on the
|
|
23
|
+
// layer (src/cocoa/video.js, src/cocoa/player.js), between frames and on its
|
|
24
|
+
// own clock, and the layer is exactly the part's rect. A source this bridge
|
|
25
|
+
// cannot show is a part declined, drawn by its element like any other. A
|
|
21
26
|
// transform is CSS's matrix, which the bridge takes from
|
|
22
27
|
// `@windowkit/appkit` 0.19 (`transformForms()`) along with the negative
|
|
23
28
|
// delay a part joined half way through needs, so that is the bridge sprites
|
|
@@ -25,8 +30,11 @@
|
|
|
25
30
|
// element.
|
|
26
31
|
|
|
27
32
|
import { RASTER_PAD, RasterState } from './presenter.js';
|
|
33
|
+
import { CocoaPlayer, PlayerLift } from './player.js';
|
|
34
|
+
import { VideoLift, canLiftVideo } from './video.js';
|
|
28
35
|
import { intersectRects } from '../nodes/rects.js';
|
|
29
36
|
import { DEV } from '../nodes/util.js';
|
|
37
|
+
import { isVideoFrames } from '../videoframes.js';
|
|
30
38
|
|
|
31
39
|
// Past this many device pixels on a side a part is drawn by its element: a
|
|
32
40
|
// layer's contents are a bitmap, and a page-sized one is a page-sized
|
|
@@ -105,12 +113,26 @@ function isAnimation(a) {
|
|
|
105
113
|
return true;
|
|
106
114
|
}
|
|
107
115
|
|
|
108
|
-
/** Is `sprite` a part as `Node.sprites()` documents one?
|
|
116
|
+
/** Is `sprite` a part as `Node.sprites()` documents one? Either it paints
|
|
117
|
+
* or it has a source to show, never both. */
|
|
109
118
|
function isSprite(sprite) {
|
|
110
119
|
if (sprite == null || typeof sprite.key !== 'string') return false;
|
|
111
|
-
if (!isRect(sprite.rect)
|
|
120
|
+
if (!isRect(sprite.rect)) return false;
|
|
121
|
+
if (
|
|
122
|
+
sprite.contents != null
|
|
123
|
+
? sprite.paint !== undefined
|
|
124
|
+
: typeof sprite.paint !== 'function'
|
|
125
|
+
) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
112
128
|
if (sprite.reach !== undefined && !isRect(sprite.reach)) return false;
|
|
113
129
|
if (sprite.clip !== undefined && !isRect(sprite.clip)) return false;
|
|
130
|
+
if (
|
|
131
|
+
sprite.clipRadius !== undefined &&
|
|
132
|
+
!(finite(sprite.clipRadius) && sprite.clipRadius >= 0)
|
|
133
|
+
) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
114
136
|
if (sprite.opacity !== undefined && !finite(sprite.opacity)) return false;
|
|
115
137
|
if (sprite.transform !== undefined && !isMatrix(sprite.transform)) {
|
|
116
138
|
return false;
|
|
@@ -204,8 +226,11 @@ function extentOf(sprite) {
|
|
|
204
226
|
}
|
|
205
227
|
|
|
206
228
|
/** The raster a part's content is painted into: its reach, out to whole
|
|
207
|
-
* pixels and padded for antialiasing, untransformed.
|
|
229
|
+
* pixels and padded for antialiasing, untransformed. A source's layer is
|
|
230
|
+
* the part's rect, which the element has already put on whole pixels so
|
|
231
|
+
* that the hole it leaves and the layer meet. */
|
|
208
232
|
function rasterRectOf(sprite) {
|
|
233
|
+
if (sprite.contents != null) return sprite.rect;
|
|
209
234
|
const reach = sprite.reach ?? sprite.rect;
|
|
210
235
|
const x = Math.floor(reach.x) - RASTER_PAD;
|
|
211
236
|
const y = Math.floor(reach.y) - RASTER_PAD;
|
|
@@ -236,6 +261,8 @@ export class SpriteLayers {
|
|
|
236
261
|
this.supported = Boolean(
|
|
237
262
|
this.native.transformForms?.()?.includes?.('matrix'),
|
|
238
263
|
);
|
|
264
|
+
// and a video's frames on a layer need its video surfaces
|
|
265
|
+
this.videos = canLiftVideo(this.native);
|
|
239
266
|
this.hosts = new Map(); // element -> Map(key -> state)
|
|
240
267
|
this._warned = new WeakSet();
|
|
241
268
|
}
|
|
@@ -304,26 +331,48 @@ export class SpriteLayers {
|
|
|
304
331
|
continue;
|
|
305
332
|
}
|
|
306
333
|
if (now.has(sprite.key)) continue;
|
|
334
|
+
const contents = sprite.contents ?? null;
|
|
335
|
+
// a source this bridge cannot show: the element draws it
|
|
336
|
+
if (contents !== null && !this._canShow(contents)) continue;
|
|
307
337
|
const raster = rasterRectOf(sprite);
|
|
308
|
-
|
|
338
|
+
// a raster's limit; a source's surface is its own size, whatever
|
|
339
|
+
// the layer is scaled to
|
|
340
|
+
if (
|
|
341
|
+
contents === null &&
|
|
342
|
+
(raster.width > MAX_SIDE || raster.height > MAX_SIDE)
|
|
343
|
+
) {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
309
346
|
// what shows of everywhere it can be: inside its own clip, and the
|
|
310
347
|
// clips of its element's ancestors, which cut its layer (`_clear`)
|
|
311
|
-
const cut = {
|
|
348
|
+
const cut = {
|
|
349
|
+
clip: sprite.clip ?? null,
|
|
350
|
+
radius: sprite.clip ? (sprite.clipRadius ?? 0) : 0,
|
|
351
|
+
bounds: null,
|
|
352
|
+
};
|
|
312
353
|
const extent = extentOf(sprite);
|
|
313
354
|
const bounds = cut.clip ? intersectRects(extent, cut.clip) : extent;
|
|
314
355
|
if (!bounds || !this.promotion._clear(host, bounds, cut)) continue;
|
|
315
356
|
const shown = cut.bounds;
|
|
316
357
|
let state = was?.get(sprite.key);
|
|
317
358
|
// a clip that comes or goes is a layer in a box or out of one: lifted
|
|
318
|
-
// again, rather than a running layer moved between parents
|
|
319
|
-
|
|
359
|
+
// again, rather than a running layer moved between parents — and so
|
|
360
|
+
// is a part that shows another source, or stops painting to show one
|
|
361
|
+
if (
|
|
362
|
+
state &&
|
|
363
|
+
(Boolean(state.clip) !== Boolean(cut.clip) ||
|
|
364
|
+
state.contents !== contents)
|
|
365
|
+
) {
|
|
320
366
|
this._drop(state, root);
|
|
321
367
|
state = null;
|
|
322
368
|
}
|
|
323
369
|
const lifting = !state;
|
|
324
|
-
if (lifting)
|
|
370
|
+
if (lifting) {
|
|
371
|
+
state = this._lift(host, sprite.key, cut.clip !== null, contents);
|
|
372
|
+
}
|
|
325
373
|
state.sprite = sprite;
|
|
326
374
|
state.clip = cut.clip;
|
|
375
|
+
state.radius = cut.radius;
|
|
327
376
|
state.order = [...place, AFTER_CHILDREN, index];
|
|
328
377
|
// the bitmap under where it shows repaints without it, from this
|
|
329
378
|
// frame on; where it went since, it is the element's own claims
|
|
@@ -370,7 +419,14 @@ export class SpriteLayers {
|
|
|
370
419
|
}
|
|
371
420
|
}
|
|
372
421
|
|
|
373
|
-
|
|
422
|
+
/** Can this bridge show `contents` on a layer by itself? A player always
|
|
423
|
+
* can: it was made by this bridge, with the layer it plays into. */
|
|
424
|
+
_canShow(contents) {
|
|
425
|
+
if (contents instanceof CocoaPlayer) return !contents.released;
|
|
426
|
+
return isVideoFrames(contents) && this.videos;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
_lift(host, key, clipped, contents = null) {
|
|
374
430
|
const layer = this.native.createLayer();
|
|
375
431
|
// a part cut to a clip is in a box of the clip's size that masks to it,
|
|
376
432
|
// and the box is what stands among the layers above the bitmap
|
|
@@ -389,6 +445,7 @@ export class SpriteLayers {
|
|
|
389
445
|
box,
|
|
390
446
|
boxProps: {},
|
|
391
447
|
clip: null, // device pixels, window coordinates
|
|
448
|
+
radius: 0, // the clip's corners, device pixels
|
|
392
449
|
raster: new RasterState(),
|
|
393
450
|
painted: UNPAINTED,
|
|
394
451
|
rect: null, // the raster's rect, device pixels, window coordinates
|
|
@@ -402,6 +459,14 @@ export class SpriteLayers {
|
|
|
402
459
|
sprite: null,
|
|
403
460
|
extent: null,
|
|
404
461
|
order: null,
|
|
462
|
+
// what shows itself on the layer, for a part with a source
|
|
463
|
+
contents,
|
|
464
|
+
lift:
|
|
465
|
+
contents === null
|
|
466
|
+
? null
|
|
467
|
+
: contents instanceof CocoaPlayer
|
|
468
|
+
? new PlayerLift(this.native, contents, layer)
|
|
469
|
+
: new VideoLift(this.native, contents, layer),
|
|
405
470
|
};
|
|
406
471
|
}
|
|
407
472
|
|
|
@@ -409,6 +474,7 @@ export class SpriteLayers {
|
|
|
409
474
|
* forgotten, and the bitmap under where it could be repaints with the
|
|
410
475
|
* element drawing it again. */
|
|
411
476
|
_drop(state, root) {
|
|
477
|
+
state.lift?.release();
|
|
412
478
|
this.native.removeFromSuperlayer(state.box ?? state.layer);
|
|
413
479
|
state.raster.release(this.native);
|
|
414
480
|
for (const run of state.runs.values()) this._ends().delete(run.caId);
|
|
@@ -427,13 +493,14 @@ export class SpriteLayers {
|
|
|
427
493
|
const phase = `${reach.x - Math.floor(reach.x)},${reach.y - Math.floor(reach.y)}`;
|
|
428
494
|
// painted again for a new version, and for a raster of another size or
|
|
429
495
|
// with the part at another fraction of a pixel; a move by whole pixels
|
|
430
|
-
// is the layer's alone
|
|
496
|
+
// is the layer's alone. A source paints nothing: it shows itself.
|
|
431
497
|
if (
|
|
432
|
-
state.
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
498
|
+
!state.lift &&
|
|
499
|
+
(state.painted === UNPAINTED ||
|
|
500
|
+
sprite.version !== state.painted ||
|
|
501
|
+
state.rect?.width !== rect.width ||
|
|
502
|
+
state.rect?.height !== rect.height ||
|
|
503
|
+
state.phase !== phase)
|
|
437
504
|
) {
|
|
438
505
|
this._paint(state, sprite, rect);
|
|
439
506
|
state.phase = phase;
|
|
@@ -446,14 +513,23 @@ export class SpriteLayers {
|
|
|
446
513
|
const clip = state.clip;
|
|
447
514
|
if (state.box) {
|
|
448
515
|
const frame = [clip.x / s, clip.y / s, clip.width / s, clip.height / s];
|
|
516
|
+
// its corners rounded where the clip's are: a circle's arc, as wide
|
|
517
|
+
// as the box allows
|
|
518
|
+
const cornerRadius =
|
|
519
|
+
Math.min(state.radius, clip.width / 2, clip.height / 2) / s;
|
|
449
520
|
const was = state.boxProps;
|
|
450
|
-
if (
|
|
521
|
+
if (
|
|
522
|
+
!sameMatrix(was.frame, frame) ||
|
|
523
|
+
was.zPosition !== z ||
|
|
524
|
+
was.cornerRadius !== cornerRadius
|
|
525
|
+
) {
|
|
451
526
|
this.native.setLayerProps(state.box, {
|
|
452
527
|
frame,
|
|
453
528
|
masksToBounds: true,
|
|
529
|
+
cornerRadius,
|
|
454
530
|
zPosition: z,
|
|
455
531
|
});
|
|
456
|
-
state.boxProps = { frame, zPosition: z };
|
|
532
|
+
state.boxProps = { frame, zPosition: z, cornerRadius };
|
|
457
533
|
}
|
|
458
534
|
}
|
|
459
535
|
const ox = state.box ? clip.x : 0;
|
|
@@ -490,6 +566,8 @@ export class SpriteLayers {
|
|
|
490
566
|
}
|
|
491
567
|
if (any) this.native.setLayerProps(state.layer, out);
|
|
492
568
|
state.props = next;
|
|
569
|
+
// a source that fills the layer with a layer of its own
|
|
570
|
+
state.lift?.layout?.(next.bounds);
|
|
493
571
|
// after the model went out, in the same transaction
|
|
494
572
|
this._syncAnimations(state, sprite);
|
|
495
573
|
}
|
|
@@ -595,6 +673,7 @@ export class SpriteLayers {
|
|
|
595
673
|
destroy() {
|
|
596
674
|
for (const lifted of this.hosts.values()) {
|
|
597
675
|
for (const state of lifted.values()) {
|
|
676
|
+
state.lift?.release();
|
|
598
677
|
this.native.removeFromSuperlayer(state.box ?? state.layer);
|
|
599
678
|
state.raster.release(this.native);
|
|
600
679
|
for (const run of state.runs.values()) this._ends().delete(run.caId);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// A video's frames on a layer of their own (docs/architecture/video.md §7):
|
|
2
|
+
// what the surface presenter's sprites (src/cocoa/sprites.js) do with a part
|
|
3
|
+
// whose `contents` is a `VideoFrames` sink instead of a `paint`.
|
|
4
|
+
//
|
|
5
|
+
// Every frame goes into a video surface — an IOSurface the render server
|
|
6
|
+
// scans out as it is, NV12 for a YCbCr sink and BGRA for a BGRA one
|
|
7
|
+
// (@windowkit/appkit's createVideoSurface) — and the layer is repointed at
|
|
8
|
+
// it, inside the `push` that delivered it: a layer change of its own, with
|
|
9
|
+
// no react-x11 frame, no paint and nothing for the frame pacer to price. At
|
|
10
|
+
// 1080p that is the copy in (0.07ms for NV12, 0.27ms for an I420 frame
|
|
11
|
+
// interleaved on the way) and a transaction.
|
|
12
|
+
//
|
|
13
|
+
// The surfaces are a ring, because a surface the render server is still
|
|
14
|
+
// reading — the one on screen, and in threaded mode the one a queued frame
|
|
15
|
+
// has not taken off the layer yet — is torn by a write. A frame goes into
|
|
16
|
+
// one `videoSurfaceIsInUse` says nothing reads, a new one is made while the
|
|
17
|
+
// ring is short, and with every one of them busy the frame waits for the
|
|
18
|
+
// next push or a short retry: the newest frame is the frame, and a dropped
|
|
19
|
+
// one is the cheapest thing to lose.
|
|
20
|
+
//
|
|
21
|
+
// The verbs are asked for by name (`canLiftVideo`), and a bridge without
|
|
22
|
+
// them lifts nothing: the part is declined and the element draws its frames,
|
|
23
|
+
// which is what it does on every other backend.
|
|
24
|
+
|
|
25
|
+
// Two would do in pump mode — one on screen, one being written — and a
|
|
26
|
+
// worker's queued flip holds a third.
|
|
27
|
+
const RING_MAX = 4;
|
|
28
|
+
// With the whole ring busy, how soon to try the newest frame again: about a
|
|
29
|
+
// display frame, since that is how often the render server lets one go.
|
|
30
|
+
const RETRY_MS = 8;
|
|
31
|
+
|
|
32
|
+
/** Has `native` the verbs a lifted video needs? */
|
|
33
|
+
export function canLiftVideo(native) {
|
|
34
|
+
return (
|
|
35
|
+
typeof native?.createVideoSurface === 'function' &&
|
|
36
|
+
typeof native.writeVideoSurface === 'function' &&
|
|
37
|
+
typeof native.videoSurfaceIsInUse === 'function' &&
|
|
38
|
+
typeof native.setLayerContentsIOSurface === 'function'
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A sink's frames on `layer`, from the frame it holds now until `release()`.
|
|
44
|
+
* Placing the layer — its bounds, its place among the others, the box that
|
|
45
|
+
* cuts it to a clip — is the presenter's; this only ever sets what it shows.
|
|
46
|
+
*/
|
|
47
|
+
export class VideoLift {
|
|
48
|
+
constructor(native, sink, layer) {
|
|
49
|
+
this.native = native;
|
|
50
|
+
this.sink = sink;
|
|
51
|
+
this.layer = layer;
|
|
52
|
+
this.ring = []; // { handle, id }
|
|
53
|
+
this.current = null;
|
|
54
|
+
this.shown = 0; // the version on the layer
|
|
55
|
+
this.released = false;
|
|
56
|
+
this._retry = null;
|
|
57
|
+
this._unsubscribe = sink._subscribe((frame) => this._show(frame));
|
|
58
|
+
if (sink.frame) this._show(sink.frame);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A surface nothing reads, or a new one while the ring is short. */
|
|
62
|
+
_free() {
|
|
63
|
+
const native = this.native;
|
|
64
|
+
for (const s of this.ring) {
|
|
65
|
+
if (s !== this.current && !native.videoSurfaceIsInUse(s.handle)) {
|
|
66
|
+
return s;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (this.ring.length >= RING_MAX) return null;
|
|
70
|
+
const sink = this.sink;
|
|
71
|
+
const made = native.createVideoSurface(sink.width, sink.height, {
|
|
72
|
+
// a YCbCr frame stays YCbCr, I420 interleaved by the write; the
|
|
73
|
+
// render server converts it, in the colour the sink says it is
|
|
74
|
+
format: sink.format === 'BGRA' ? 'BGRA' : 'NV12',
|
|
75
|
+
colorSpace: sink.colorSpace,
|
|
76
|
+
range: sink.range,
|
|
77
|
+
});
|
|
78
|
+
const s = { handle: made.handle, id: made.iosurfaceId };
|
|
79
|
+
this.ring.push(s);
|
|
80
|
+
return s;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_show(frame) {
|
|
84
|
+
if (this.released || frame === null) return;
|
|
85
|
+
const s = this._free();
|
|
86
|
+
if (!s) {
|
|
87
|
+
this._again();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
this.native.writeVideoSurface(s.handle, this.sink.format, frame.planes, {
|
|
91
|
+
strides: frame.strides,
|
|
92
|
+
});
|
|
93
|
+
// its own transaction, actions off, in pump mode; a command of its own
|
|
94
|
+
// from a worker
|
|
95
|
+
this.native.setLayerContentsIOSurface(this.layer, s.id);
|
|
96
|
+
this.current = s;
|
|
97
|
+
this.shown = frame.version;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_again() {
|
|
101
|
+
if (this._retry) return;
|
|
102
|
+
this._retry = setTimeout(() => {
|
|
103
|
+
this._retry = null;
|
|
104
|
+
const frame = this.sink.frame;
|
|
105
|
+
if (frame && frame.version !== this.shown) this._show(frame);
|
|
106
|
+
}, RETRY_MS);
|
|
107
|
+
this._retry.unref?.();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Off the layer: no more frames, and the surfaces freed — the layer
|
|
111
|
+
* holds the one it shows until the presenter takes it away. */
|
|
112
|
+
release() {
|
|
113
|
+
if (this.released) return;
|
|
114
|
+
this.released = true;
|
|
115
|
+
this._unsubscribe();
|
|
116
|
+
if (this._retry) clearTimeout(this._retry);
|
|
117
|
+
this._retry = null;
|
|
118
|
+
for (const s of this.ring) this.native.releaseVideoSurface?.(s.handle);
|
|
119
|
+
this.ring = [];
|
|
120
|
+
this.current = null;
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/host.js
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export * from './types/tray.js';
|
|
|
32
32
|
export * from './types/permissions.js';
|
|
33
33
|
export * from './types/notifications.js';
|
|
34
34
|
export * from './types/desktopcalendar.js';
|
|
35
|
+
export * from './types/video.js';
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
38
|
* The XID of the X11 window a ref points at, or `null` if there is not one
|
|
@@ -220,9 +221,21 @@ export function useClipboard(): Clipboard;
|
|
|
220
221
|
* above everything the X server draws: nothing can be drawn over one there.
|
|
221
222
|
* Ask it before handing a surface its HUD. A property of the backend and the
|
|
222
223
|
* display, settled before the first render.
|
|
224
|
+
*
|
|
225
|
+
* `'mediaPlayback'` is whether this display plays a file or URL itself — a
|
|
226
|
+
* platform player, AVFoundation on macOS over a bridge with its player verbs
|
|
227
|
+
* — which `<video src>` needs. False on X11 and Wayland for good; ask it
|
|
228
|
+
* before rendering a `<video src>`, which refuses with one `onError` and
|
|
229
|
+
* shows its poster where it is false. `<video frames>` needs no capability.
|
|
230
|
+
* A property of the backend, and it never changes.
|
|
223
231
|
*/
|
|
224
232
|
export type SupportsFeature =
|
|
225
|
-
|
|
233
|
+
| 'transparency'
|
|
234
|
+
| 'shaders'
|
|
235
|
+
| 'nativeControls'
|
|
236
|
+
| 'embedding'
|
|
237
|
+
| 'glOverlay'
|
|
238
|
+
| 'mediaPlayback';
|
|
226
239
|
|
|
227
240
|
/**
|
|
228
241
|
* Can this **display** do something? `'transparency'` is true when the
|
package/src/index.js
CHANGED
|
@@ -75,6 +75,13 @@ export {
|
|
|
75
75
|
export { useDesktopCalendarEvents } from './desktopcalendarhooks.js';
|
|
76
76
|
export { parseUriList } from './transfer.js';
|
|
77
77
|
export { useApp, useClipboard, useSupports } from './appcontext.js';
|
|
78
|
+
export {
|
|
79
|
+
createVideoFrames,
|
|
80
|
+
VIDEO_FORMATS,
|
|
81
|
+
VideoFrames,
|
|
82
|
+
} from './videoframes.js';
|
|
83
|
+
export { useVideoFrames } from './videohooks.js';
|
|
84
|
+
export { NoMediaPlaybackError } from './mediaplayback.js';
|
|
78
85
|
export { BusUnavailableError, closeBus, sessionBus, systemBus } from './bus.js';
|
|
79
86
|
export { announce } from './a11y.js';
|
|
80
87
|
// the standard Undo/Cut/Copy/Paste menu, for an element that edits or
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Whether this display plays a file or URL itself — `<video src>` — and the
|
|
2
|
+
// error a `<video src>` reports where it does not.
|
|
3
|
+
//
|
|
4
|
+
// `<video>` asks before it opens anything, and `useSupports('mediaPlayback')`
|
|
5
|
+
// asks for a component deciding what to render. One function for both, so
|
|
6
|
+
// the two cannot disagree (#531's rule, src/embedding.js's shape). A feature
|
|
7
|
+
// test on the app, not a question about which backend this is: the answer is
|
|
8
|
+
// yes where the app can make a player — the Cocoa app over a bridge with
|
|
9
|
+
// AVFoundation's player verbs — and no on X11 and Wayland for good, where no
|
|
10
|
+
// display-server player exists and the application decodes into `frames`.
|
|
11
|
+
//
|
|
12
|
+
// A leaf module on purpose: it imports nothing of ours, so both
|
|
13
|
+
// `appcontext.js` and the node layer can use it.
|
|
14
|
+
|
|
15
|
+
/** Can `app` play a `<video src>` with a platform player? */
|
|
16
|
+
export function canPlayMedia(app) {
|
|
17
|
+
return typeof app?.createPlayer === 'function';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `<video src>` where this backend has no platform player — the error
|
|
22
|
+
* `onError` is handed, so an application can tell it from a file that would
|
|
23
|
+
* not play.
|
|
24
|
+
*/
|
|
25
|
+
export class NoMediaPlaybackError extends Error {
|
|
26
|
+
constructor() {
|
|
27
|
+
super(
|
|
28
|
+
'react-x11: <video src> needs a platform media player, and this ' +
|
|
29
|
+
'backend has none — so the poster is shown instead. Ask ' +
|
|
30
|
+
"useSupports('mediaPlayback') before rendering one, and give a " +
|
|
31
|
+
'backend without a player frames you decode yourself: <video ' +
|
|
32
|
+
'frames={useVideoFrames(…)}> works everywhere (docs/elements.md#video).',
|
|
33
|
+
);
|
|
34
|
+
this.name = 'NoMediaPlaybackError';
|
|
35
|
+
this.code = 'ENOMEDIAPLAYBACK';
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/node.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
MouseEvent,
|
|
25
25
|
WheelEvent,
|
|
26
26
|
} from './types/events.js';
|
|
27
|
+
import type { VideoFrames } from './types/video.js';
|
|
27
28
|
|
|
28
29
|
/** A gradient, as `createLinearGradient` answers with. */
|
|
29
30
|
export interface CanvasGradientLike {
|
|
@@ -289,9 +290,23 @@ export interface Sprite {
|
|
|
289
290
|
* every ancestor with square corners (a rounded one has to hold all of
|
|
290
291
|
* it). */
|
|
291
292
|
clip?: Rect;
|
|
293
|
+
/** The radius of `clip`'s corners, in device pixels: a circular arc at
|
|
294
|
+
* each of the four, as a card with a `borderRadius` cuts what it holds.
|
|
295
|
+
* 0 when left out. A clipping ancestor with square corners that would
|
|
296
|
+
* cut the clip again keeps the part with its element, since one box
|
|
297
|
+
* cannot take both. */
|
|
298
|
+
clipRadius?: number;
|
|
292
299
|
/** Draws the part where it is in the window, as `paint` would, but at
|
|
293
|
-
* opacity 1 and untransformed. */
|
|
294
|
-
paint(ctx: Context2D): void;
|
|
300
|
+
* opacity 1 and untransformed. A part with `contents` has none. */
|
|
301
|
+
paint?(ctx: Context2D): void;
|
|
302
|
+
/**
|
|
303
|
+
* A source that shows itself on the part's layer instead of a paint — a
|
|
304
|
+
* `VideoFrames` sink, whose every push reaches the layer with no frame of
|
|
305
|
+
* the window's own. `rect` is the picture, on whole pixels; a presenter
|
|
306
|
+
* that cannot show the source declines the part, and the element draws
|
|
307
|
+
* the current frame itself (docs/extending.md).
|
|
308
|
+
*/
|
|
309
|
+
contents?: VideoFrames;
|
|
295
310
|
/** Compared with `===` from frame to frame; a change paints the part
|
|
296
311
|
* again. Left out, it is painted once. */
|
|
297
312
|
version?: unknown;
|
package/src/nodes/fit.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// `objectFit`: where a picture with a size of its own goes in a box that has
|
|
2
|
+
// another — CSS's `object-fit`, for `<image>` and `<video>`, centred the way
|
|
3
|
+
// CSS's default `object-position: 50% 50%` centres it. A leaf: no node, no
|
|
4
|
+
// backend, rects in and a rect out.
|
|
5
|
+
|
|
6
|
+
export const OBJECT_FITS = Object.freeze([
|
|
7
|
+
'fill',
|
|
8
|
+
'contain',
|
|
9
|
+
'cover',
|
|
10
|
+
'none',
|
|
11
|
+
'scale-down',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/** The element's `objectFit`, or its default where the style names none. */
|
|
15
|
+
export function objectFitOf(style, fallback) {
|
|
16
|
+
const fit = style?.objectFit;
|
|
17
|
+
return OBJECT_FITS.includes(fit) ? fit : fallback;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where a `width` x `height` picture goes in `box` under `fit`: `fill`
|
|
22
|
+
* stretches it to the box, `contain` scales it to fit inside with bars,
|
|
23
|
+
* `cover` scales it to fill with the overflow cut (by the caller), `none`
|
|
24
|
+
* leaves it its own size, and `scale-down` is `none` or `contain`, whichever
|
|
25
|
+
* is smaller. Its edges are snapped to whole pixels, so a picture drawn
|
|
26
|
+
* there and a layer placed there cover exactly the same ones — except under
|
|
27
|
+
* `fill`, which is the box itself, as an `<image>` has always been drawn.
|
|
28
|
+
*/
|
|
29
|
+
export function fitRect(box, width, height, fit) {
|
|
30
|
+
if (fit === 'fill' || !(width > 0 && height > 0)) return { ...box };
|
|
31
|
+
let k;
|
|
32
|
+
if (fit === 'none') k = 1;
|
|
33
|
+
else {
|
|
34
|
+
const sx = box.width / width;
|
|
35
|
+
const sy = box.height / height;
|
|
36
|
+
k = fit === 'cover' ? Math.max(sx, sy) : Math.min(sx, sy);
|
|
37
|
+
if (fit === 'scale-down') k = Math.min(k, 1);
|
|
38
|
+
}
|
|
39
|
+
const w = width * k;
|
|
40
|
+
const h = height * k;
|
|
41
|
+
const x0 = Math.round(box.x + (box.width - w) / 2);
|
|
42
|
+
const y0 = Math.round(box.y + (box.height - h) / 2);
|
|
43
|
+
const x1 = Math.round(box.x + (box.width + w) / 2);
|
|
44
|
+
const y1 = Math.round(box.y + (box.height + h) / 2);
|
|
45
|
+
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
|
46
|
+
}
|
package/src/nodes/image.js
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from '../imagesource.js';
|
|
19
19
|
import { loadImageFile } from '../imagedecode.js';
|
|
20
20
|
import { symbolWeight, symbolsFor, warnOnce } from '../symbols.js';
|
|
21
|
+
import { fitRect, objectFitOf } from './fit.js';
|
|
21
22
|
import { intrinsicSize } from './layout.js';
|
|
22
23
|
import { Node } from './node.js';
|
|
23
24
|
import { DEV } from './util.js';
|
|
@@ -317,12 +318,42 @@ export class ImageNode extends Node {
|
|
|
317
318
|
}
|
|
318
319
|
if (!this.image) return;
|
|
319
320
|
const content = this.contentBox();
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
321
|
+
const fit = objectFitOf(this.style, 'fill');
|
|
322
|
+
if (fit === 'fill') {
|
|
323
|
+
ctx.drawImage(
|
|
324
|
+
this.image,
|
|
325
|
+
content.x,
|
|
326
|
+
content.y,
|
|
327
|
+
content.width,
|
|
328
|
+
content.height,
|
|
329
|
+
);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// the source's size in device pixels, as `measureContent` takes it
|
|
333
|
+
const s = this.scale;
|
|
334
|
+
const rect = fitRect(
|
|
335
|
+
content,
|
|
336
|
+
(this.image.width ?? 0) * s,
|
|
337
|
+
(this.image.height ?? 0) * s,
|
|
338
|
+
fit,
|
|
326
339
|
);
|
|
340
|
+
if (!(rect.width > 0 && rect.height > 0)) return;
|
|
341
|
+
const inside =
|
|
342
|
+
rect.x >= content.x &&
|
|
343
|
+
rect.y >= content.y &&
|
|
344
|
+
rect.x + rect.width <= content.x + content.width &&
|
|
345
|
+
rect.y + rect.height <= content.y + content.height;
|
|
346
|
+
if (!inside) {
|
|
347
|
+
// `cover`, and `none` larger than the box: cut to the content box
|
|
348
|
+
ctx.save();
|
|
349
|
+
ctx.beginPath();
|
|
350
|
+
ctx.rect(content.x, content.y, content.width, content.height);
|
|
351
|
+
ctx.clip();
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
ctx.drawImage(this.image, rect.x, rect.y, rect.width, rect.height);
|
|
355
|
+
} finally {
|
|
356
|
+
if (!inside) ctx.restore();
|
|
357
|
+
}
|
|
327
358
|
}
|
|
328
359
|
}
|
package/src/nodes/kinds.js
CHANGED