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/styles.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { cssColorStraight } from 'ntk/color';
|
|
7
7
|
|
|
8
8
|
import { parseBoxShadow, parseLinearGradient } from './decorations.js';
|
|
9
|
+
import { OBJECT_FITS } from './nodes/fit.js';
|
|
9
10
|
import {
|
|
10
11
|
validateGridValue,
|
|
11
12
|
GRID_CONTAINER_PROPS,
|
|
@@ -575,6 +576,9 @@ const STYLE_PROPS = new Set([
|
|
|
575
576
|
...TEXT_PAINT_PROPS,
|
|
576
577
|
'color',
|
|
577
578
|
'borderStyle',
|
|
579
|
+
// Where an `<image>`'s or a `<video>`'s picture goes in its box — CSS's
|
|
580
|
+
// `object-fit`. Paint: the box is laid out the same whichever it is.
|
|
581
|
+
'objectFit',
|
|
578
582
|
'transition',
|
|
579
583
|
// a loop rather than a change with an end — see `animationsOf`
|
|
580
584
|
'animation',
|
|
@@ -938,6 +942,13 @@ function validateValue(key, value, where) {
|
|
|
938
942
|
}
|
|
939
943
|
return;
|
|
940
944
|
}
|
|
945
|
+
if (key === 'objectFit') {
|
|
946
|
+
if (value == null || OBJECT_FITS.includes(value) || isToken(value)) return;
|
|
947
|
+
throw new Error(
|
|
948
|
+
`react-x11: invalid objectFit ${JSON.stringify(value)} in ${where} ` +
|
|
949
|
+
`(expected ${OBJECT_FITS.map((v) => `'${v}'`).join(', ')})`,
|
|
950
|
+
);
|
|
951
|
+
}
|
|
941
952
|
if (key === 'opacity') {
|
|
942
953
|
// unset, the way a conditional writes it: `opacity: dim ? 0.5 : undefined`
|
|
943
954
|
if (value == null) return;
|
|
@@ -1189,6 +1200,8 @@ const NOT_ANIMATABLE = new Set([
|
|
|
1189
1200
|
'cursor',
|
|
1190
1201
|
'pointerEvents',
|
|
1191
1202
|
'borderStyle',
|
|
1203
|
+
// a keyword: there is no halfway between filling a box and covering it
|
|
1204
|
+
'objectFit',
|
|
1192
1205
|
'fontFamily',
|
|
1193
1206
|
'fontWeight',
|
|
1194
1207
|
'fontStyle',
|
|
@@ -2269,11 +2282,13 @@ export function paintPropsChanged(props, oldProps = {}) {
|
|
|
2269
2282
|
for (const key of PAINT_PROPS) {
|
|
2270
2283
|
if (props[key] !== oldProps[key]) return true;
|
|
2271
2284
|
}
|
|
2272
|
-
// `color` and `
|
|
2273
|
-
// that set also decides what a state block is allowed to set,
|
|
2274
|
-
// it would change validation rather than just this comparison
|
|
2285
|
+
// `color`, `borderStyle` and `objectFit` paint but are deliberately not in
|
|
2286
|
+
// PAINT_PROPS: that set also decides what a state block is allowed to set,
|
|
2287
|
+
// and widening it would change validation rather than just this comparison
|
|
2275
2288
|
return (
|
|
2276
|
-
props.color !== oldProps.color ||
|
|
2289
|
+
props.color !== oldProps.color ||
|
|
2290
|
+
props.borderStyle !== oldProps.borderStyle ||
|
|
2291
|
+
props.objectFit !== oldProps.objectFit
|
|
2277
2292
|
);
|
|
2278
2293
|
}
|
|
2279
2294
|
|
package/src/types/elements.d.ts
CHANGED
|
@@ -11,8 +11,10 @@ import type {
|
|
|
11
11
|
NtkWindow,
|
|
12
12
|
ScrollableNode,
|
|
13
13
|
TextInputNode,
|
|
14
|
+
VideoNode,
|
|
14
15
|
} from './nodes.js';
|
|
15
16
|
import type { AnchorOptions, ScreenAnchorRect } from './components.js';
|
|
17
|
+
import type { VideoFrames, VideoMetadataEvent } from './video.js';
|
|
16
18
|
import type {
|
|
17
19
|
ChangeEvent,
|
|
18
20
|
SelectionChangeEvent,
|
|
@@ -904,6 +906,57 @@ export interface ImageProps extends DrawnProps<DrawnNode> {
|
|
|
904
906
|
// style size, the source's own size (stated, for the server-side sources)
|
|
905
907
|
// is the natural one, kept to its aspect ratio.
|
|
906
908
|
|
|
909
|
+
/**
|
|
910
|
+
* `<video>`: frames the application decodes (`frames`), or a file or URL the
|
|
911
|
+
* platform plays (`src`, where `useSupports('mediaPlayback')` is true),
|
|
912
|
+
* fitted into the box by `objectFit` — `'contain'` unless the style says
|
|
913
|
+
* otherwise, with `backgroundColor` in the bars. Laid out at the stream's
|
|
914
|
+
* size in logical pixels, kept to its ratio when the style names one axis;
|
|
915
|
+
* before that is known, the poster's, and then HTML's 300x150.
|
|
916
|
+
*
|
|
917
|
+
* On macOS the picture goes on a layer of its own while nothing is painted
|
|
918
|
+
* over it, and every `push` reaches the screen with no frame of the window's
|
|
919
|
+
* own; elsewhere, and whenever something overlaps it, it is drawn in paint
|
|
920
|
+
* order like an `<image>` whose pixels changed. `borderRadius` on an
|
|
921
|
+
* ancestor that clips it costs the layer, not the picture.
|
|
922
|
+
*/
|
|
923
|
+
export interface VideoProps extends DrawnProps<VideoNode> {
|
|
924
|
+
/** A sink the application pushes decoded frames into
|
|
925
|
+
* (`useVideoFrames`). Works on every backend. */
|
|
926
|
+
frames?: VideoFrames;
|
|
927
|
+
/**
|
|
928
|
+
* A path or URL the platform plays itself. Where there is no player the
|
|
929
|
+
* element reports one `NoMediaPlaybackError` through `onError`, on a
|
|
930
|
+
* microtask, and shows its poster. Exclusive with `frames`: passing both
|
|
931
|
+
* throws.
|
|
932
|
+
*/
|
|
933
|
+
src?: string;
|
|
934
|
+
/** Shown until there is a frame, and whenever there is none — an
|
|
935
|
+
* `<image src>` value. */
|
|
936
|
+
poster?: ImageSource;
|
|
937
|
+
/** HTML's names and defaults: nothing plays until asked. For `src`; a
|
|
938
|
+
* sink plays what it is pushed. */
|
|
939
|
+
autoPlay?: boolean;
|
|
940
|
+
loop?: boolean;
|
|
941
|
+
muted?: boolean;
|
|
942
|
+
/** 0 to 1. */
|
|
943
|
+
volume?: number;
|
|
944
|
+
playbackRate?: number;
|
|
945
|
+
/** Controlled when present; the element plays or pauses to match. */
|
|
946
|
+
paused?: boolean;
|
|
947
|
+
/** The stream's size is known — for a sink, once it is mounted. */
|
|
948
|
+
onLoadedMetadata?: (ev: VideoMetadataEvent) => void;
|
|
949
|
+
onPlay?: () => void;
|
|
950
|
+
onPause?: () => void;
|
|
951
|
+
onEnded?: () => void;
|
|
952
|
+
/** A few times a second while playing, as HTML's is, and after a seek. */
|
|
953
|
+
onTimeUpdate?: (ev: { currentTime: number; node: VideoNode }) => void;
|
|
954
|
+
/** The source failed, or this backend has no player for `src`
|
|
955
|
+
* (`NoMediaPlaybackError`). Without a handler the failure is a console
|
|
956
|
+
* warning. */
|
|
957
|
+
onError?: (err: Error) => void;
|
|
958
|
+
}
|
|
959
|
+
|
|
907
960
|
/** What `onDraw` is told about the node it is painting. */
|
|
908
961
|
export interface DrawInfo {
|
|
909
962
|
/**
|
|
@@ -1075,6 +1128,7 @@ export interface ReactX11Elements {
|
|
|
1075
1128
|
textinput: TextInputProps;
|
|
1076
1129
|
textarea: TextAreaProps;
|
|
1077
1130
|
image: ImageProps;
|
|
1131
|
+
video: VideoProps;
|
|
1078
1132
|
canvas: CanvasProps;
|
|
1079
1133
|
svg: SvgProps;
|
|
1080
1134
|
glarea: GlAreaProps;
|
package/src/types/nodes.d.ts
CHANGED
|
@@ -157,6 +157,25 @@ export interface ScrollableNode extends DrawnNode {
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
/** `<textinput>` / `<textarea>`. */
|
|
160
|
+
/**
|
|
161
|
+
* A `<video>`, as its `ref` sees it: HTMLMediaElement's imperative half.
|
|
162
|
+
* `play`, `pause` and `seek` act on `src`'s player and do nothing for a sink,
|
|
163
|
+
* which plays what it is pushed.
|
|
164
|
+
*/
|
|
165
|
+
export interface VideoNode extends DrawnNode {
|
|
166
|
+
/** Play — from the start if the item has ended. */
|
|
167
|
+
play(): void;
|
|
168
|
+
pause(): void;
|
|
169
|
+
/** To `seconds` in, exactly. */
|
|
170
|
+
seek(seconds: number): void;
|
|
171
|
+
/** Seconds in; for a sink, its newest frame's `time`, or 0. */
|
|
172
|
+
readonly currentTime: number;
|
|
173
|
+
/** Seconds; `Infinity` for a live stream and for a sink, NaN before it is
|
|
174
|
+
* known. */
|
|
175
|
+
readonly duration: number;
|
|
176
|
+
readonly paused: boolean;
|
|
177
|
+
}
|
|
178
|
+
|
|
160
179
|
export interface TextInputNode extends DrawnNode {
|
|
161
180
|
/**
|
|
162
181
|
* The control's current text. Inside an `onChange` handler this is the
|
package/src/types/style.d.ts
CHANGED
|
@@ -72,6 +72,15 @@ export type JustifyItems = 'flex-start' | 'center' | 'flex-end' | 'stretch';
|
|
|
72
72
|
export type JustifySelf = 'auto' | JustifyItems;
|
|
73
73
|
export type Overflow = 'visible' | 'hidden' | 'scroll';
|
|
74
74
|
export type BorderStyle = 'solid' | 'dashed';
|
|
75
|
+
/**
|
|
76
|
+
* Where an `<image>`'s or a `<video>`'s picture goes in its content box —
|
|
77
|
+
* CSS's `object-fit`, centred. `'fill'` stretches it to the box (`<image>`'s
|
|
78
|
+
* default); `'contain'` fits it inside, with bars of the background
|
|
79
|
+
* (`<video>`'s); `'cover'` fills the box and cuts what overflows; `'none'`
|
|
80
|
+
* keeps its own size; `'scale-down'` is the smaller of `'none'` and
|
|
81
|
+
* `'contain'`.
|
|
82
|
+
*/
|
|
83
|
+
export type ObjectFit = 'fill' | 'contain' | 'cover' | 'none' | 'scale-down';
|
|
75
84
|
/**
|
|
76
85
|
* Whether the pointer can land on a node. `'none'`: not on it nor anything
|
|
77
86
|
* inside it. `'box-none'` (React Native's): not on the node itself, but on
|
|
@@ -450,6 +459,8 @@ export type HitSlop =
|
|
|
450
459
|
/** Every property a style may set, before the block forms. */
|
|
451
460
|
export interface StyleProperties extends LayoutStyle, PaintStyle, TextStyle {
|
|
452
461
|
borderStyle?: BorderStyle;
|
|
462
|
+
/** `<image>` and `<video>`: where the picture goes in the box. */
|
|
463
|
+
objectFit?: ObjectFit;
|
|
453
464
|
cursor?: Cursor;
|
|
454
465
|
pointerEvents?: PointerEvents;
|
|
455
466
|
/**
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Video: frames an application decodes into a sink, and the platform's own
|
|
3
|
+
* player behind `<video src>` (docs/elements.md#video).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { NtkApp } from './nodes.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How a frame's bytes are laid out. `'NV12'`: a Y plane, then one plane of
|
|
10
|
+
* Cb/Cr pairs at half the size each way — what a hardware decoder emits.
|
|
11
|
+
* `'I420'`: Y, Cb, Cr, three planes, the chroma at half size — `yuv420p`,
|
|
12
|
+
* most software decoders' default. `'BGRA'`: four bytes a pixel, blue first;
|
|
13
|
+
* the fourth byte is ignored, since a frame is opaque.
|
|
14
|
+
*/
|
|
15
|
+
export type VideoFormat = 'NV12' | 'I420' | 'BGRA';
|
|
16
|
+
|
|
17
|
+
/** The formats, in the order they are listed in. */
|
|
18
|
+
export const VIDEO_FORMATS: readonly VideoFormat[];
|
|
19
|
+
|
|
20
|
+
/** What a YCbCr frame's numbers mean — the matrix and the primaries. SDR
|
|
21
|
+
* only. */
|
|
22
|
+
export type VideoColorSpace = 'bt709' | 'bt601' | 'bt2020';
|
|
23
|
+
|
|
24
|
+
/** `'video'` puts black at 16 and white at 235; `'full'` at 0 and 255. */
|
|
25
|
+
export type VideoRange = 'video' | 'full';
|
|
26
|
+
|
|
27
|
+
export interface VideoFramesOptions {
|
|
28
|
+
/** The frame size in whole pixels — source pixels, which `<video>` lays
|
|
29
|
+
* out as logical pixels, as `<image>` does. */
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
/** `'BGRA'` when left out. A decoder that can choose asks
|
|
33
|
+
* `preferredFormats`. */
|
|
34
|
+
format?: VideoFormat;
|
|
35
|
+
/** `'bt709'` when left out. */
|
|
36
|
+
colorSpace?: VideoColorSpace;
|
|
37
|
+
/** `'video'` when left out. */
|
|
38
|
+
range?: VideoRange;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One pushed frame, as a sink holds it. */
|
|
42
|
+
export interface VideoFrame {
|
|
43
|
+
/** One view per plane, over the bytes `push` was handed — not copied. */
|
|
44
|
+
readonly planes: readonly Uint8Array[];
|
|
45
|
+
/** Each plane's bytes per row. */
|
|
46
|
+
readonly strides: readonly number[];
|
|
47
|
+
/** The presentation time `push` was given, in seconds, or null. */
|
|
48
|
+
readonly time: number | null;
|
|
49
|
+
/** Which push this was, counting from 1. */
|
|
50
|
+
readonly version: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface VideoPushOptions {
|
|
54
|
+
/** Each plane's bytes per row, when a decoder pads them; packed when left
|
|
55
|
+
* out. */
|
|
56
|
+
strides?: number[];
|
|
57
|
+
/** The frame's presentation time in seconds, kept as `frame.time`. */
|
|
58
|
+
time?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Where decoded frames go to be shown, and what `<video frames>` takes. The
|
|
63
|
+
* newest frame is the frame: a push replaces the last, shown or not.
|
|
64
|
+
*/
|
|
65
|
+
export class VideoFrames {
|
|
66
|
+
readonly width: number;
|
|
67
|
+
readonly height: number;
|
|
68
|
+
readonly format: VideoFormat;
|
|
69
|
+
readonly colorSpace: VideoColorSpace;
|
|
70
|
+
readonly range: VideoRange;
|
|
71
|
+
/** This display's formats, cheapest first: what a decoder that can choose
|
|
72
|
+
* should emit (`ffmpeg -pix_fmt nv12`). NV12 leads where a frame goes on
|
|
73
|
+
* a layer as it is — macOS — and BGRA wherever a 2D context draws it. */
|
|
74
|
+
readonly preferredFormats: readonly VideoFormat[];
|
|
75
|
+
/** Frames pushed so far. */
|
|
76
|
+
readonly version: number;
|
|
77
|
+
/** The newest frame, or null before the first push and after `close()`. */
|
|
78
|
+
readonly frame: VideoFrame | null;
|
|
79
|
+
readonly closed: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* One decoded frame: a Buffer per plane (`[y, uv]` for NV12, `[y, cb, cr]`
|
|
82
|
+
* for I420, `[bgra]`), or one Buffer holding the planes back to back, the
|
|
83
|
+
* way an `ffmpeg -f rawvideo` pipe delivers them. The planes are read
|
|
84
|
+
* until the next push, not copied at the call. Throws a `TypeError` for
|
|
85
|
+
* planes too small for the frame; does nothing after `close()`.
|
|
86
|
+
*/
|
|
87
|
+
push(
|
|
88
|
+
planes: ArrayBufferView | readonly ArrayBufferView[],
|
|
89
|
+
options?: VideoPushOptions,
|
|
90
|
+
): void;
|
|
91
|
+
/** Drop the frame and take no more: what shows the sink shows its poster
|
|
92
|
+
* or its background from the next frame on. */
|
|
93
|
+
close(): void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A sink for frames of one size and format, on `app`. */
|
|
97
|
+
export function createVideoFrames(
|
|
98
|
+
app: NtkApp,
|
|
99
|
+
options: VideoFramesOptions,
|
|
100
|
+
): VideoFrames;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A sink for a component: the same object across renders, a new one when the
|
|
104
|
+
* size, the format or the colour changes. Nothing to close on unmount.
|
|
105
|
+
*/
|
|
106
|
+
export function useVideoFrames(options: VideoFramesOptions): VideoFrames;
|
|
107
|
+
|
|
108
|
+
/** `<video src>` on a backend with no platform player — what `onError` is
|
|
109
|
+
* handed there. `useSupports('mediaPlayback')` asks first. */
|
|
110
|
+
export class NoMediaPlaybackError extends Error {
|
|
111
|
+
readonly name: 'NoMediaPlaybackError';
|
|
112
|
+
readonly code: 'ENOMEDIAPLAYBACK';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `onLoadedMetadata`: the stream's size, and how long it is. */
|
|
116
|
+
export interface VideoMetadataEvent {
|
|
117
|
+
/** Source pixels. */
|
|
118
|
+
width: number;
|
|
119
|
+
height: number;
|
|
120
|
+
/** Seconds; `Infinity` for a live source — a sink is one. */
|
|
121
|
+
duration: number;
|
|
122
|
+
node: unknown;
|
|
123
|
+
}
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// `VideoFrames`: a sink an application pushes decoded video frames into,
|
|
2
|
+
// and a `<video frames>` shows (docs/architecture/video.md §6.3).
|
|
3
|
+
//
|
|
4
|
+
// react-x11 decodes nothing. Where the bytes come from — an `ffmpeg` child
|
|
5
|
+
// writing rawvideo to a pipe, a WASM decoder, an addon over libmpv — is the
|
|
6
|
+
// application's business, and every one of them ends in `push`. What this
|
|
7
|
+
// module owns is the contract between that decoder and whatever shows the
|
|
8
|
+
// frame, which is one of two things per element and per frame:
|
|
9
|
+
//
|
|
10
|
+
// - **lifted**, on a backend that can put a frame on a layer of its own
|
|
11
|
+
// (Cocoa, over `@windowkit/appkit`'s video surfaces): `push` writes the
|
|
12
|
+
// frame into a surface the render server scans out and repoints the layer,
|
|
13
|
+
// right there, and react-x11's frame clock is not involved at all
|
|
14
|
+
// (src/cocoa/video.js);
|
|
15
|
+
// - **drawn**, everywhere else and whenever something is painted over the
|
|
16
|
+
// video: `push` claims the element's picture, and the next frame copies
|
|
17
|
+
// the newest frame into a surface of the element's own and composites it
|
|
18
|
+
// in paint order (src/nodes/video.js).
|
|
19
|
+
//
|
|
20
|
+
// Three rules, each of which is a cost somewhere:
|
|
21
|
+
//
|
|
22
|
+
// - **The newest frame is the frame.** A push while the last one is not yet
|
|
23
|
+
// shown replaces it; nothing queues. A sink mirrors a decoder's output —
|
|
24
|
+
// the decoder's own clock is the pacing, as a `<canvas>` animation's is.
|
|
25
|
+
// - **A push hands the planes over until the next push.** The drawn path
|
|
26
|
+
// reads them at the next frame, not at the call, so a frame pushed faster
|
|
27
|
+
// than the display shows is never converted at all. A decoder that writes
|
|
28
|
+
// every frame into one buffer pushes that buffer again after writing it,
|
|
29
|
+
// which is exactly when the old contents stop mattering.
|
|
30
|
+
// - **Every format is accepted everywhere; the cheap one differs.** NV12 is
|
|
31
|
+
// what a hardware decoder emits and what a layer shows as it is; BGRA is
|
|
32
|
+
// what a 2D context draws. `preferredFormats` is this display's order, for
|
|
33
|
+
// a decoder that can choose (`ffmpeg -pix_fmt`); one that cannot still
|
|
34
|
+
// works, with the conversion charged where it happens.
|
|
35
|
+
//
|
|
36
|
+
// Pure: no node, no backend. The app is asked for its formats
|
|
37
|
+
// (`app.videoFormats()`, the Cocoa app's answer from the bridge) and nothing
|
|
38
|
+
// else.
|
|
39
|
+
|
|
40
|
+
export const VIDEO_FORMATS = Object.freeze(['NV12', 'I420', 'BGRA']);
|
|
41
|
+
const COLOR_SPACES = ['bt709', 'bt601', 'bt2020'];
|
|
42
|
+
const RANGES = ['video', 'full'];
|
|
43
|
+
// The largest side a frame may have: a surface's limit on every backend, and
|
|
44
|
+
// a number past which a size is a bug rather than a video.
|
|
45
|
+
const MAX_SIDE = 16384;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The planes of a `format` frame at `width` x `height`: how many bytes of
|
|
49
|
+
* picture a row of each holds, and how many rows. A 4:2:0 chroma plane is
|
|
50
|
+
* half the size, rounded up.
|
|
51
|
+
*/
|
|
52
|
+
export function planeLayout(format, width, height) {
|
|
53
|
+
const cw = Math.ceil(width / 2);
|
|
54
|
+
const ch = Math.ceil(height / 2);
|
|
55
|
+
switch (format) {
|
|
56
|
+
case 'NV12':
|
|
57
|
+
return [
|
|
58
|
+
{ rowBytes: width, rows: height },
|
|
59
|
+
{ rowBytes: cw * 2, rows: ch },
|
|
60
|
+
];
|
|
61
|
+
case 'I420':
|
|
62
|
+
return [
|
|
63
|
+
{ rowBytes: width, rows: height },
|
|
64
|
+
{ rowBytes: cw, rows: ch },
|
|
65
|
+
{ rowBytes: cw, rows: ch },
|
|
66
|
+
];
|
|
67
|
+
case 'BGRA':
|
|
68
|
+
return [{ rowBytes: width * 4, rows: height }];
|
|
69
|
+
}
|
|
70
|
+
throw new TypeError(`react-x11: unknown video format ${format}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const isBytes = (v) => ArrayBuffer.isView(v) && !(v instanceof DataView);
|
|
74
|
+
|
|
75
|
+
const asBytes = (v) =>
|
|
76
|
+
v instanceof Uint8Array || v instanceof Uint8ClampedArray
|
|
77
|
+
? v
|
|
78
|
+
: new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `push`'s argument as planes: one `Uint8Array` view per plane (no copy)
|
|
82
|
+
* and each one's stride, checked to hold every row it claims. `planes` is
|
|
83
|
+
* an array of them, or one buffer holding them back to back — what an
|
|
84
|
+
* `ffmpeg -f rawvideo` pipe delivers per frame.
|
|
85
|
+
*/
|
|
86
|
+
export function readPlanes(sink, planes, strides) {
|
|
87
|
+
const { format, width, height } = sink;
|
|
88
|
+
const layout = planeLayout(format, width, height);
|
|
89
|
+
if (strides !== undefined) {
|
|
90
|
+
if (
|
|
91
|
+
!Array.isArray(strides) ||
|
|
92
|
+
strides.length !== layout.length ||
|
|
93
|
+
!strides.every((s, i) => Number.isInteger(s) && s >= layout[i].rowBytes)
|
|
94
|
+
) {
|
|
95
|
+
throw new TypeError(
|
|
96
|
+
`react-x11: VideoFrames.push: strides must be ${layout.length} ` +
|
|
97
|
+
`whole numbers of bytes, at least ` +
|
|
98
|
+
`[${layout.map((p) => p.rowBytes).join(', ')}] for a ` +
|
|
99
|
+
`${width}x${height} ${format} frame`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const pitch = layout.map((p, i) => strides?.[i] ?? p.rowBytes);
|
|
104
|
+
const sizes = layout.map((p, i) => pitch[i] * (p.rows - 1) + p.rowBytes);
|
|
105
|
+
let views;
|
|
106
|
+
if (isBytes(planes)) {
|
|
107
|
+
// one buffer: the planes back to back, each a whole number of strides
|
|
108
|
+
// except the last row of the last
|
|
109
|
+
const all = asBytes(planes);
|
|
110
|
+
views = [];
|
|
111
|
+
let at = 0;
|
|
112
|
+
for (let i = 0; i < layout.length; i++) {
|
|
113
|
+
const length =
|
|
114
|
+
i === layout.length - 1 ? sizes[i] : pitch[i] * layout[i].rows;
|
|
115
|
+
views.push(all.subarray(at, at + length));
|
|
116
|
+
at += length;
|
|
117
|
+
}
|
|
118
|
+
if (at > all.length) {
|
|
119
|
+
throw new TypeError(
|
|
120
|
+
`react-x11: VideoFrames.push: a ${width}x${height} ${format} frame ` +
|
|
121
|
+
`in one buffer needs ${at} bytes, and this one has ${all.length}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
} else if (Array.isArray(planes) && planes.length === layout.length) {
|
|
125
|
+
views = planes.map((p, i) => {
|
|
126
|
+
if (!isBytes(p)) {
|
|
127
|
+
throw new TypeError(
|
|
128
|
+
`react-x11: VideoFrames.push: plane ${i} is not a Buffer or a typed array`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return asBytes(p);
|
|
132
|
+
});
|
|
133
|
+
} else {
|
|
134
|
+
throw new TypeError(
|
|
135
|
+
`react-x11: VideoFrames.push: a ${format} frame is ` +
|
|
136
|
+
(layout.length === 1
|
|
137
|
+
? 'one buffer'
|
|
138
|
+
: `an array of ${layout.length} planes (${format === 'NV12' ? 'Y, CbCr' : 'Y, Cb, Cr'}), or one buffer holding them back to back`),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
views.forEach((view, i) => {
|
|
142
|
+
if (view.length < sizes[i]) {
|
|
143
|
+
throw new TypeError(
|
|
144
|
+
`react-x11: VideoFrames.push: plane ${i} of a ${width}x${height} ` +
|
|
145
|
+
`${format} frame needs ${sizes[i]} bytes, and has ${view.length}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
return { planes: views, strides: pitch };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Where decoded video frames go to be shown — `createVideoFrames(app, …)`,
|
|
154
|
+
* or `useVideoFrames(…)` in a component — and what `<video frames>` takes.
|
|
155
|
+
*
|
|
156
|
+
* A sink owns nothing native. What shows it — a `<video>`'s surface, a
|
|
157
|
+
* layer's ring of video surfaces — owns that, and lets it go when it stops
|
|
158
|
+
* showing it, so a sink that is dropped needs no call.
|
|
159
|
+
*/
|
|
160
|
+
export class VideoFrames {
|
|
161
|
+
constructor(app, options = {}) {
|
|
162
|
+
const {
|
|
163
|
+
width,
|
|
164
|
+
height,
|
|
165
|
+
format = 'BGRA',
|
|
166
|
+
colorSpace = 'bt709',
|
|
167
|
+
range = 'video',
|
|
168
|
+
} = options;
|
|
169
|
+
if (
|
|
170
|
+
!Number.isInteger(width) ||
|
|
171
|
+
!Number.isInteger(height) ||
|
|
172
|
+
width < 1 ||
|
|
173
|
+
height < 1 ||
|
|
174
|
+
width > MAX_SIDE ||
|
|
175
|
+
height > MAX_SIDE
|
|
176
|
+
) {
|
|
177
|
+
throw new TypeError(
|
|
178
|
+
'react-x11: createVideoFrames: width and height are the frame size ' +
|
|
179
|
+
`in whole pixels, 1 to ${MAX_SIDE} (got ${width}x${height})`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (!VIDEO_FORMATS.includes(format)) {
|
|
183
|
+
throw new TypeError(
|
|
184
|
+
`react-x11: createVideoFrames: format ${JSON.stringify(format)} — ` +
|
|
185
|
+
`expected one of ${VIDEO_FORMATS.join(', ')}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (!COLOR_SPACES.includes(colorSpace)) {
|
|
189
|
+
throw new TypeError(
|
|
190
|
+
`react-x11: createVideoFrames: colorSpace ${JSON.stringify(colorSpace)} ` +
|
|
191
|
+
`— expected one of ${COLOR_SPACES.join(', ')}`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
if (!RANGES.includes(range)) {
|
|
195
|
+
throw new TypeError(
|
|
196
|
+
`react-x11: createVideoFrames: range ${JSON.stringify(range)} — ` +
|
|
197
|
+
"expected 'video' or 'full'",
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
this.app = app;
|
|
201
|
+
this.width = width;
|
|
202
|
+
this.height = height;
|
|
203
|
+
this.format = format;
|
|
204
|
+
this.colorSpace = colorSpace;
|
|
205
|
+
this.range = range;
|
|
206
|
+
/** frames pushed; what a presentation compares to know it is behind */
|
|
207
|
+
this.version = 0;
|
|
208
|
+
this.closed = false;
|
|
209
|
+
this._frame = null;
|
|
210
|
+
this._listeners = new Set();
|
|
211
|
+
this._preferred = null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* This display's formats, cheapest first: what a decoder that can choose
|
|
216
|
+
* should emit. NV12 first where a frame can go on a layer as it is
|
|
217
|
+
* (Cocoa); BGRA first wherever a 2D context draws it.
|
|
218
|
+
*/
|
|
219
|
+
get preferredFormats() {
|
|
220
|
+
if (this._preferred === null) {
|
|
221
|
+
const asked = this.app?.videoFormats?.();
|
|
222
|
+
this._preferred = Object.freeze(
|
|
223
|
+
Array.isArray(asked) && asked.length !== 0
|
|
224
|
+
? asked.filter((f) => VIDEO_FORMATS.includes(f))
|
|
225
|
+
: ['BGRA'],
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return this._preferred;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The newest frame — `{ planes, strides, time, version }` — or null. */
|
|
232
|
+
get frame() {
|
|
233
|
+
return this._frame;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* One decoded frame: a Buffer per plane (`[y, uv]` for NV12, `[y, u, v]`
|
|
238
|
+
* for I420, `[bgra]`), or one Buffer holding the planes back to back.
|
|
239
|
+
* `strides` are the planes' bytes per row when a decoder pads them;
|
|
240
|
+
* `time` is the frame's presentation time in seconds, kept as `frame.time`
|
|
241
|
+
* for whoever wants it. Replaces the frame before it, shown or not.
|
|
242
|
+
* Ignored once `close()` has run, so a decoder racing an unmount does not
|
|
243
|
+
* have to know it lost.
|
|
244
|
+
*/
|
|
245
|
+
push(planes, { strides, time } = {}) {
|
|
246
|
+
if (this.closed) return;
|
|
247
|
+
const frame = readPlanes(this, planes, strides);
|
|
248
|
+
this.version += 1;
|
|
249
|
+
frame.time = typeof time === 'number' ? time : null;
|
|
250
|
+
frame.version = this.version;
|
|
251
|
+
this._frame = frame;
|
|
252
|
+
for (const fn of [...this._listeners]) fn(frame);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Drop the frame and take no more: what shows the sink shows its poster
|
|
256
|
+
* or its background from the next frame on. */
|
|
257
|
+
close() {
|
|
258
|
+
if (this.closed) return;
|
|
259
|
+
this.closed = true;
|
|
260
|
+
this._frame = null;
|
|
261
|
+
this.version += 1;
|
|
262
|
+
for (const fn of [...this._listeners]) fn(null);
|
|
263
|
+
this._listeners.clear();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** `fn(frame)` on every push, and `fn(null)` on `close()`; answers the
|
|
267
|
+
* unsubscribe. For what shows a sink, not for applications. */
|
|
268
|
+
_subscribe(fn) {
|
|
269
|
+
if (this.closed) return () => {};
|
|
270
|
+
this._listeners.add(fn);
|
|
271
|
+
return () => this._listeners.delete(fn);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A sink for frames of one size and format, on `app`. */
|
|
276
|
+
export function createVideoFrames(app, options) {
|
|
277
|
+
return new VideoFrames(app, options);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export const isVideoFrames = (v) => v instanceof VideoFrames;
|
|
281
|
+
|
|
282
|
+
// --- converting a frame where nothing native will ----------------------------
|
|
283
|
+
//
|
|
284
|
+
// The drawn path on a backend with no native write (X11, win32, Wayland, a
|
|
285
|
+
// Cocoa bridge older than its video surfaces): the frame converted here to
|
|
286
|
+
// the straight RGBA `putImageData` takes. Not free — two million pixels a
|
|
287
|
+
// frame at 1080p — which is why `preferredFormats` says BGRA first there.
|
|
288
|
+
// The matrix and the range are the frame's; no transfer function is
|
|
289
|
+
// applied, because none of those backends colour-manages anything else
|
|
290
|
+
// either, and a video brighter or darker than every other thing on the
|
|
291
|
+
// screen beside it is worse than one that is wrong the same way they are.
|
|
292
|
+
|
|
293
|
+
const MATRIX = {
|
|
294
|
+
bt709: [0.2126, 0.0722],
|
|
295
|
+
bt601: [0.299, 0.114],
|
|
296
|
+
bt2020: [0.2627, 0.0593],
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const tables = new Map();
|
|
300
|
+
|
|
301
|
+
/** Per-byte contributions in 0..255 units, made once per matrix and range:
|
|
302
|
+
* R = Y + crR, G = Y - cbG - crG, B = Y + cbB. */
|
|
303
|
+
function tablesFor(colorSpace, range) {
|
|
304
|
+
const key = `${colorSpace}/${range}`;
|
|
305
|
+
let t = tables.get(key);
|
|
306
|
+
if (t) return t;
|
|
307
|
+
const [kr, kb] = MATRIX[colorSpace];
|
|
308
|
+
const kg = 1 - kr - kb;
|
|
309
|
+
const full = range === 'full';
|
|
310
|
+
const y = new Float32Array(256);
|
|
311
|
+
const crR = new Float32Array(256);
|
|
312
|
+
const crG = new Float32Array(256);
|
|
313
|
+
const cbG = new Float32Array(256);
|
|
314
|
+
const cbB = new Float32Array(256);
|
|
315
|
+
for (let i = 0; i < 256; i++) {
|
|
316
|
+
y[i] = full ? i : ((i - 16) * 255) / 219;
|
|
317
|
+
const c = full ? (i - 128) * 1 : ((i - 128) * 255) / 224;
|
|
318
|
+
crR[i] = 2 * (1 - kr) * c;
|
|
319
|
+
cbB[i] = 2 * (1 - kb) * c;
|
|
320
|
+
crG[i] = ((2 * kr * (1 - kr)) / kg) * c;
|
|
321
|
+
cbG[i] = ((2 * kb * (1 - kb)) / kg) * c;
|
|
322
|
+
}
|
|
323
|
+
t = { y, crR, crG, cbG, cbB };
|
|
324
|
+
tables.set(key, t);
|
|
325
|
+
return t;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* `frame` from `sink` as straight RGBA into `out` (a Uint8ClampedArray of
|
|
330
|
+
* width x height x 4, which clamps and rounds what is written into it).
|
|
331
|
+
*/
|
|
332
|
+
export function frameToRGBA(sink, frame, out) {
|
|
333
|
+
const { width: w, height: h, format } = sink;
|
|
334
|
+
const [p0, p1, p2] = frame.planes;
|
|
335
|
+
const [s0, s1, s2] = frame.strides;
|
|
336
|
+
if (format === 'BGRA') {
|
|
337
|
+
for (let row = 0; row < h; row++) {
|
|
338
|
+
let si = row * s0;
|
|
339
|
+
let di = row * w * 4;
|
|
340
|
+
for (let x = 0; x < w; x++, si += 4, di += 4) {
|
|
341
|
+
out[di] = p0[si + 2];
|
|
342
|
+
out[di + 1] = p0[si + 1];
|
|
343
|
+
out[di + 2] = p0[si];
|
|
344
|
+
out[di + 3] = 255;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
const { y: Y, crR, crG, cbG, cbB } = tablesFor(sink.colorSpace, sink.range);
|
|
350
|
+
const nv12 = format === 'NV12';
|
|
351
|
+
for (let row = 0; row < h; row++) {
|
|
352
|
+
const yi = row * s0;
|
|
353
|
+
const ci = (row >> 1) * s1;
|
|
354
|
+
const vi = nv12 ? 0 : (row >> 1) * s2;
|
|
355
|
+
let di = row * w * 4;
|
|
356
|
+
for (let x = 0; x < w; x++, di += 4) {
|
|
357
|
+
const cx = x >> 1;
|
|
358
|
+
let cb;
|
|
359
|
+
let cr;
|
|
360
|
+
if (nv12) {
|
|
361
|
+
cb = p1[ci + cx * 2];
|
|
362
|
+
cr = p1[ci + cx * 2 + 1];
|
|
363
|
+
} else {
|
|
364
|
+
cb = p1[ci + cx];
|
|
365
|
+
cr = p2[vi + cx];
|
|
366
|
+
}
|
|
367
|
+
const l = Y[p0[yi + x]];
|
|
368
|
+
out[di] = l + crR[cr];
|
|
369
|
+
out[di + 1] = l - cbG[cb] - crG[cr];
|
|
370
|
+
out[di + 2] = l + cbB[cb];
|
|
371
|
+
out[di + 3] = 255;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
}
|