react-x11 2.32.0 → 2.34.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.
@@ -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;
@@ -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
@@ -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
+ }
@@ -0,0 +1,29 @@
1
+ // `useVideoFrames`: a VideoFrames sink for a component (src/videoframes.js).
2
+ import { useMemo } from 'react';
3
+
4
+ import { useApp } from './appcontext.js';
5
+ import { createVideoFrames } from './videoframes.js';
6
+
7
+ /**
8
+ * A sink for decoded frames of one size and format, the same object across
9
+ * renders and a new one when the size, the format or the colour changes —
10
+ * so a decoder started in an effect keyed on it is restarted exactly when
11
+ * its output would no longer fit:
12
+ *
13
+ * ```jsx
14
+ * const frames = useVideoFrames({ width: 1280, height: 720, format: 'NV12' });
15
+ * useEffect(() => startDecoder(url, frames), [frames, url]);
16
+ * <video frames={frames} style={{ width: '100%', aspectRatio: 16 / 9 }} />;
17
+ * ```
18
+ *
19
+ * Nothing to close on unmount: a sink owns nothing native, and what shows
20
+ * it lets go of what it made when it stops showing it.
21
+ */
22
+ export function useVideoFrames(options) {
23
+ const app = useApp();
24
+ const { width, height, format, colorSpace, range } = options ?? {};
25
+ return useMemo(
26
+ () => createVideoFrames(app, { width, height, format, colorSpace, range }),
27
+ [app, width, height, format, colorSpace, range],
28
+ );
29
+ }