react-x11 2.52.0 → 2.53.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.
Files changed (112) hide show
  1. package/README.md +10 -10
  2. package/docs/README.md +160 -314
  3. package/docs/accessibility.md +176 -229
  4. package/docs/appearance.md +77 -176
  5. package/docs/click-to-component.md +18 -51
  6. package/docs/clipboard.md +40 -59
  7. package/docs/components.md +518 -1036
  8. package/docs/dbus.md +61 -133
  9. package/docs/debugging.md +152 -245
  10. package/docs/desktop-calendar.md +51 -88
  11. package/docs/desktop.md +222 -347
  12. package/docs/devtools.md +50 -167
  13. package/docs/drag-and-drop.md +126 -204
  14. package/docs/ecosystem/animation.md +91 -125
  15. package/docs/ecosystem/data-fetching.md +38 -53
  16. package/docs/ecosystem/dev-tooling.md +159 -202
  17. package/docs/ecosystem/forms.md +61 -97
  18. package/docs/ecosystem/headless.md +64 -89
  19. package/docs/ecosystem/i18n.md +18 -21
  20. package/docs/ecosystem/icons.md +93 -126
  21. package/docs/ecosystem/layout.md +42 -52
  22. package/docs/ecosystem/routing.md +18 -29
  23. package/docs/ecosystem/state.md +71 -88
  24. package/docs/ecosystem/testing.md +131 -157
  25. package/docs/ecosystem/theming.md +119 -165
  26. package/docs/ecosystem.md +145 -179
  27. package/docs/elements.md +1091 -1774
  28. package/docs/embedding.md +83 -132
  29. package/docs/events.md +412 -695
  30. package/docs/extending.md +784 -1326
  31. package/docs/eyedropper.md +63 -122
  32. package/docs/filedialog.md +71 -107
  33. package/docs/frame.md +195 -339
  34. package/docs/gl.md +65 -62
  35. package/docs/globalmenu.md +63 -146
  36. package/docs/macos.md +531 -3234
  37. package/docs/notifications.md +33 -47
  38. package/docs/packaging.md +166 -309
  39. package/docs/permissions.md +9 -14
  40. package/docs/react-features.md +81 -106
  41. package/docs/remote.md +32 -76
  42. package/docs/scale.md +72 -186
  43. package/docs/styling.md +329 -547
  44. package/docs/system.md +200 -280
  45. package/docs/testing.md +63 -83
  46. package/docs/typescript.md +6 -48
  47. package/docs/uri-schemes.md +67 -107
  48. package/docs/wayland-backend.md +200 -459
  49. package/docs/windows-integrations.md +75 -131
  50. package/docs/windows.md +343 -1367
  51. package/package.json +3 -2
  52. package/src/Reconciler.js +2 -2
  53. package/src/a11y.js +1 -1
  54. package/src/backend/context2d.js +3 -3
  55. package/src/bootstrap.js +1 -1
  56. package/src/cocoa/app.js +6 -6
  57. package/src/cocoa/bezels.js +1 -1
  58. package/src/cocoa/fonts.js +1 -1
  59. package/src/cocoa/globalmenu.js +1 -1
  60. package/src/cocoa/keymap.js +1 -1
  61. package/src/cocoa/panewindow.js +1 -1
  62. package/src/cocoa/player.js +1 -1
  63. package/src/cocoa/presenter.js +2 -2
  64. package/src/cocoa/promotion.js +1 -1
  65. package/src/cocoa/relaunch.js +1 -1
  66. package/src/cocoa/threaded.js +1 -1
  67. package/src/cocoa/video.js +1 -1
  68. package/src/cocoa/window.js +4 -4
  69. package/src/components/Menu.js +7 -2
  70. package/src/components/Switch.js +1 -1
  71. package/src/components/native.js +1 -1
  72. package/src/dnd.js +2 -2
  73. package/src/glnodes.js +1 -1
  74. package/src/grid.js +1 -1
  75. package/src/layouts.js +1 -1
  76. package/src/nodes/animation.js +1 -1
  77. package/src/nodes/invalidate.js +1 -1
  78. package/src/nodes/layouthost.js +1 -1
  79. package/src/nodes/video.js +19 -8
  80. package/src/nodes/window/droptarget.js +1 -1
  81. package/src/pacing.js +1 -1
  82. package/src/refresh/index.js +2 -1
  83. package/src/types/video.d.ts +5 -2
  84. package/src/videobackend.js +28 -0
  85. package/src/videoframes.js +29 -16
  86. package/src/wayland/app.js +27 -0
  87. package/src/wayland/video.js +376 -0
  88. package/src/win32/a11y.js +1 -1
  89. package/src/win32/app.js +2 -2
  90. package/src/win32/fonts.js +1 -1
  91. package/src/win32/glarea.js +4 -4
  92. package/src/win32/overlay.js +1 -1
  93. package/src/win32/panewindow.js +3 -3
  94. package/src/win32/shell.js +1 -1
  95. package/src/win32/window.js +2 -2
  96. package/src/windowid.js +1 -1
  97. package/src/xvideo.js +189 -0
  98. package/docs/architecture/accessibility.md +0 -470
  99. package/docs/architecture/animation.md +0 -707
  100. package/docs/architecture/container-queries.md +0 -525
  101. package/docs/architecture/custom-layout.md +0 -334
  102. package/docs/architecture/drag-and-drop.md +0 -941
  103. package/docs/architecture/element-layer-contents.md +0 -622
  104. package/docs/architecture/frame-pacing.md +0 -255
  105. package/docs/architecture/grid-layout.md +0 -358
  106. package/docs/architecture/protocol-efficiency.md +0 -357
  107. package/docs/architecture/video.md +0 -1149
  108. package/docs/architecture/windowed-regions.md +0 -854
  109. package/docs/glx.md +0 -137
  110. package/docs/wayland.md +0 -545
  111. package/docs/windows-embedding.md +0 -271
  112. package/docs/windows-gl.md +0 -221
@@ -1,1149 +0,0 @@
1
- # Video: decoding a stream, capturing one, and putting the frames on the screen
2
-
3
- _Design record, 2026-09-08, **reviewed 2026-10-02** against master at 65e323a
4
- (react-x11 2.30.0, ntk ^8.17.8, `@windowkit/appkit` ^0.19.0,
5
- `@windowkit/win32` ^0.0.6, `@windowkit/wayland` ^3.1.1, node-x11 4.2.3 in
6
- the lockfile). §0.2 is what has been built since — track 1 of §12 and the
7
- Cocoa half of track 2. §0.1 is what
8
- moved in the month between the two dates and what it changed here; §6 is
9
- the element, §7 the seam it rides, §8 the two consumers it is for, §12 what
10
- can be built now and in what order, and §13 the spikes every number came
11
- from — two of them new, and one of them overturning the conclusion the first
12
- version of this record drew for macOS. The mechanism it extends is
13
- [sprites](../extending.md#parts-of-your-drawing-on-layers-of-their-own),
14
- which shipped where
15
- [element-owned layer contents](element-layer-contents.md) was designed; the
16
- constraint it runs into on X11 is the one
17
- [protocol efficiency](protocol-efficiency.md) measures everywhere else._
18
-
19
- ---
20
-
21
- ## 0. TL;DR
22
-
23
- - **No X11 extension decodes video.** The one that did — XvMC — is dead,
24
- and the things that replaced it (VA-API, VDPAU, NVDEC) are not
25
- extensions at all but C libraries that borrow a `Display*`. On X11 the
26
- decoder is always ours, in our process, and X's only job is moving
27
- finished frames to the screen without a copy per pixel. Nothing about
28
- that changed, and nothing will.
29
- - **On Cocoa both halves are OS services.** VideoToolbox decodes in
30
- hardware into `CVPixelBuffer`s that are already IOSurfaces, and
31
- `AVPlayerLayer` is a `CALayer` that shows an `AVPlayer`, which takes a
32
- URL and does the rest.
33
- Measured: 1.87ms per 1080p H.264 frame, hardware, every frame
34
- IOSurface-backed (§3.3).
35
- - **The trap that decided the first version of this record is not a trap
36
- on screen.** A `CALayer` whose `contents` is a YUV IOSurface rasterizes
37
- _nothing_ through `render(in:)` — that measurement stands — and shows the
38
- picture **on screen**, through the render server, for every two-plane
39
- and packed layout tried: `420v`, `420f` and `2vuy` all render; only the
40
- three-plane `y420` does not (§3.4, probed today through the window
41
- server's own capture). So the frame sink on macOS needs no AV layer
42
- class and no BGRA conversion: a plain layer and an NV12 surface, which
43
- costs **0.40ms of CPU per 1080p frame** for the copy and the commit
44
- (§3.4.1). `AVPlayerLayer` is for _sources_ — a URL, with audio, seeking
45
- and HLS — not a workaround.
46
- - **The seam this needs shipped, under another name.** #499's
47
- `presentedSurface()` was never built; what landed is **sprites**
48
- (#819/#821/#828): a node offers parts of its drawing, the surface
49
- presenter lifts each onto a layer under promotion's rules — the same
50
- z-order test, the same clip boxes, the same hole, the same "declining is
51
- always safe" — and today every part's content is a raster the presenter
52
- paints by calling the part's own `paint`. Video is one addition to that
53
- vocabulary: **a part whose content is a source** rather than a paint
54
- (§7). Everything else about lifting a video is already written and
55
- tested.
56
- - **So a cross-backend `<video>` is one element with two sources.**
57
- `frames`, a sink the application feeds decoded frames into, on every
58
- backend; and `src`, a URL the platform plays itself, on the backends that
59
- have a platform player — AVFoundation now, Media Foundation when the
60
- Windows half lands — refused with a typed error where there is none (§6).
61
- Presented two ways everywhere: lifted onto a layer when the frame says it
62
- can be, drawn into the window's bitmap when it cannot.
63
- - **Two consumers, one element.** A `<video>` in a form is the element.
64
- A `<video>` inside `@react-x11/components`' `<Html>` is the element too,
65
- mounted beside the document at the box's rect the way `<Html>` already
66
- mounts a core `<textinput>` for an `<input>` (§8) — not a second video
67
- implementation inside the HTML renderer, and not a new seam.
68
- - **Windows has the pieces and none of the design.** The win32 backend is
69
- a DirectComposition visual tree per window with `<glarea>` on a visual of
70
- its own and a pane host that already attaches a surface _from a handle_ —
71
- which is precisely what `IMFMediaEngine`'s windowless swapchain hands
72
- out. §4 is the plan and the list of things to probe on a Windows machine
73
- before writing any of it.
74
- - **The X11 numbers that set the budget still stand.** A 1080p frame
75
- through core `PutImage` costs 33ms with a round trip (§2.5); SHM halves
76
- it and is already in ntk; Xv is now in node-x11 **and in the lockfile**
77
- (4.2.3), untouched by anything here; XQuartz still answers "no adaptors
78
- present", so every Xv path still needs the drawn fallback by
79
- construction.
80
-
81
- ### 0.1 Review, 2026-10-02: what moved
82
-
83
- | the record said, 2026-09-08 | the tree says, 2026-10-02 | what it changes here |
84
- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
85
- | a video node is an element-owned layer on #499's `presentedSurface()`, which should answer a layer | #499 is closed, nothing of it built; **sprites** shipped instead (`src/nodes/sprites.js`, `src/cocoa/sprites.js`), a per-frame accessor in the shape of `opaqueRect()` whose parts are rasters painted by the presenter | §7: the seam is a sprite part with `contents`, not a new accessor |
86
- | a `CALayer` draws no YUV IOSurface; use `AVSampleBufferDisplayLayer` or pay 2.1× the decode for BGRA | **on screen it draws every two-plane and packed layout**; the blank was `render(in:)`, a CPU path (§3.4) | the Cocoa frame sink is a plain layer and two bridge verbs, not an AV class; AV verbs are for `src` only |
87
- | the implementing PR should first verify that the render server scans out a YUV surface | verified: `420v`, `420f`, `2vuy` render; `y420` and `f420` (three planes) do not; colour attachments change the matching | NV12 is the sink's native format on macOS; I420 is interleaved on the way in |
88
- | the bridge should grow `createVideoLayer`, `enqueueSampleBuffer`, `flushVideoLayer` and a synchroniser (against `@windowkit/appkit` ^0.7.0) | 0.19.0: `createSurfaceIOSurface`, `surfaceFromIOSurfaceID`, `setLayerContentsIOSurface`, `surfaceIsInUse`, a `surface-released` event, `snapshotWindow` through `CGWindowListCreateImage`; still no CoreVideo, CoreMedia, VideoToolbox, AVKit or Metal linked | §9: the verb list, in two releases |
89
- | whether `snapshotWindow` composites sublayers is open, and decides what the tests can assert | it is the window server's composite, and today's capture holds an `AVPlayerLayer`, an `AVSampleBufferDisplayLayer` and the YUV tiles | pixel tests on Cocoa go through `snapshotWindow`; no `render(in:)` |
90
- | two backends | four: win32 (`src/win32/`, DirectComposition, `<glarea>` on a visual, a pane host over `CreateSurfaceFromHandle`) and Wayland (`src/wayland/`, dma-buf only, its own GLES rasterizer, no subsurfaces bound) | §4 and §5, new |
91
- | Xv is a lockfile refresh away | the lockfile has node-x11 4.2.3 (`PutImage`, `ShmPutImage`, `QueryImageAttributes`, `StopVideo`, both notify selectors); ntk still has no Xv wrapper and the renderer calls none of it | the Xv rung is gated on nothing upstream; it is still work |
92
- | camera capture is a separate element, later | unchanged — and the on-screen result makes both `AVCaptureVideoPreviewLayer` and a raw `2vuy` surface viable layer contents, where the first version doubted both | §3.5's conclusion softens; the element is still later |
93
- | the ecosystem has nothing to play a video with | `@react-x11/components` has `<MediaPlayer>` (mpv or VLC embedded over `<foreign>`, X11 only, transport over mpv's JSON IPC) and `<Html>` lays a `<video>` out as its poster, or a 300×150 box; neither plays a frame on macOS | §8: what each becomes |
94
-
95
- ### 0.2 Built, 2026-10-02
96
-
97
- - **Core** (§12 track 1): `VideoFrames` (`src/videoframes.js`,
98
- `createVideoFrames`, `useVideoFrames`), `<video>` (`src/nodes/video.js`)
99
- drawn on every backend through a `Surface` of its own, `objectFit` for
100
- `<video>` and `<image>` (`src/nodes/fit.js`), `poster`, HTML's sizing,
101
- `onLoadedMetadata`, the typed refusal of `src` (`NoMediaPlaybackError`)
102
- and `useSupports('mediaPlayback')`, false everywhere until a player lands.
103
- The JavaScript conversion (`frameToRGBA`) applies the matrix and the
104
- range and no transfer function.
105
- - **The `contents` sprite kind** (§7) in `src/cocoa/sprites.js`, and the
106
- lift in `src/cocoa/video.js`: a ring of up to four video surfaces, each
107
- push written and the layer repointed in the push. Measured on the real
108
- bridge, threaded: sixty pushes, one window frame — the lift's.
109
- - **`@windowkit/appkit` release A** (§9), with the shapes the work settled
110
- on: `createVideoSurface(width, height, { format, colorSpace, range })`,
111
- `writeVideoSurface(target, format, planes, { strides, … })` — whose target
112
- may also be a 2D surface, which is the drawn presentation's whole native
113
- half — `videoSurfaceIsInUse`, `releaseVideoSurface` and `videoFormats()`.
114
- The surface holds the IOSurface alone, not the CVPixelBuffer it was made
115
- through: a pixel buffer keeps a use count on its surface, and
116
- `IOSurfaceIsInUse` would answer true for one nothing shows.
117
- `videoSurfacePlanes` was not needed: the copy is 0.07ms at 1080p.
118
- - **A colour finding the record did not have.** With CoreVideo's tags
119
- alone, Core Animation shows a surface tagged BT.709 throughout with the
120
- exact 709 curve — while `AVPlayerLayer`, VideoToolbox's pixel transfer,
121
- Core Image, and Core Animation itself for any other tagging use Apple's
122
- 1.961 gamma: a video-range Y′=47 was sRGB 51 on such a layer and 40 under
123
- `AVPlayerLayer` playing the same clip. So §3.4's "colour attachments are
124
- read" was half the story: the bridge names each video surface's colour
125
- space as the one CoreVideo makes of its tags, which puts a lifted frame on
126
- the player's curve exactly, and every drawn frame goes through
127
- VideoToolbox, about 1ms at 1080p. A frame lifted, drawn, and played by
128
- `AVPlayerLayer` agree to within two levels on screen (the bridge's
129
- `test/video-surface.js` and `test/player.js`).
130
-
131
- Also: the presenter bench's `video` scenario and its gate rule (§9) — a
132
- lifted sink holds to no window frame per push, a rule read as skipped over a
133
- bridge without video surfaces. And **`<video src>` on macOS** (§12 track 3):
134
- `@windowkit/appkit`'s `createPlayer`, `playerSet`, `playerSeek`,
135
- `playerCopyFrame` and `releasePlayer` with the `player-*` events, and
136
- `src/cocoa/player.js`, the player as a sprite part's `contents` — its
137
- `AVPlayerLayer` lifted into the part, its frame copied into the element's
138
- surface while it plays and something is over it. HTML's props, events and
139
- imperative half; `useSupports('mediaPlayback')` true where the bridge plays.
140
- Not built yet: the Windows and Wayland rungs.
141
-
142
- ## 1. The asymmetry, stated once
143
-
144
- Every other feature in `docs/architecture/` has the same shape on every
145
- backend: a rule, N implementations, one set of semantics. Video does not,
146
- and pretending otherwise would produce an API that is a lie on half the
147
- backends.
148
-
149
- On macOS, "play this video" is a supported product: hand `AVPlayer` a URL,
150
- attach `AVPlayerLayer`, and you have hardware decode, HLS, A/V sync,
151
- seeking, HDR and colour management, none of it ours. On Windows the same
152
- product is Media Foundation's `IMFMediaEngine`, whose event vocabulary is
153
- HTML5's media element verbatim. On X11 and Wayland every one of those is a
154
- component someone assembles — a demuxer, a decoder, a clock, a colour
155
- conversion, a presentation path — and the display server contributes only
156
- the last one, badly on X11 unless you leave the protocol for a native
157
- library, and not at all on Wayland, which is a buffer handoff by design.
158
-
159
- The design consequence is that the _element_ is shared and the _source_ is
160
- not. A `<video>` whose only prop is a URL would work beautifully on Cocoa
161
- and be a lie on X11, where nothing in this project can open a URL and
162
- produce frames. A `<video>` whose only prop is a frame sink is honest
163
- everywhere but throws away the whole of AVFoundation. §6 is the resolution:
164
- both, with the frame sink as the primitive and the URL as the convenience
165
- of the backends that have a player, asked for with `useSupports` and refused
166
- with a typed error elsewhere.
167
-
168
- ## 2. X11
169
-
170
- ### 2.1 Xv, which does not decode
171
-
172
- Xv is the extension people mean when they say "the video extension", and
173
- the first thing to be clear about is that it is a **presentation**
174
- extension. It converts colour, it scales, and it puts the result in a
175
- drawable. It does not decode anything.
176
-
177
- The model is ports on adaptors. `QueryAdaptors` on a window answers the
178
- adaptors a screen has; each has ports, a type mask (`ImageMask` is the one
179
- that matters — an adaptor that accepts images from client memory rather
180
- than from a capture source), and the visuals it can draw into. You
181
- `GrabPort` one, ask `ListImageFormats` for the FOURCCs it takes (`YV12`,
182
- `I420`, `UYVY`, `YUY2`…), ask `QueryImageAttributes` for the plane pitches
183
- and offsets at your size, and then push a frame per `PutImage` — or
184
- `ShmPutImage`, which is the same request with a shared-memory segment in
185
- place of the payload.
186
-
187
- Two things it gives you that are worth the trouble:
188
-
189
- - **The colour conversion is the server's.** You push planar YUV at
190
- 1.5 bytes per pixel; the driver produces RGB. For 1080p that is 3.1MB
191
- per frame instead of 7.9MB, and no CPU of ours touches a pixel.
192
- - **The scale is the server's too**, with `QueryBestSize` to ask what it
193
- would prefer, and the drawable rectangle chosen per frame — so a resized
194
- video costs nothing extra on our side.
195
-
196
- And the caveats, which are why it is not simply the answer:
197
-
198
- - **Overlay adaptors and compositing do not mix.** A hardware overlay
199
- writes past the compositor; under a compositing WM you want a textured
200
- adaptor, which every modern driver provides, but the distinction is not
201
- visible in the protocol and the failure is a black or misplaced
202
- rectangle.
203
- - **There is no clock.** Xv presents when you ask. Pacing, A/V sync and
204
- vsync are entirely yours — which is exactly the seam the
205
- [frame pacer](frame-pacing.md) already owns for everything else.
206
- - **It may not exist.** Measured on this machine: XQuartz advertises
207
- `X-Video Extension version 2.2` and answers `no adaptors present`. The
208
- extension is there and useless. Any Xv path needs a fallback by
209
- construction, not as a nicety.
210
- - **Where it draws.** A textured adaptor's `PutImage` lands in the
211
- drawable it names. Named at our window it is overwritten by the next
212
- present of ntk's backing pixmap, and named at a child window of its own
213
- it stacks above everything drawn, which is the z-order flaw `<glarea>`
214
- and `<MediaPlayer>` both live with. The honest target is the **backing
215
- pixmap itself**, at the fitted rect, as a step of the paint pass — then
216
- the frame composites in paint order and the present carries it. Whether
217
- ntk will name that pixmap to a caller is a small seam to ask for, and
218
- the question the Xv rung starts with.
219
-
220
- ### 2.2 The decode extensions, and what replaced them
221
-
222
- **XvMC** is the one X extension that ever did decode: it took MPEG-2
223
- macroblocks, IDCT and motion compensation onto the GPU. It is effectively
224
- dead — a handful of drivers, MPEG-2 and some MPEG-4 only — and it is
225
- unreachable from a protocol client anyway, because the client API is a
226
- vendor `libXvMC*.so` the application dlopens, not wire requests. It is
227
- mentioned here so that the next person who finds it in a list of
228
- extensions does not spend an afternoon on it.
229
-
230
- **VA-API, VDPAU, NVDEC** are what people actually use, and none of them
231
- is an X extension. They are C libraries that use X only to obtain a
232
- display and, optionally, to present: `vaPutSurface(Drawable)`,
233
- `VdpPresentationQueueTargetCreateX11`. Underneath they go to DRI and the
234
- kernel. A pure-JS protocol client cannot speak them at all; reaching them
235
- means a native addon, at which point the addon may as well own decode and
236
- presentation together.
237
-
238
- The conclusion is the TL;DR's: **on X11 we decode, always.** In practice
239
- that means ffmpeg through a native addon, a WASM decoder, or a subprocess
240
- pipe — and that is a dependency decision (§10), not a rendering one.
241
-
242
- ### 2.3 The transports
243
-
244
- Given frames in our memory, three ways to get them onto the screen, in
245
- ascending order of how little they copy.
246
-
247
- **MIT-SHM.** `ShmPutImage` moves a frame through shared memory instead of
248
- the socket. ntk already has this — `ShmUploader` in
249
- `node_modules/ntk/lib/shm-upload.js`, reached through the app's `shm`
250
- getter and used by `putImageData` and by `Image` uploads: a pool of
251
- segments rounded up to a 64KB quantum, the first upload of a size going
252
- out as core `PutImage` while a segment warms behind it, and each segment
253
- returned on `ShmCompletion`. Its own measurements put it at roughly 2× on
254
- uploads, and it is available on XQuartz here. This is the floor of any X11
255
- video path, it is already built, and **the frame sink's drawn presentation
256
- gets it for free** by writing into an ntk `Surface` (§6.3).
257
-
258
- **DRI3 + Present.** The modern zero-copy present: `PixmapFromBuffer`
259
- imports a dma-buf fd as a Pixmap, `PresentPixmap` flips it with
260
- `target_msc`/`divisor`/`remainder` for vsync, and
261
- `PresentCompleteNotify`/`PresentIdleNotify` hand back both a frame clock
262
- and buffer recycling. node-x11 ships `dri3.js` and `present.js`, and
263
- `x11-dri` is already an optional dependency. The blocker is the direction:
264
- the DRI3 _receive_ side — importing a buffer somebody else allocated — is
265
- not wired, and that is precisely the direction a hardware decoder needs.
266
- This is the right long-term path and the largest piece of work in this
267
- document.
268
-
269
- **GLX / direct GL.** Upload Y and CbCr as textures and convert in a
270
- fragment shader. This works **today**, through `<glarea>`, with no new
271
- protocol at all: see [gl.md](../gl.md) and the direct-GL notes in
272
- [glx.md](../glx.md). It is the fastest thing to prototype and it costs a
273
- `<glarea>`'s constraints — the node owns a real child window or layer, and
274
- its pixels cannot be read back through the X path. It is also, as it
275
- happens, the shape of the Wayland answer (§5), where the backend's whole
276
- 2D context is a GLES rasterizer.
277
-
278
- ### 2.4 What node-x11 has
279
-
280
- Until 2026-09-07 `lib/ext/xv.js` implemented the half of Xv that asks
281
- questions and none of the half that answers with pixels. That gap is
282
- closed —
283
- [sidorares/node-x11#294](https://github.com/sidorares/node-x11/issues/294)
284
- was fixed by
285
- [#295](https://github.com/sidorares/node-x11/pull/295) — and, since this
286
- record was first written, **installed**: the lockfile carries 4.2.3, so
287
- `npm ci` now gets the image path. The refresh the first version asked for
288
- has happened.
289
-
290
- | Xv minor opcode | 4.1.0 | 4.2.0+ |
291
- | ----------------------------------------------- | ----- | ------- |
292
- | 0–4 version, adaptors, encodings, port grab | yes | yes |
293
- | 5–8 `PutVideo`/`PutStill`/`GetVideo`/`GetStill` | no | no |
294
- | 9 `StopVideo` | no | **yes** |
295
- | 10 `SelectVideoNotify` / 11 `SelectPortNotify` | no | **yes** |
296
- | 12–16 best size, port attributes, image formats | yes | yes |
297
- | 17 `QueryImageAttributes` | no | **yes** |
298
- | 18 `PutImage` | no | **yes** |
299
- | 19 `ShmPutImage` | no | **yes** |
300
-
301
- 5–8 remain absent on purpose — they are the capture-card half of the
302
- extension, as dead as XvMC, and §3.5's answer to "where do camera frames
303
- come from" is not Xv.
304
-
305
- Two things about the implementation that bound what this document can
306
- assume:
307
-
308
- - **`PutImage` refuses a frame the connection cannot carry**, rather than
309
- sending it and having the server drop the connection over a bad request
310
- length. The 40-byte header plus a padded payload has to fit
311
- `max_request_length`, which without BIG-REQUESTS is 256KB. A 1080p
312
- `I420` frame is 3.1MB, so **`PutImage` alone cannot carry an HD frame on
313
- a connection without BIG-REQUESTS**, and will say so instead of failing
314
- mysteriously.
315
- - **`ShmPutImage` sidesteps that entirely**, because its 52 bytes carry a
316
- `SHMSEG` and no payload. So for HD it is not an optimisation over
317
- `PutImage`; it is the path.
318
-
319
- What is still missing is ours: ntk has no Xv wrapper (its only `xv`
320
- substrings are `Xvfb` and `glxVisual`), and nothing in `src/` requires the
321
- extension. The rung is gated on nobody but us (§12).
322
-
323
- ### 2.5 Measured: what the naive path costs
324
-
325
- The question that decides whether "decode in-process and `PutImage` the
326
- result" is even worth prototyping. A 1920×1080 frame at depth 24, pushed
327
- to an offscreen pixmap with a `GetInputFocus` round trip after each so the
328
- server has demonstrably consumed it, on XQuartz over a unix socket, M1 Pro:
329
-
330
- | path | median | min | p95 |
331
- | ------------------------------------- | ------- | ------- | ------- |
332
- | core `PutImage`, 7.9 MiB BGRA, + sync | 33.19ms | 25.22ms | 48.26ms |
333
-
334
- That is **238 MiB/s and a 30.1fps ceiling** with zero time left for
335
- layout, paint, or the rest of the application — for one video, at 1080p,
336
- on a local socket. It is a bound rather than a Linux number (XQuartz is
337
- not a DRI-backed server), but it is the right order of magnitude for the
338
- shape of the problem, and it prices the alternatives:
339
-
340
- - `420v` instead of BGRA is 3.1MB instead of 7.9MB — **2.6× less to
341
- push**, before SHM.
342
- - SHM removes the socket copy; ntk's own figure is ~2× on uploads.
343
- - Xv does both _and_ moves the colour conversion and the scale off our
344
- CPU — which is why it was worth the upstream request even though the
345
- extension is absent on XQuartz.
346
-
347
- What follows for the element: on X11 the sink wants **BGRA frames**, and
348
- says so (§6.3's `preferredFormats`), because a decoder that can emit BGRA
349
- does the conversion in C for nothing, and one that cannot leaves it to a
350
- JavaScript loop over two million pixels. At 720p — the size a video in a
351
- form actually is — a BGRA frame is 3.7MB, which by the two numbers above
352
- the SHM path puts on the screen in the order of ten milliseconds on
353
- XQuartz — derived, not measured, and owed a measurement by the bench
354
- scenario in §12. That is the honest X11
355
- budget until the Xv rung, and a document that said otherwise would be
356
- read by someone building a 4K player on a `PutImage`.
357
-
358
- ## 3. Cocoa
359
-
360
- ### 3.1 Decode
361
-
362
- `VTDecompressionSession` is the primitive: build a
363
- `CMVideoFormatDescription` from the stream's parameter sets, feed it
364
- `CMSampleBuffer`s, get `CVPixelBuffer`s. Ask for IOSurface backing with
365
- `kCVPixelBufferIOSurfacePropertiesKey` and every output buffer is a
366
- surface the render server can already read.
367
-
368
- Measured on this machine (macOS 15.2 then, M1 Pro),
369
- `VTIsHardwareDecodeSupported`:
370
-
371
- | codec | hardware |
372
- | ---------- | -------- |
373
- | H.264 | yes |
374
- | HEVC | yes |
375
- | ProRes 422 | yes |
376
- | AV1 | no |
377
- | VP9 | no |
378
-
379
- AV1 is the one to plan around: hardware AV1 decode arrives with M3-class
380
- silicon, so an AV1 stream on this machine is a software path or nothing.
381
-
382
- Above VideoToolbox, AVFoundation supplies the parts we would otherwise
383
- write: `AVAsset` + `AVAssetReader` for files frame by frame, `AVPlayer` /
384
- `AVPlayerItem` for URLs including HLS,
385
- `AVPlayerItemVideoOutput.copyPixelBuffer(forItemTime:)` to pull frames in
386
- step with a display link, `AVCaptureVideoPreviewLayer` for a camera, and
387
- ScreenCaptureKit for the screen.
388
-
389
- ### 3.2 The presentation ladder
390
-
391
- Every rung is a `CALayer`, which is why all of them compose with layer
392
- promotion and with the sprite order unchanged.
393
-
394
- 1. **`AVPlayerLayer`** — set `player`, set `videoGravity`, done. No pixel
395
- code and no JS in the frame path at all. Everything about the source is
396
- AVFoundation's. **The rung for `src`.**
397
- 2. **`AVSampleBufferDisplayLayer`** — enqueue `CMSampleBuffer`s from our
398
- own demuxer or socket; it decodes and schedules them itself, and
399
- `AVSampleBufferRenderSynchronizer` with a `CMTimebase` is how it is
400
- synchronised against audio. The rung for "a compressed stream that is
401
- not a file or a URL" — which no consumer in §8 has yet.
402
- 3. **A plain `CALayer` whose `contents` is the decoded IOSurface** — the
403
- rung this repo is already standing on, since `setLayerContentsIOSurface`
404
- is how `<glarea>`, the `<Frame>` pane host and the window swapchain all
405
- present. **The rung for `frames`**, now that §3.4 says it takes YUV.
406
- 4. **`CAMetalLayer` + `CVMetalTextureCache`** — zero-copy `MTLTexture`
407
- from the pixel buffer, full shader control, our own colour conversion
408
- and scaling. Nothing here needs it.
409
- 5. `CAOpenGLLayer` — legacy, and `<glarea>`'s neighbourhood.
410
-
411
- ### 3.3 Measured
412
-
413
- The 2026-09-08 spike encodes a synthetic sequence with VideoToolbox,
414
- decodes it back, and times each step. 1920×1080 H.264, 60 frames, hardware
415
- acceleration confirmed on the session, every output buffer IOSurface-backed:
416
-
417
- | step | median |
418
- | -------------------------------------------- | ------- |
419
- | decode a 1080p H.264 frame, default output | 1.868ms |
420
- | decode the same frame, output forced to BGRA | 3.917ms |
421
- | `layer.contents = ioSurface` | 0.04µs |
422
-
423
- At 1280×720 the same measurement is 1.143ms for H.264 and 0.932ms for
424
- HEVC. The flip is **below the resolution of a microsecond timer**, because
425
- the assignment itself is a pointer swap and the transaction commit is
426
- where CA's cost lives.
427
-
428
- `AVSampleBufferDisplayLayer` accepted all 30 compressed sample buffers it
429
- was offered headless, with `status` rendering and no error.
430
-
431
- ### 3.4 The YUV surface: blank through `render(in:)`, drawn on screen
432
-
433
- A hardware decoder's natural output is `420v` — bi-planar 8-bit
434
- video-range YCbCr, two planes, 3.1MB for 1080p. Our swapchain surfaces are
435
- BGRA. The obvious move is to hand the decoder's surface straight to the
436
- existing `setLayerContentsIOSurface` verb.
437
-
438
- The first version of this record measured that through
439
- `CALayer.render(in:)` and got an empty box: 0 of 4096 pixels for a decoded
440
- `420v` frame, 0 of 4096 for a captured packed `2vuy` one, against BGRA and
441
- `CGImage` controls at 4096 of 4096 through the same probe. It read that as
442
- "a `CALayer` takes any YUV IOSurface and draws none of them", recommended
443
- AV layer classes for everything, and — to its credit — ended with "whether
444
- the render server scans out a YUV surface on screen is not settled by it,
445
- and that is the very first thing the implementing PR should check".
446
-
447
- Checked today (`scripts/spikes/video/yuvscan.swift`, §13): ten tiles in a
448
- borderless window, each a `CALayer` on screen, the window captured
449
- through the window server (`screencapture -l`, the same source as the
450
- bridge's `snapshotWindow`), and the centre pixel of each tile read back.
451
- macOS 15.8.1, M1 Pro:
452
-
453
- | layer `contents` | on screen, window-server capture | `render(in:)`, 2026-09-08 |
454
- | ---------------------------------------------- | -------------------------------- | ------------------------- |
455
- | BGRA IOSurface — control | blue, as filled | 4096 / 4096 px |
456
- | `420v` IOSurface, 2 planes, video range | **green, as filled** | 0 / 4096 px |
457
- | `420f` IOSurface, 2 planes, full range | **green, as filled** | — |
458
- | `2vuy` IOSurface, 1 packed plane | **green, as filled** | 0 / 4096 px |
459
- | `y420` IOSurface, 3 planes, video range | nothing: the tile is transparent | — |
460
- | `f420` IOSurface, 3 planes, full range | nothing: the tile is transparent | — |
461
- | `AVSampleBufferDisplayLayer`, 30 H.264 frames | red, as encoded | — |
462
- | `AVPlayerLayer`, a `.mov` written by the probe | magenta, as encoded | — |
463
- | `CGImage` — control | orange, as drawn | 4096 / 4096 px |
464
-
465
- Three conclusions, and the first two overturn what was written here before:
466
-
467
- - **The render server draws YUV layer contents.** `render(in:)` is a CPU
468
- path that does not, which is a fact about testing, not about the screen
469
- (§13 says what to test through instead). The "free" path is free.
470
- - **Two planes or packed, not three.** Every bi-planar and packed layout
471
- tried renders; the three-plane planar ones — what ffmpeg calls
472
- `yuv420p`, the default output of nearly every decoder — draw nothing:
473
- the layer is transparent and the window shows through.
474
- So the sink's native format on macOS is **NV12**, and an I420 frame is
475
- interleaved into one on the way in: a loop the bridge's write verb does
476
- over 1.5 bytes per pixel, in the same pass as the copy it has to make
477
- anyway.
478
- - **Colour attachments are read, and change the picture.** A second
479
- `420v` tile with a BT.601 matrix and an sRGB colour space attached
480
- rendered a visibly lighter green than the bare one. The sink has to say
481
- what its bytes mean (§6.3: `colorSpace`, `range`), and the bridge has to
482
- attach it to the surface — the Generic-RGB default has bitten this
483
- backend before (#820), and a video that is subtly the wrong colour is
484
- the kind of defect nobody files.
485
-
486
- The cost of the three ways out stays on record because two of them are
487
- still what `src` and a compressed stream use, and because the number is
488
- what proves the sink should not convert: forcing BGRA out of the decoder
489
- costs **2.1× the decode** (3.917ms against 1.868ms) plus 8.3MB per surface
490
- against 3.1MB; `AVSampleBufferDisplayLayer` takes YCbCr natively and brings
491
- A/V sync; Metal is its own render loop.
492
-
493
- #### 3.4.1 Measured: what the frame sink costs through a plain layer
494
-
495
- The question §6.3 rests on: when decoded bytes arrive from a decoder — over
496
- a pipe, from an addon, from a WASM module — what does it cost to show them
497
- through rung 3? `scripts/spikes/video/sinkcost.swift` stands a `memcpy`
498
- into a locked IOSurface in for the read, points a layer at it inside a
499
- transaction, and paces the loop to 60Hz so the render server has a real
500
- frame per iteration. 1920×1080, a ring of three surfaces, 240 frames,
501
- process CPU from `getrusage`, two runs each:
502
-
503
- | format | bytes per frame | CPU per frame | note |
504
- | ----------- | --------------- | -------------- | ----------------------------------------- |
505
- | NV12 (420v) | 3.11 MB | 0.40 – 0.43 ms | the copy and the commit; nothing converts |
506
- | BGRA | 8.29 MB | 0.69 – 0.72 ms | the same, over 2.7× the bytes |
507
-
508
- The wall time per iteration is the 60Hz pacing, not a cost. So a
509
- 1080p60 sink lifted onto a layer is **about 25ms of CPU per second**
510
- in the process that feeds it, before the decoder; the decoder is the
511
- whole budget, and the presentation is noise beside it. That is the number
512
- that says the sink needs no JS frame per video frame and no pacer claim:
513
- a `push()` on a lifted sink writes the surface and repoints the layer
514
- right there, and react-x11's own frame clock is not involved until the
515
- next frame re-decides the lift (§7).
516
-
517
- ### 3.5 Capture: the same stack, one step shorter
518
-
519
- A webcam is the same set of API calls with the decode removed.
520
- `AVCaptureSession` delivers `CMSampleBuffer`s carrying IOSurface-backed
521
- `CVPixelBuffer`s — the same objects a `VTDecompressionSession` produces —
522
- and `AVCaptureVideoPreviewLayer` is a `CALayer`, so rung 1 of §3.2 serves a
523
- camera exactly as it serves a file. And since §3.4, so does rung 3: a
524
- camera's native `2vuy` frame is a layer content the screen shows.
525
-
526
- Three things separate capture from playback, and only one of them is work.
527
-
528
- **The permission half is already built.** `camera` and `microphone` are
529
- first-class kinds in [permissions.md](../permissions.md) —
530
- `usePermission('camera')`, `permissionStatus`, `requestPermission`,
531
- `openPrivacySettings` — translated in `src/cocoa/permissions.js` onto the
532
- bridge's `authorizationStatus`/`requestAuthorization` verbs, which are
533
- `AVCaptureDevice`'s underneath (the one place the bridge links
534
- AVFoundation today). A capture element does not need to touch TCC at all.
535
-
536
- **Discovery needs no authorization.** `AVCaptureDevice.DiscoverySession`
537
- enumerates cameras and microphones, names included, while the status is
538
- still `notDetermined` — no prompt, no indicator light. A device picker can
539
- be built and rendered before consent; only the frames are gated.
540
-
541
- **The conversion is not free, and this is the finding.** A camera's native
542
- delivery is YUV — `2vuy` at the 720p preset here. Asking the output for
543
- BGRA instead is one line and is the conventional capture configuration.
544
- Measured, 1280×720, 60 frames per configuration, camera live:
545
-
546
- | output configuration | delivered | process CPU | delivery interval | our callback |
547
- | -------------------- | --------- | ------------- | ----------------- | ------------ |
548
- | native, no settings | `2vuy` | 4.16ms/frame | 33.5ms | 0.054ms |
549
- | `videoSettings` BGRA | `BGRA` | 11.12ms/frame | 33.0ms | 0.095ms |
550
-
551
- **+6.96ms per frame, 2.67×**, charged to our own process rather than to the
552
- camera daemon, on another thread — so it burns a core rather than dropping
553
- frames, which is precisely the kind of cost the frame pacer cannot see.
554
- The absolute figures amortize one-time session setup over 60 frames; the
555
- delta is the trustworthy part.
556
-
557
- So capture reaches the same conclusion as playback by the same route: do
558
- not convert. Hand the session to `AVCaptureVideoPreviewLayer`, or hand its
559
- native surfaces to a plain layer; both are on the screen.
560
-
561
- **What the tests cannot do.** A running `AVCaptureVideoPreviewLayer` with a
562
- live connection rasterized 0/4096 through `layer.render(in:)` — which, after
563
- §3.4, is unsurprising rather than alarming. Capture pixel tests go through
564
- the window server's capture like every other layer's, or through an
565
- `AVCaptureVideoDataOutput` tap.
566
-
567
- **One packaging finding, because it will cost someone an afternoon.** A
568
- correctly signed `.app` carrying `NSCameraUsageDescription` and a
569
- `CFBundleIdentifier`, executed directly from a shell, answered
570
- `requestAccess` with `granted = false` while `authorizationStatus` stayed
571
- `notDetermined` — a refusal that never prompted. Launched through
572
- LaunchServices (`open`), so that the app is its own responsible process,
573
- the same binary prompted and was granted. This is the same shape as the
574
- notification rung's trap in [packaging.md](../packaging.md), and the same
575
- rule applies: **a falsy grant with the status still `notDetermined` is not
576
- a denial**, and code that reads it as one tells the user they declined
577
- something they were never asked.
578
-
579
- ## 4. Windows
580
-
581
- _Written on a Mac against the bridge's source at v0.0.6 and
582
- [windows.md](../windows.md). Nothing below has been run; §4.3 is what to
583
- run first._
584
-
585
- ### 4.1 What the backend has
586
-
587
- A window on win32 is a DirectComposition **visual tree**: one target, one
588
- root visual whose content is a virtual surface in `B8G8R8A8`, painted one
589
- `BeginDraw` per damage rect and committed once per frame
590
- ([windows.md](../windows.md#rendering)). Under the root visual hang the
591
- children an element can own today, each on a visual of its own:
592
-
593
- - **`<glarea>`**, a composition swapchain filled through a WGL context and
594
- `WGL_NV_DX_interop2`, added at the _bottom_ of the children
595
- ([windows-gl.md](../windows-gl.md)). A child HWND was built, measured
596
- invisible in every variant, and abandoned
597
- ([windows-embedding.md](../windows-embedding.md)).
598
- - **A `<Frame>` pane**, attached from a surface _handle_ another process
599
- presents into: `DCompositionCreateSurfaceHandle` on the pane's side,
600
- `CreateSurfaceFromHandle` and a visual on the host's
601
- (`paneAttach`, `paneSetRect` with its clip).
602
- - **Layers**, a premultiplied virtual surface on a visual at the _top_ of
603
- the children, which is what `<glarea>`'s drawn children paint on
604
- (`layerCreate`, `layerBeginDraw`, `layerScroll`).
605
-
606
- What it does not have is a presenter that decides, per frame, whether an
607
- element's content may sit on a visual of its own: the Windows retained tier
608
- and promotion are a Phase 4 probe in windows.md, not code, and the sprite
609
- seam is asked on macOS only. So on win32 today a visual is where
610
- `<glarea>`'s is — above the 2D surface, under the layers, and under
611
- whatever the 2D surface paints after it is not. There is no Media
612
- Foundation, no YUV, no `DXGI_FORMAT_NV12` anywhere in the bridge, and
613
- `windowPixels` reads the window back through `PrintWindow`.
614
-
615
- ### 4.2 The plan
616
-
617
- **`src` is `IMFMediaEngine`**, the platform's player, which has two ways to
618
- show a picture and the backend wants both, in this order:
619
-
620
- 1. **Frame-server mode first.** `TransferVideoFrame` copies the current
621
- frame into a D3D11 texture on request; the backend draws that texture
622
- into the window's surface at the fitted rect, in paint order, like an
623
- `<image>` whose bytes changed. A copy per frame — but a GPU copy, and
624
- correct under every tooltip, popup and fade, with no new z-order rule.
625
- This is the drawn presentation of §7, and on Windows it is the one that
626
- ships first because nothing there can yet decide the other.
627
- 2. **Windowless swapchain mode second.** `EnableWindowlessSwapchainMode`,
628
- `GetVideoSwapchainHandle`, and the handle goes to the exact call the
629
- pane host already makes — `CreateSurfaceFromHandle` onto a visual at
630
- the element's rect. Zero copies, the engine presents on its own clock,
631
- and the overlay planes can scan it out where the GPU has them. It
632
- needs what `<glarea>` lacks: a per-frame answer to "is anything drawn
633
- over this", which is the Windows half of promotion. Until then it is
634
- `<glarea>`'s stacking, and a video element should not inherit that
635
- quietly.
636
-
637
- The engine's notifications — `LOADEDMETADATA`, `TIMEUPDATE`, `PLAYING`,
638
- `PAUSE`, `ENDED`, `ERROR` — are the element's events already (§6.1); the
639
- bridge forwards them as it forwards everything else, in batches, off the
640
- engine's work queue onto Node's thread.
641
-
642
- **`frames` is a texture.** A decoded frame arrives as bytes; the bridge
643
- puts them in a `D3D11_USAGE_DYNAMIC` texture and the drawn presentation
644
- composites it. BGRA is the floor. NV12 is the better transport —
645
- `DXGI_FORMAT_NV12` textures are routine, and a flip-model composition
646
- swapchain in NV12 on a visual of its own is what windows.md's rendering
647
- section already names for video — but whether the adapter will create one
648
- is a `CheckFormatSupport` question per machine, and the fallback is a
649
- `ID3D11VideoProcessor` blit to BGRA. Both are probes, not plans.
650
-
651
- ### 4.3 What to probe first, on the Windows machine
652
-
653
- In the order that decides the most for the least:
654
-
655
- 1. `IMFMediaEngine` from a bridge thread: create it from
656
- `MFMediaEngineClassFactory` on the UI thread with a DXGI device manager
657
- over the bridge's D3D11 device, load a file URL, confirm
658
- `LOADEDMETADATA` and the frame size, and `TransferVideoFrame` into a
659
- texture the 2D surface can draw. That is the whole drawn path.
660
- 2. `EnableWindowlessSwapchainMode` + `GetVideoSwapchainHandle` +
661
- `CreateSurfaceFromHandle` on a visual: does the picture appear, does
662
- `paneSetRect`'s clip apply to it, and does it respect the visual's
663
- place among the children?
664
- 3. `windowPixels` over 1 and 2: does `PrintWindow` with full-content
665
- rendering see a swapchain visual's frame? It decides what the tests can
666
- assert without a capture tool.
667
- 4. `CheckFormatSupport(DXGI_FORMAT_NV12)` with `DISPLAY` and
668
- `TEXTURE2D`, and a composition swapchain in NV12: on the GPU in the
669
- machine and on WARP, since CI is WARP.
670
- 5. The cost of the frame-server copy at 1080p60: a GPU copy is cheap, and
671
- the number is still owed before it is called cheap here.
672
- 6. Media Foundation's threading against the bridge's UI thread: the engine
673
- wants a multithreaded apartment and a work queue; the UI thread is an
674
- STA for OLE. Confirm the notify callback can post to the bridge's event
675
- batch without a wait on the UI thread.
676
-
677
- ## 5. Wayland
678
-
679
- The Wayland backend presents dma-bufs and nothing else: every frame is
680
- drawn by its own GLES rasterizer into a GPU target and handed over through
681
- `zwp_linux_dmabuf_v1`; `wl_shm` is bound for screen capture only; and
682
- `wl_subcompositor` is never bound, by design — a subsurface would be a
683
- second swapchain, a second frame clock and a second import per frame
684
- ([wayland-backend.md](../wayland-backend.md)). Its `<glarea>` is a
685
- rectangle of the window's own target, not a surface of its own.
686
-
687
- That makes its video story the shortest one here and, for the frame sink,
688
- the cheapest: **the backend's 2D context is already a GL rasterizer**, so
689
- decoded planes go up as textures and the colour conversion is a sampler in
690
- a fragment shader — the GLX transport of §2.3, native to the backend rather
691
- than bolted on, and with no conversion in JavaScript for any format. The
692
- sink's drawn presentation on Wayland takes NV12, I420 and BGRA alike and
693
- prefers whichever the decoder emits. Nothing lifts: there is no layer to
694
- lift onto and no compositor-side scaler asked for, and `wp_viewporter` is
695
- used for the window's fractional scale only.
696
-
697
- `src` has no answer on Wayland for the same reason it has none on X11 — no
698
- display-server player exists; GStreamer and PipeWire are libraries, and
699
- the application that wants them brings them (§8.3) — so
700
- `useSupports('mediaPlayback')` is false there and `<video src>` refuses the
701
- way it does on X11. The zero-copy future is the same shape as X11's DRI3
702
- one: a decoder that exports dma-buf fds (VA-API does) and a subsurface that
703
- imports them, both of which the backend has deliberately not built, and
704
- neither of which a form with a video in it needs.
705
-
706
- ## 6. The element
707
-
708
- ### 6.1 `<video>`
709
-
710
- ```jsx
711
- // a file or URL the platform plays itself — AVFoundation today,
712
- // Media Foundation when the Windows half lands
713
- <video
714
- src="file:///Users/me/clip.mp4"
715
- autoPlay
716
- muted
717
- loop
718
- style={{
719
- width: 320,
720
- height: 180,
721
- objectFit: 'contain',
722
- backgroundColor: '#000',
723
- }}
724
- onLoadedMetadata={({ width, height, duration }) => setDuration(duration)}
725
- onTimeUpdate={({ currentTime }) => setAt(currentTime)}
726
- />;
727
-
728
- // frames the application decodes — every backend
729
- const frames = useVideoFrames({ width: 1280, height: 720, format: 'NV12' });
730
- useEffect(() => {
731
- const decoder = startDecoder(url, frames.preferredFormats);
732
- decoder.on('frame', (planes, time) => frames.push(planes, { time }));
733
- return () => decoder.stop();
734
- }, [frames, url]);
735
- <video frames={frames} style={{ width: '100%', aspectRatio: 16 / 9 }} />;
736
- ```
737
-
738
- A `<video>` is a **drawn** node like `<image>` — it joins yoga, it is in
739
- the paint order, it is hit-tested like anything — not a `drawn: false`
740
- child window like `<glarea>` or `<foreign>`. That is the whole point of
741
- §7: a video in a form sits _under_ the form's popups and tooltips and
742
- _inside_ its scroll panes, and a child window can do neither.
743
-
744
- | prop | |
745
- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
746
- | `src` | A path or URL, played by the platform. Where `useSupports('mediaPlayback')` is false the element reports one typed `onError` on a microtask and shows its `poster` — never a silent black box, the rule `<foreign>` set in #531. |
747
- | `frames` | A `VideoFrames` sink (§6.3). Mutually exclusive with `src`, and passing both throws, the way `<image>`'s `src`/`picture`/`drawable` do. |
748
- | `poster` | An `ImageSource`, shown until the first frame and whenever there is nothing to show. |
749
- | `autoPlay`, `loop`, `muted`, `volume`, `playbackRate` | HTML's names and HTML's defaults: nothing plays until asked. Meaningful with `src`; a sink plays what it is pushed. |
750
- | `paused` | Controlled when present, the shape `<MediaPlayer paused>` already has. Left out, the element is uncontrolled and the handle drives it. |
751
- | `onLoadedMetadata({ width, height, duration })` | The stream's size is also the element's natural size, kept to its ratio when the style names one axis; before it is known, HTML's 300×150. |
752
- | `onPlay`, `onPause`, `onEnded`, `onTimeUpdate`, `onError` | `onTimeUpdate` is coalesced to a few a second, as HTML's is, so a progress bar costs a render per tick and not per frame. |
753
- | `ref` → `{ play(), pause(), seek(seconds), currentTime, duration }` | The imperative half, for the same reason `HTMLMediaElement` has one: a seek is an event, not a state. |
754
- | `role`, `aria-label` | `video` by default — a role the accessibility model gains with the element, AT-SPI's `ROLE_VIDEO` beside `img`'s `IMAGE` — named by `aria-label`; the frames themselves are not described. |
755
-
756
- Style, in `style` like everything else: `width`/`height` as any element;
757
- `objectFit` — `'contain'` by default, `'cover'`, `'fill'` — decides where
758
- the picture goes in the box, and `backgroundColor` is what shows in the
759
- bars. `objectFit` is a new paint property and applies to `<image>` as well
760
- in the same change, since an image stretched to its box has wanted it for
761
- as long as there has been an `<image>`; `borderRadius` on a `<video>` is
762
- legal and costs the drawn presentation on macOS (§7), which the docs say
763
- in one sentence next to the prop.
764
-
765
- ### 6.2 What `useSupports('mediaPlayback')` answers
766
-
767
- Whether `<video src>` plays on this display: true on Cocoa once the player
768
- verbs ship, true on win32 once §4's first probe becomes code, false on X11
769
- and Wayland for good, false on every backend until then. It is a property
770
- of the display's pipeline, which is what `useSupports` answers; the
771
- desktop-service shape (`{ available, backend, features }`) is for things
772
- that come and go while the process runs, and a platform player does not.
773
- `frames` needs no capability: it works everywhere, at a cost that differs.
774
-
775
- ### 6.3 `VideoFrames`, the sink
776
-
777
- ```ts
778
- createVideoFrames(app, {
779
- width, height,
780
- format: 'NV12' | 'I420' | 'BGRA',
781
- colorSpace?: 'bt709' | 'bt601' | 'bt2020', // default bt709
782
- range?: 'video' | 'full', // default video
783
- }): VideoFrames
784
-
785
- interface VideoFrames {
786
- readonly width: number; readonly height: number; readonly format: VideoFormat;
787
- /** this backend's order of preference, for a decoder that can choose */
788
- readonly preferredFormats: readonly VideoFormat[];
789
- push(planes: Buffer | readonly Buffer[], opts?: { strides?: number[]; time?: number }): void;
790
- close(): void;
791
- }
792
-
793
- useVideoFrames(options): VideoFrames // stable across renders; new when size, format or colour changes; nothing to close
794
- ```
795
-
796
- Three rules, each of which is a cost somewhere:
797
-
798
- - **The newest frame is the frame.** A `push` while the last one is not yet
799
- shown replaces it; nothing queues. A sink is a mirror of a decoder's
800
- output, not a transport with back-pressure, and the decoder's own clock
801
- is the pacing — exactly as a `<canvas>` animation's is.
802
- - **Every format is accepted everywhere; the cheap one differs.** A decoder
803
- that can choose reads `preferredFormats`; one that cannot still works,
804
- with the conversion charged where it happens:
805
-
806
- | backend | lifted | drawn | `preferredFormats` |
807
- | ------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------- |
808
- | Cocoa | NV12 as is; I420 interleaved on the write (bridge); BGRA as is | BGRA; NV12/I420 converted in the bridge by VideoToolbox, colour-matched to sRGB, about 1ms at 1080p | `NV12`, `I420`, `BGRA` |
809
- | X11 | — | BGRA into an ntk `Surface` (SHM where the server has it); NV12/I420 converted in JS, which is not free at 1080p | `BGRA` |
810
- | win32 | later: an NV12 swapchain on a visual (§4.2) | BGRA into a texture; NV12/I420 through the video processor, or in JS until then | `BGRA`, then `NV12` |
811
- | Wayland | — | NV12, I420 and BGRA as textures; YUV sampled in the shader, no conversion anywhere | `NV12`, `I420`, `BGRA` |
812
-
813
- - **`push` is not a frame.** On a lifted sink it writes a surface from the
814
- ring and repoints the layer in a transaction of its own, and react-x11's
815
- frame clock does not run — 0.4ms of CPU at 1080p (§3.4.1) and no claim
816
- for the [pacer](frame-pacing.md) to price. On a drawn sink it claims the
817
- element's content rect, bounded, and the next frame composites the new
818
- bytes like an `<image>` whose `src` changed — priced like any claim, and
819
- the first thing a profile of an X11 player shows.
820
-
821
- Where the bytes come from is the application's business (§10), and the
822
- sink's design keeps it that way: an `ffmpeg` child process writing
823
- `rawvideo` to a pipe, a WASM decoder, a native addon over libmpv or VA-API
824
- all end in the same `push`.
825
-
826
- ## 7. The seam: a sprite whose content is a source
827
-
828
- Sprites ([extending.md](../extending.md#parts-of-your-drawing-on-layers-of-their-own),
829
- `src/nodes/sprites.js`, `src/cocoa/sprites.js`) are how an element hands
830
- parts of its drawing to the surface presenter, which lifts each onto a
831
- `CALayer` above the window's bitmap under promotion's rules: asked every
832
- frame after layout and before the damage is taken; refused when anything
833
- painted after the element reaches into the part, when the element or an
834
- ancestor fades, when a rounded clipping ancestor does not hold all of it,
835
- when it sits inside a `<glarea>`, or when a side exceeds 8192 device
836
- pixels; cut by a box layer with `masksToBounds` to every square clipping
837
- ancestor; a hole in the element's own paint from the frame `spritesLifted`
838
- says so; the bitmap under it claimed on the way onto a layer and off it.
839
- Today a part's content is always a raster: the presenter calls the part's
840
- `paint` into a surface of its own and hands that surface to the layer, and
841
- paints again when the `version` changes.
842
-
843
- A video is a part whose content the presenter does **not** paint:
844
-
845
- ```js
846
- sprites() {
847
- if (!this.source || !this.frameRect) return null;
848
- return [{ key: 'video', rect: this.frameRect, clip: this.contentBox(), contents: this.source }];
849
- }
850
- ```
851
-
852
- - `contents` is a `VideoFrames` sink, or the player a `<video src>` made.
853
- A part with `contents` has no `paint` and no `version`, and for now no
854
- `animations` — nothing in §8 wants a video that fades on the render
855
- server, and a fade on the element itself is a refusal, as it is for
856
- every sprite.
857
- - **Lifted**, the layer's content is the source's: for a sink, the ring
858
- surface `push` last wrote, repointed by `push` itself between frames
859
- (§6.3); for a player, the `AVPlayerLayer` the bridge made, added under
860
- the clip box at the part's place in the one order every layer above the
861
- bitmap shares. The element paints its bars and leaves the hole.
862
- - **Declined**, the element draws the source's current frame itself:
863
- `source.draw(ctx, rect)`, a `drawImage` of the sink's BGRA surface, or
864
- of the frame the bridge pulls from the player on request
865
- (`AVPlayerItemVideoOutput`, one verb, lazily made, §9). Every reason a
866
- sprite is declined is a reason a video is drawn for a while — a tooltip
867
- over it, a dialog's dim, a rounded card that does not hold it — and
868
- since declining is decided per frame, the cost lasts exactly as long as
869
- the reason.
870
- - **Everything else is promotion's, unchanged**: the z-order test asked
871
- at the part's extent, the clip boxes, the claim of the bitmap under it,
872
- the one order with the promoted nodes. A `contents` part makes
873
- `SpriteLayers` skip its raster and ask the source for a layer or a
874
- surface, and that is the whole change on the presenter's side.
875
-
876
- Why not a child window or layer of the element's own, the way `<glarea>`
877
- has one: because a `<glarea>`'s layer sits at a fixed `zPosition` above
878
- everything the presenter draws, is never clipped by an ancestor, and
879
- cannot be drawn over — `<MediaPlayer>`'s own documentation explains that
880
- its transport bar cannot be a sibling `<box>` for exactly this reason. A
881
- video in a form is content, and content lives in the paint order. The
882
- sprite seam is the one that lets a thing be on a layer when it can and in
883
- the bitmap when it must, and that is the property this element needs
884
- most.
885
-
886
- Why not `Node._promoted`, promotion's own hole: because promotion lifts a
887
- plain `<box>` for an animation it can express, and re-rasters the node's
888
- children into the layer itself. A video's content is nobody's raster.
889
-
890
- And why `<video>` is the first implementer and not only a consumer: the
891
- element's one part is itself, which is the simplest possible sprite — and
892
- it proves the `contents` kind for any registered element that wants to
893
- show a live source inside its own drawing, a chart with a camera feed in
894
- its corner being the obvious one. The **X11, Wayland and win32 presenters
895
- never ask**, so the same element draws itself there as it always would.
896
-
897
- ## 8. The two consumers
898
-
899
- ### 8.1 A `<video>` in a form
900
-
901
- The element of §6, with nothing between the application and it. On macOS,
902
- `<video src>` with the player verbs: no JS in the frame path, audio and
903
- seeking from AVFoundation, lifted under the form's own popups and tooltips
904
- and drawn for the frames a dialog dims it. On X11, `useSupports` says no to
905
- `src`, and the application decodes into `frames` — the ecosystem's job to
906
- make that one line, below.
907
-
908
- ### 8.2 A `<video>` inside `<Html>`
909
-
910
- `@react-x11/components`' `<Html>` is one registered element, `<htmlview>`,
911
- that parses, lays out and paints a document into the window's own context.
912
- Its `<video>` today is its `poster` as an image box, or a 300×150 frame
913
- with nothing in it; its `<audio>` is nothing at all; and it has no hook by
914
- which a host supplies a renderer for a tag. It lifts a CSS animation onto
915
- a layer through the sprite seam (#620), and it mounts **real core widgets
916
- beside the document** for form controls — a `<textinput>` at an `<input>`'s
917
- rect, cut to the box's clip and faded by its opacity, reported through
918
- `onControls` — on the rule its source states: _a drawn control is a picture
919
- of a control_.
920
-
921
- A drawn video is a picture of a video, and the same rule gives the same
922
- answer. `<Html>` reports its media boxes the way it reports its controls —
923
- rect, clip, opacity, and the attributes `src`, `poster`, `autoplay`,
924
- `loop`, `muted` — and its React half mounts a core `<video>` beside the
925
- document at each, absolutely positioned and clipped exactly as a widget is.
926
- Everything the element knows applies: it lifts on macOS when its part is
927
- clear, it draws when it is not, it scrolls with the pane because its `abs`
928
- does, and `src` refuses where `useSupports` says so, which `<Html>` turns
929
- into the poster it shows today. `onResource` grows a `kind: 'video'` so a
930
- host that has a decoder can answer a URL with a `frames` sink instead,
931
- and a host that has none declines and gets the poster.
932
-
933
- What `<Html>` decides for itself, and should say in its docs: a video box
934
- that something later in the document paints over — an overlay of controls,
935
- a caption positioned across it — is declined and stays a poster, the same
936
- `paintedAfter` rule its sprites already apply, because a sibling mounted
937
- above the document would hide the overlay. HTML's `controls` attribute is
938
- not drawn by `<Html>`: the transport bar an application wants is its own
939
- React, over or under the box, the position `<MediaPlayer>`'s docs already
940
- take. And `<audio>` is not this element: a sound with no box is a player
941
- with no presentation, which is a different feature and a smaller one.
942
-
943
- ### 8.3 `<MediaPlayer>`, and where a decoder comes from
944
-
945
- `<MediaPlayer>` embeds mpv or VLC over `<foreign>`, drives it over mpv's
946
- JSON IPC, and is X11-only because `--wid` is an X mechanism; on macOS it
947
- reports `unavailable`. It keeps doing that: an application that already
948
- has mpv and an X display has a player with audio, subtitles and every
949
- codec, and nothing here replaces it. What changes is its ceiling. Its
950
- window stacks above everything drawn, so it cannot be scrolled under a
951
- header, dimmed by a dialog or overlaid by a transport bar — and a backend
952
- of it that decodes into a `<video frames>` sink instead of a window has
953
- none of those limits, on every backend, at the cost of the audio path and
954
- the sink's bytes. The shape of that backend is the ecosystem's decision,
955
- not this record's; the two candidates are an `ffmpeg` child process
956
- writing `rawvideo` to a pipe (pure JS, silent, every backend), and a native
957
- addon over libmpv's software render API (audio, seeking, subtitles, every
958
- backend, one prebuilt binary). Either one makes `<MediaPlayer>` the thing
959
- a React developer reaches for and `<video>` the primitive it stands on,
960
- which is the right order.
961
-
962
- ### 8.4 Camera, later
963
-
964
- The same `frames` shape with the bytes from a capture session — on macOS
965
- the `2vuy` surfaces §3.5 measured, as layer contents or as bytes — behind
966
- a `createCameraFrames({ deviceId })` that owns device selection and the
967
- session's lifetime. The permission half needs nothing new. A separate
968
- piece of work, after this, on the same element.
969
-
970
- ## 9. Where the code goes
971
-
972
- | piece | where |
973
- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
974
- | the element | `src/nodes/video.js` beside `image.js`, the kind in `src/nodes/kinds.js` and `src/Reconciler.js`, `VideoProps` in `src/types/elements.d.ts`, a line in `test/types/api.tsx`; `objectFit` in `src/styles.js` for `<video>` and `<image>` both |
975
- | the sink | `src/videoframes.js` — pure: formats, the ring, the conversion policy, `preferredFormats` — with a presentation per backend behind it: ntk `Surface` on X11, `src/cocoa/video.js`, `src/win32/video.js`, `src/wayland/video.js` |
976
- | the sprite kind | the `contents` field in `src/nodes/sprites.js`'s contract and `src/node.d.ts`; `src/cocoa/sprites.js` skips the raster for it; `src/cocoa/promotion.js` unchanged |
977
- | the capability | `mediaPlayback` in `src/appcontext.js`'s `FEATURES` |
978
- | the player, Cocoa | `src/cocoa/player.js` over the verbs below, in the shape of `src/cocoa/glarea.js` |
979
- | the player, win32 | `src/win32/player.js` over `IMFMediaEngine`, after §4.3 |
980
- | bridge verbs, `@windowkit/appkit` | release A, the sink: `createVideoSurface({ width, height, format, colorSpace, range })` → `{ handle, iosurfaceId }` with the colour attachments set; `writeVideoSurface(handle, planes)`, which interleaves I420 into NV12; an optional `videoSurfacePlanes(handle)` answering `Buffer`s over the locked planes so a pipe read lands in the surface with no copy at all. Reuse, unchanged: `setLayerContentsIOSurface`, `surfaceIsInUse`, `surface-released`, `releaseSurface`. Release B, the player: `createPlayer({ url })`, `playerSet(id, { rate, volume, muted, loop })`, `playerSeek(id, seconds)`, `playerLayer(id)` answering a layer handle `addSublayer` takes, `playerCopyFrame(id)` answering a BGRA surface for the drawn presentation, `releasePlayer(id)`; events `player-metadata`, `player-time`, `player-rate`, `player-ended`, `player-error`. Release C, capture, later |
981
- | bridge verbs, `@windowkit/win32` | after §4.3: `mediaCreate`, `mediaSet`, `mediaSeek`, `mediaFrame` (frame-server), `mediaRelease` and the engine's events; later `mediaAttach` onto a visual, over the pane host's `CreateSurfaceFromHandle` |
982
- | Xv | a thin wrapper in ntk over node-x11's requests, port and format negotiation, `ShmPutImage` as the HD path, a clean decline where `QueryAdaptors` answers none |
983
- | docs | a `<video>` section in [elements.md](../elements.md); the ladder in [macos.md](../macos.md) and [windows.md](../windows.md); the `contents` part in [extending.md](../extending.md); this file is the record they point at |
984
- | tests | `test/video.test.js` — pixels, fit, the poster, the refusal — on the mock and the in-process X server; `test/cocoa-video.test.js` over `test/helpers/cocoa-bridge.js` for which verbs a lift and a decline send; a real-bridge check through `snapshotWindow`; a sink scenario in `scripts/bench/protocol.js` and a rule in `presenters-gate.json` that a lifted sink paints no window frame per pushed frame |
985
- | spikes | `scripts/spikes/video/` — `yuvscan.swift` and `sinkcost.swift`, this PR |
986
-
987
- ## 10. What is not here, and why
988
-
989
- - **A decoder.** react-x11 will not ship one. On Cocoa and Windows `src`
990
- does not need one; everywhere, `frames` takes what the application
991
- decodes. Bundling ffmpeg would be the largest dependency in the project
992
- by an order of magnitude, and would land on every user of a toolkit most
993
- of whom want a button. §8.3 is where a decoder belongs.
994
- - **Audio for `frames`, and therefore A/V sync.** A sink is silent frames,
995
- on every backend, and says so. `src` has audio wherever it has a player.
996
- A `frames` source that also carries audio is the libmpv shape of §8.3,
997
- and its sync is the decoder's.
998
- - **Seeking, buffering, HLS, DRM.** All the platform player's where there
999
- is one and entirely absent otherwise. Anything the element exposes that
1000
- only works with `src` is documented as `src`-only, in the way
1001
- [macos.md](../macos.md) documents the rest of that backend's asymmetries.
1002
- - **A compressed-stream source.** `AVSampleBufferDisplayLayer` and its
1003
- synchroniser are the right rung for one, no consumer has asked, and the
1004
- verb count would double. When one does, it is a third source prop on the
1005
- same element and the same sprite.
1006
- - **`<audio>`.** A player with no box is not this element.
1007
- - **Fullscreen, picture-in-picture, the lock screen's now-playing.** Window
1008
- state is `useWindowState()`'s; the rest is desktop integration with its
1009
- own ladder.
1010
- - **Reading a lifted video's pixels through the X path.** Like `<glarea>`,
1011
- a lifted video is not in the window's bitmap. On macOS `snapshotWindow`
1012
- sees it (§3.4); on X11 there is nothing lifted to miss.
1013
-
1014
- ## 11. Decisions the implementing PRs have to make, and the suggested answer
1015
-
1016
- | question | suggested answer |
1017
- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1018
- | the seam | a sprite part with `contents` (§7), not a new accessor and not a child window; `<video>` offers itself as its one part |
1019
- | the element's primitive | `frames`, a sink, on every backend; `src` is the convenience of the backends with a player |
1020
- | `src` where there is no player | one typed `onError`, the poster stays, `useSupports('mediaPlayback')` to ask first — never a black box |
1021
- | the sink's native format | NV12 on macOS (two planes render, three do not), BGRA on X11 and win32 until their fast paths exist, anything on Wayland; published as `preferredFormats` |
1022
- | pixel format on Cocoa | YUV through a plain layer — **not** BGRA, measured at 2.1× the decode on playback and 2.67× the process CPU on capture for the same picture — and not an AV layer class for a sink, which §3.4 made unnecessary |
1023
- | colour | the sink names `colorSpace` and `range`; the bridge attaches them to the surface; the defaults are BT.709 video range, and a test pins that the bare and the attached surface differ, since §3.4 saw them differ |
1024
- | bridge verbs, Cocoa | two for the sink, six and five events for the player, in two releases (§9); the sink first, because it ships the whole element on macOS without AV |
1025
- | the frame clock | a lifted sink's `push` is not a react-x11 frame and is not priced (§6.3); a drawn sink's is, exactly like any other claim; a player's layer costs the frame nothing |
1026
- | the hole | the element's, through `spritesLifted`, as for every sprite; the bars are painted always, the picture only when drawn |
1027
- | rounded video corners | legal, and the drawn presentation; say so beside the prop rather than mask a layer that cannot be |
1028
- | X11 v1 | BGRA frames into an ntk `Surface` with SHM; Xv second, into the backing pixmap if ntk will name it; DRI3 import as the real answer |
1029
- | Xv fallback | mandatory: `QueryAdaptors` answering none is every Mac, and the drawn path is the decline |
1030
- | win32 v1 | `IMFMediaEngine` in frame-server mode, drawn in paint order; the swapchain visual waits for the Windows half of promotion |
1031
- | tests | the fake bridge for what reaches the natives; `snapshotWindow` for pixels on Cocoa, never `render(in:)`; on X11 that the element paints the same with and without SHM; on every backend that a declined video and a lifted one show the same pixels |
1032
- | `<Html>` | mounts a core `<video>` beside the document, as it mounts a `<textinput>`; declines a box something paints over; grows `onResource` by `kind: 'video'` |
1033
- | camera | a `frames` source with device selection and a session, later, on the same element |
1034
-
1035
- ## 12. What can be built now, and in what order
1036
-
1037
- Five tracks. The first ships on every backend with no bridge release and
1038
- no upstream; the next two are two `@windowkit/appkit` releases; the fourth
1039
- waits for a Windows machine; the fifth is the ecosystem's.
1040
-
1041
- 1. **Core, now.** `VideoFrames`, `<video frames>` drawn through an ntk
1042
- `Surface` on X11 and the shared 2D dialect on Cocoa and win32, NV12 and
1043
- I420 converted to BGRA where a backend cannot take them, `objectFit`
1044
- for `<video>` and `<image>`, `poster`, the typed refusal of `src`,
1045
- `useSupports('mediaPlayback')` answering false everywhere, the `contents`
1046
- sprite kind in `src/cocoa/sprites.js` behind a feature test for the
1047
- verbs that do not exist yet. Pixel tests on the mock and the in-process
1048
- server; a 720p sink scenario in the protocol bench, which is what makes
1049
- the SHM path's effect visible. The whole element is usable from this
1050
- step: an application with `ffmpeg` on its `PATH` plays a file on X11
1051
- and on macOS in forty lines, silently, at the drawn cost.
1052
- 2. **`@windowkit/appkit` release A — the sink's two verbs** (§9). Then the
1053
- Cocoa presenter lifts a `<video frames>` and a 1080p60 feed costs the
1054
- process 25ms of CPU a second (§3.4.1) and react-x11 no frames at all.
1055
- The acceptance test is the gate rule: a lifted sink paints no window
1056
- frame per pushed frame, over the fake bridge; and a real-bridge
1057
- `snapshotWindow` that shows the pushed colour.
1058
- 3. **`@windowkit/appkit` release B — the player.** `<video src>` on macOS
1059
- with audio, seeking and HLS from AVFoundation; `mediaPlayback` true
1060
- there; the drawn presentation through `playerCopyFrame` so a dialog's
1061
- dim or a rounded card still shows the picture. The first `<video>` a
1062
- form developer meets is this one.
1063
- 4. **Windows, on the Windows machine.** §4.3's probes, then `src` through
1064
- `IMFMediaEngine` in frame-server mode and `frames` through a dynamic
1065
- texture, both drawn; the swapchain visual and the NV12 swapchain when
1066
- the backend has the Windows half of promotion to decide them.
1067
- 5. **`@react-x11/components`.** `<Html>` reports media boxes and mounts
1068
- `<video>`s; `onResource` learns `video`; `<MediaPlayer>` keeps mpv and
1069
- VLC and grows a `frames` backend — `ffmpeg` first, since it is pure JS
1070
- and silent is still a video, libmpv when someone wants audio on macOS.
1071
-
1072
- After those, in order of value: the Xv rung on X11 (into the backing
1073
- pixmap, if ntk will name it), the camera source, DRI3 import on X11 and
1074
- a dma-buf subsurface on Wayland for the zero-copy decoders, and a
1075
- compressed-stream source over `AVSampleBufferDisplayLayer` when a consumer
1076
- appears.
1077
-
1078
- ## 13. The spikes
1079
-
1080
- Every number above came from a program that uses the platform directly —
1081
- no bridge, no renderer, no React — so that what it measures is the
1082
- platform and not our use of it.
1083
-
1084
- **`scripts/spikes/video/yuvscan.swift`** (2026-10-02) answers §3.4's
1085
- on-screen question. It builds eight `CALayer`s in a borderless window:
1086
- BGRA and `CGImage` controls, `420v`, `420f`, `2vuy`, `y420` and `f420`
1087
- surfaces made through `CVPixelBufferCreate` with IOSurface backing and
1088
- filled with one YCbCr colour per plane, an `AVSampleBufferDisplayLayer` fed
1089
- thirty H.264 frames a `VTCompressionSession` encodes from solid red, and an
1090
- `AVPlayerLayer` playing a `.mov` an `AVAssetWriter` writes from solid
1091
- magenta. Two seconds in it captures the window through
1092
- `screencapture -l <windowNumber>` — the window server's composite, the
1093
- same source as `CGWindowListCreateImage` and therefore as the bridge's
1094
- `snapshotWindow`, and the only capture a Swift program on this SDK is
1095
- still allowed — and prints the centre pixel of each tile. Build and run
1096
- with `swiftc -O -swift-version 5 -o yuvscan yuvscan.swift && ./yuvscan`;
1097
- a capture that comes back empty is the Screen Recording permission, not
1098
- the layer. The controls are the part that makes the YUV rows meaningful.
1099
-
1100
- **`scripts/spikes/video/sinkcost.swift`** (2026-10-02) answers §3.4.1. It
1101
- rotates three 1920×1080 surfaces, copies a prepared frame into each
1102
- plane of the next one, repoints a layer at it inside a transaction, and
1103
- paces to 60Hz; `getrusage` around 240 iterations gives the process CPU per
1104
- frame, for NV12 and BGRA in turn, twice each. The `memcpy` stands in for
1105
- the read from a decoder; a verb that answers `Buffer`s over the locked
1106
- planes removes even that.
1107
-
1108
- The **2026-09-08 spikes** behind §2.5, §3.1–3.3 and §3.5 are described
1109
- there and were not committed; their shapes: encode synthetic frames with a
1110
- `VTCompressionSession` to get a real stream with parameter sets, decode
1111
- them back with and without BGRA forced, time each frame, check
1112
- `CVPixelBufferGetIOSurface`, assign the surface to a layer two thousand
1113
- times for the flip cost, probe rasterization with `render(in:)` against
1114
- controls; push a 1920×1080 depth-24 image to an offscreen pixmap with core
1115
- `PutImage` and a `GetInputFocus` round trip after each; run an
1116
- `AVCaptureSession` at 1280×720 for 60 frames twice, native and BGRA, with
1117
- `getrusage` around each run, from a bundle launched through
1118
- LaunchServices so that the authorization request prompts, keeping nothing
1119
- of what the camera saw.
1120
-
1121
- ## 14. Verdict
1122
-
1123
- The Cocoa half got smaller in the month since this was first written, and
1124
- for a good reason: the thing it was designed around turned out not to be
1125
- there. A plain layer shows a YUV surface on screen, the seam that lifts
1126
- part of an element onto a layer has shipped and been tested on something
1127
- else, and the bridge can already address an IOSurface by id. What is left
1128
- for a frame sink on macOS is two verbs and a field on a sprite; what is
1129
- left for a URL is the player verbs, which are the first AVFoundation the
1130
- bridge will link for anything but a permission prompt.
1131
-
1132
- The X11 half is exactly as large as it was, and this record's main
1133
- service there is still to say so before someone spends a week on it: the
1134
- transport that exists in the protocol is capped near 30fps at 1080p, the
1135
- one that would fix it is now installed and still absent on every Mac, and
1136
- the one that is right is the largest piece of work here. A sink the
1137
- application feeds, drawn through the surface every retained-surface
1138
- element already draws through, is honest, is small, and is the version of
1139
- this that ships on four backends at once.
1140
-
1141
- Windows has every piece a player needs except a decision about stacking,
1142
- which is promotion's to make there as it was on macOS, and a drawn
1143
- presentation that needs no such decision. Wayland is a rasterizer, so its
1144
- sink is a shader.
1145
-
1146
- The thing not to build is still the API that hides the difference — and
1147
- the thing not to build twice is the element: a video inside a document is
1148
- the same `<video>` as one in a form, mounted where the document says it
1149
- goes.