react-native-skia-webgpu-video 0.1.0-beta.3 → 0.1.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 (81) hide show
  1. package/README.md +48 -43
  2. package/android/BUILD_VALIDATION.md +1 -1
  3. package/android/CMakeLists.txt +3 -2
  4. package/android/cpp/ExportHintSession.cpp +114 -0
  5. package/android/cpp/ExportHintSession.h +35 -0
  6. package/android/cpp/VideoEncoderHostObject.cpp +76 -101
  7. package/android/cpp/VideoEncoderHostObject.h +14 -9
  8. package/android/cpp/VideoFrame.cpp +3 -5
  9. package/android/cpp/cpp-adapter.cpp +7 -4
  10. package/android/src/main/java/com/azzapp/rnskv/GLFrameExtractor.java +3 -9
  11. package/android/src/main/java/com/azzapp/rnskv/VideoCompositionDecoder.java +8 -11
  12. package/android/src/main/java/com/azzapp/rnskv/VideoEncoder.java +68 -28
  13. package/docs/FRAME_PIXEL_FORMAT.md +17 -2
  14. package/docs/MIGRATION.md +1 -1
  15. package/docs/UPSTREAM_NOTES.md +42 -2
  16. package/ios/RNSVColorSpace.h +8 -3
  17. package/lib/module/canvas.js +3 -5
  18. package/lib/module/canvas.js.map +1 -1
  19. package/lib/module/exportFrames.js +8 -10
  20. package/lib/module/exportFrames.js.map +1 -1
  21. package/lib/module/exportTarget.js +183 -40
  22. package/lib/module/exportTarget.js.map +1 -1
  23. package/lib/module/exportVideoComposition.js +78 -35
  24. package/lib/module/exportVideoComposition.js.map +1 -1
  25. package/lib/module/frameInterop.js +323 -82
  26. package/lib/module/frameInterop.js.map +1 -1
  27. package/lib/module/gpu.js +1 -3
  28. package/lib/module/gpu.js.map +1 -1
  29. package/lib/module/gpuDevice.js +12 -0
  30. package/lib/module/gpuDevice.js.map +1 -1
  31. package/lib/module/memory.js +7 -0
  32. package/lib/module/memory.js.map +1 -1
  33. package/lib/module/previewLifecycle.js +2 -1
  34. package/lib/module/previewLifecycle.js.map +1 -1
  35. package/lib/module/videoCompositionPlayer.js +65 -14
  36. package/lib/module/videoCompositionPlayer.js.map +1 -1
  37. package/lib/module/videoFrameImage.js +1 -1
  38. package/lib/module/yuvBlit.js +31 -24
  39. package/lib/module/yuvBlit.js.map +1 -1
  40. package/lib/typescript/src/canvas.d.ts.map +1 -1
  41. package/lib/typescript/src/exportFrames.d.ts +3 -2
  42. package/lib/typescript/src/exportFrames.d.ts.map +1 -1
  43. package/lib/typescript/src/exportTarget.d.ts +53 -14
  44. package/lib/typescript/src/exportTarget.d.ts.map +1 -1
  45. package/lib/typescript/src/exportVideoComposition.d.ts +2 -1
  46. package/lib/typescript/src/exportVideoComposition.d.ts.map +1 -1
  47. package/lib/typescript/src/frameInterop.d.ts +16 -5
  48. package/lib/typescript/src/frameInterop.d.ts.map +1 -1
  49. package/lib/typescript/src/gpu.d.ts.map +1 -1
  50. package/lib/typescript/src/gpuDevice.d.ts +5 -1
  51. package/lib/typescript/src/gpuDevice.d.ts.map +1 -1
  52. package/lib/typescript/src/memory.d.ts +2 -0
  53. package/lib/typescript/src/memory.d.ts.map +1 -1
  54. package/lib/typescript/src/previewLifecycle.d.ts +2 -0
  55. package/lib/typescript/src/previewLifecycle.d.ts.map +1 -1
  56. package/lib/typescript/src/types.d.ts +18 -12
  57. package/lib/typescript/src/types.d.ts.map +1 -1
  58. package/lib/typescript/src/videoCompositionPlayer.d.ts +4 -5
  59. package/lib/typescript/src/videoCompositionPlayer.d.ts.map +1 -1
  60. package/lib/typescript/src/videoFrameImage.d.ts +1 -1
  61. package/lib/typescript/src/yuvBlit.d.ts +16 -14
  62. package/lib/typescript/src/yuvBlit.d.ts.map +1 -1
  63. package/package.json +6 -6
  64. package/patches/react-native-skia+3.10.0.patch +150 -0
  65. package/scripts/skia-canvas-patch.json +22 -258
  66. package/src/canvas.ts +3 -7
  67. package/src/exportFrames.ts +10 -11
  68. package/src/exportTarget.ts +217 -56
  69. package/src/exportVideoComposition.ts +130 -85
  70. package/src/frameInterop.ts +389 -96
  71. package/src/gpu.ts +1 -3
  72. package/src/gpuDevice.ts +26 -1
  73. package/src/memory.ts +6 -0
  74. package/src/previewLifecycle.ts +4 -1
  75. package/src/types.ts +18 -12
  76. package/src/videoCompositionPlayer.ts +76 -23
  77. package/src/videoFrameImage.ts +1 -1
  78. package/src/yuvBlit.ts +46 -31
  79. package/android/cpp/EncoderTargets.cpp +0 -226
  80. package/android/cpp/EncoderTargets.h +0 -70
  81. package/patches/react-native-skia+3.7.1.patch +0 -502
package/README.md CHANGED
@@ -4,10 +4,10 @@
4
4
 
5
5
  <p align="center">
6
6
  <a href="https://github.com/batical/react-native-skia-webgpu-video/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/batical/react-native-skia-webgpu-video/actions/workflows/ci.yml/badge.svg?branch=main" /></a>
7
- <a href="https://www.npmjs.com/package/react-native-skia-webgpu-video"><img alt="npm" src="https://img.shields.io/npm/v/react-native-skia-webgpu-video/beta" /></a>
8
- <a href="#status"><img alt="Status: beta" src="https://img.shields.io/badge/status-beta-f59e0b" /></a>
9
- <img alt="React Native Skia 3.7.1" src="https://img.shields.io/badge/Skia-3.7.1-38bdf8" />
10
- <img alt="React Native WebGPU 0.17.0" src="https://img.shields.io/badge/WebGPU-0.17.0-a78bfa" />
7
+ <a href="https://www.npmjs.com/package/react-native-skia-webgpu-video"><img alt="npm" src="https://img.shields.io/npm/v/react-native-skia-webgpu-video" /></a>
8
+ <a href="#status"><img alt="Status: stable" src="https://img.shields.io/badge/status-stable-22c55e" /></a>
9
+ <img alt="React Native Skia 3.10.0" src="https://img.shields.io/badge/Skia-3.10.0-38bdf8" />
10
+ <img alt="React Native WebGPU 0.21.3" src="https://img.shields.io/badge/WebGPU-0.21.3-a78bfa" />
11
11
  <a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-34d399" /></a>
12
12
  </p>
13
13
 
@@ -27,7 +27,7 @@ A new Skia 3 / WebGPU frame-interop layer, written from scratch with explicit re
27
27
  - **Audio compositions** with clip audio, separate tracks, and per-item volume.
28
28
  - **Custom GPU processing** through an export frame processor and an optional WebGPU resource scope.
29
29
 
30
- > **Beta.** The Skia 3.7.0 / WebGPU 0.17.0 build has run on an iPhone 15 Pro and a Pixel 8a (export speed, pixel checks); 3.7.1, which only changes iOS presentation on ProMotion displays, passed the native tests, smoke runs and the Pixel pixel check. Not yet qualified: memory over long sessions (last checked on 3.0.6), HDR, audible output and A/V synchronization; two 4K cases have known issues. See [status](#status) before adopting this in production.
30
+ > **0.1.0.** The Skia 3.9.1 / WebGPU 0.21.0 build has run on an iPhone 15 Pro and a Pixel 8a: export speed, preview frame accuracy, pixel and color checks, native and instrumented tests, smoke runs. Not yet qualified: memory over long sessions (last checked on 3.0.6), HDR, audible output and A/V synchronization. A few 4K cases have known issues. See [status](#status) before adopting this in production.
31
31
 
32
32
  ## Installation
33
33
 
@@ -37,8 +37,8 @@ A new Skia 3 / WebGPU frame-interop layer, written from scratch with explicit re
37
37
  | --- | --- |
38
38
  | React Native | `0.86.2` tested; declared peer range `>=0.83 <0.87` |
39
39
  | React | `19.2.3` tested |
40
- | React Native Skia | **`react-native-skia@3.7.1`** |
41
- | React Native WebGPU | **`react-native-webgpu@0.17.0`** (both resolve `react-native-webgpu-dawn@157.0.0`) |
40
+ | React Native Skia | **`react-native-skia@3.10.0`** |
41
+ | React Native WebGPU | **`react-native-webgpu@0.21.3`** (both resolve `react-native-webgpu-dawn@158.0.0`) |
42
42
  | Reanimated / Worklets | **`4.5.3` / `0.11.3`** |
43
43
  | Native runtime | New Architecture and Hermes |
44
44
  | Android | API **28+**, Vulkan 1.1 (declared as a required feature by Skia) |
@@ -49,11 +49,9 @@ On ProMotion iPhones, add `CADisableMinimumFrameDurationOnPhone` (Boolean, `YES`
49
49
 
50
50
  ### Install
51
51
 
52
- The beta is published on npm under the `beta` tag:
53
-
54
52
  ```sh
55
- npm install --save-exact react-native-skia@3.7.1 react-native-webgpu@0.17.0 react-native-reanimated@4.5.3 react-native-worklets@0.11.3
56
- npm install --save-exact react-native-skia-webgpu-video@beta
53
+ npm install --save-exact react-native-skia@3.10.0 react-native-webgpu@0.21.3 react-native-reanimated@4.5.3 react-native-worklets@0.11.3
54
+ npm install --save-exact react-native-skia-webgpu-video
57
55
  ```
58
56
 
59
57
  Each version requires exact Skia and WebGPU versions (its peer dependencies). The [GitHub releases](https://github.com/batical/react-native-skia-webgpu-video/releases) attach the same package archive, installable by URL. To build it from source instead, run `npm ci`, `npm run build` and `npm pack` in a clone, then `npm install` the resulting `.tgz`.
@@ -205,27 +203,27 @@ This budget covers **tracked video resources**, not all memory held by codecs, S
205
203
 
206
204
  | Step | iOS | Android |
207
205
  | --- | --- | --- |
208
- | Decode | AVFoundation, IOSurface-backed buffers, no extra copy (`alwaysCopiesSampleData = NO`): the decoder's NV12 (`framePixelFormat: 'yuv'`, the default), or BGRA converted by VideoToolbox (`'rgba'`) | API 29+, 8-bit SDR H.264/HEVC at the encoded size: `ImageReader` YUV buffers (the default). Otherwise, or with `'rgba'`: EGL render into an RGBA AHardwareBuffer (or a CPU scoped-copy fallback) |
209
- | Into Skia, preview | NV12: a YUV→RGB render pass into a reusable texture. BGRA: a WebGPU blit into it. Then an owned Graphite snapshot | WebGPU blit (YUV→RGB, crop and rotation in one pass), then an owned Graphite snapshot |
206
+ | Decode | AVFoundation, IOSurface-backed buffers, no extra copy (`alwaysCopiesSampleData = NO`): the decoder's NV12 (`framePixelFormat: 'yuv'`, the default), or BGRA converted by VideoToolbox (`'rgba'`) | API 29+, 8-bit SDR H.264/HEVC: `ImageReader` YUV buffers (the default), at any frame size, capped or explicit sizes included. Otherwise, or with `'rgba'`: EGL render into an RGBA AHardwareBuffer (or a CPU scoped-copy fallback) |
207
+ | Into Skia, preview | NV12: a YUV→RGB render pass into a texture of the frame's producer. BGRA: a WebGPU blit into it. The Skia image wraps that texture, without a copy | WebGPU blit (YUV→RGB, crop, rotation and scaling to the frame's size in one pass) into a texture of the frame's producer, which the Skia image wraps without a copy |
210
208
  | Into Skia, export | NV12: converted into a per-clip texture, as on Android. BGRA: sampled in place through the frame's IOSurface, no copy | Same as preview |
211
- | Export output | Skia draws into the encoder's CVPixelBuffer (IOSurface) | Skia draws into an AHardwareBuffer lent by the encoder; EGL samples it into the codec Surface after its sync-fd |
209
+ | Export output | Skia draws into the encoder's CVPixelBuffer (IOSurface) | Skia draws into one of two textures of the library's, which a GPU copy puts into the codec's input Surface, through a WebGPU canvas context on it (Vulkan swapchain): no EGL. Before each present, the frame time is set on the window and its dataspace reset, so the codec writes Rec.709 limited range as it did with EGL |
212
210
  | Fallback | CPU readback, with a warning | CPU readback, with a warning |
213
211
 
214
- Every path waits for checked GPU completion before releasing source frames or handing a buffer to the encoder. Scaled, HDR and 10-bit Android sources keep the EGL decode path.
212
+ Every path gives source frames back only once the GPU is done with them: exports wait for checked GPU completion; the preview submits its draws and imports without waiting on the UI thread, and hands decoder buffers back when the GPU reports them done (waiting instead when the memory budget cannot hold them). Android's export waits for a frame's GPU work once it has decoded the next frame, so the GPU draws one frame while the CPU decodes the next, and it reports each frame's duration to Android's performance hints (ADPF, Android 13 and later), which raise the CPU and, from Android 15, the GPU capacity the export gets. On Android, OpenGL remains in two places: the CPU readback fallback draws into the codec Surface through EGL, and HDR and 10-bit sources (or `framePixelFormat: 'rgba'`) keep the EGL decode path.
215
213
 
216
214
  ### Frame pixel format
217
215
 
218
216
  `framePixelFormat` chooses how a composition's frames are decoded, for its preview and its export:
219
217
 
220
218
  ```ts
221
- const composition = { duration, items, framePixelFormat: 'yuv' }; // 'auto' (default) | 'yuv' | 'rgba'
219
+ const composition = { duration, items, framePixelFormat: 'yuv' }; // 'auto' (default) | 'yuv' | 'rgba' (deprecated)
222
220
  ```
223
221
 
224
222
  - `yuv`: the decoder's own YUV 4:2:0 buffers, converted to RGB on the GPU when a frame is drawn. A decoded frame takes 1.5 bytes a pixel instead of 4 (12.4 MB instead of 33.2 MB in 4K), so the decoder keeps more frames ahead in the same memory.
225
- - `rgba`: frames converted to RGB as they are decoded (iOS: by VideoToolbox; Android: by an OpenGL pass), which an iOS GPU export samples in place.
226
- - `auto`: `yuv`, on both platforms.
223
+ - `rgba` (**deprecated**, still supported): frames converted to RGB as they are decoded (iOS: by VideoToolbox; Android: by an OpenGL pass). It no longer has an advantage. Exports run as fast in `yuv` (a 120-clip montage on an iPhone 15 Pro: 7.7 s in `yuv`, 7.8 s in `rgba`), and `yuv` renders the same colors. A 4K60 preview keeps fewer frames on screen in `rgba` (about 235 of 360 on an iPhone 15 Pro, against 355 in `yuv`), and frames take 2.7 times the memory. Use `auto`.
224
+ - `auto`: `yuv`, on both platforms. Android keeps HDR and 10-bit tracks in RGBA whatever the setting.
227
225
 
228
- Both convert with the frame's own YCbCr matrix and range, HDR tone mapped to Rec.709 by the platform. One difference: on iOS, `rgba` also converts a file tagged with other primaries or another SDR transfer function (Display P3, sRGB) to Rec.709, as AVFoundation does; `yuv`, like Android in both formats, takes its samples as Rec.709. Camera footage (Rec.709 or HLG) renders the same in both. See the [measurements](docs/FRAME_PIXEL_FORMAT.md) before choosing.
226
+ Both convert with the frame's own YCbCr matrix and range, HDR tone mapped to Rec.709 by the platform. On iOS, both also convert files tagged Display P3 or sRGB to Rec.709, as AVFoundation does. Android takes SDR samples as Rec.709 in both formats. See the [measurements](docs/FRAME_PIXEL_FORMAT.md) before choosing.
229
227
 
230
228
  For advanced exports, `createFrameProcessor` supports asynchronous preparation at each composition timestamp. The optional `react-native-skia-webgpu-video/gpu` entry point provides a resource scope whose asynchronous disposal waits for tracked work. See the [WebGPU cube example](examples/webgpu-overlay.ts).
231
229
 
@@ -233,33 +231,40 @@ These are integration building blocks. Three.js adapters, Core ML models, and au
233
231
 
234
232
  ## Benchmarks
235
233
 
236
- Release builds, 3 repetitions per case, deterministic workloads from catalog `2026-10-07.1`, median export wall time. Raw evidence: [iOS device](benchmark/ios-device-370-ab.json), [Android device](benchmark/android-device-370-ab.json); protocol and limits in the [verification report](migration/skia-3.7.0/verification.md). Earlier campaigns: [3.6.1](migration/skia-3.6.1/verification.md), [3.4.4](migration/skia-3.4.4/verification.md).
234
+ Release builds, 3 repetitions per case, deterministic workloads from catalog `2026-10-07.1` (plus two 4K → 4K exports added for beta.4), median export wall time. Protocol, raw results and Perfetto analysis in the [3.7.4 verification report](migration/skia-3.7.4/verification.md). Earlier campaigns: [3.7.0](migration/skia-3.7.0/verification.md), [3.6.1](migration/skia-3.6.1/verification.md), [3.4.4](migration/skia-3.4.4/verification.md).
237
235
 
238
- Skia 3.7.0 / WebGPU 0.17.0 move Graphite and Dawn from 154.3.0 to 157.0.0. Export speed is unchanged on both devices; the gains of this release are in on-screen canvases (below).
236
+ Beta.4 (Skia 3.7.4 / WebGPU 0.19.0) exports a third faster on Android, up to 45% on 4K sources, and leaves iOS unchanged. 0.1.0 (Skia 3.9.1 / WebGPU 0.21.0) leaves export times unchanged on both phones, within ±4% ([3.9.1 verification](migration/skia-3.9.1/verification.md)).
239
237
 
240
- **iPhone 15 Pro (iOS 27.0.1)**, four alternating series off the charger (a first series on a wireless charger ran hot and is not used). Same per-frame profile and same output bytes in both versions:
238
+ **Pixel 8a (Android 17)**, thermal state nominal, medians of 9 repetitions for beta.3 and 6 for beta.4:
241
239
 
242
- | Export case | 3.6.1 | 3.7.0 |
240
+ | Export case | beta.3 (Skia 3.7.1) | beta.4 (Skia 3.7.4) |
243
241
  | --- | --- | --- |
244
- | HEVC 1080p, rotated, 180 frames | 734 ms | 731 ms |
245
- | H.264 640×360, 60 frames | 173–211 ms | 168–171 ms |
246
- | 8 × 4K clips scaled to 1280, 360 frames | 2.74–2.78 s | 2.73–2.79 s |
247
-
248
- **Pixel 8a (Android 17)**:
249
-
250
- | Export case | 3.6.1 | 3.7.0 |
242
+ | 8 × 4K clips at native size, 1080 output, 360 frames | 9.8 s | **7.0 s** (−29%) |
243
+ | 8 × 4K clips scaled to 1280 | 12.7 s | **7.0 s** (−45%) |
244
+ | 3 simultaneous 4K clips, 150 frames | 5.6 s | **3.5 s** (−39%) |
245
+ | H.264 720p, rotated, 180 frames | 3.07 s | **1.82 s** (−41%) |
246
+ | HEVC 1080p, rotated, 180 frames | 3.12 s | **2.08 s** (−33%) |
247
+ | H.264 / HEVC 640×360, 60 frames | 1.25 / 1.27 s | **0.85 / 0.85 s** (−32 / −33%) |
248
+ | One 4K clip exported at 4K, H.264 / HEVC, 900 frames | 25.3 / 25.8 s | **23.0 / 22.3 s** (−9 / −13%) |
249
+
250
+ Where the time went:
251
+ - **Export renders straight into the codec's input Surface through WebGPU**: no EGL and no lent buffer. Exports of 1080p and below gain 10–17% on their own.
252
+ - **The GPU draws a frame while the CPU decodes the next one.** The export used to wait for the GPU after every frame, then leave it idle while decoding. Mali's frequency governor then kept the GPU at its lowest clock, which made the first 3.7.4 build 24% *slower* than 3.7.1 on 8 native 4K clips.
253
+ - **The export thread reports its frames to Android's performance hints (ADPF)**: it gets faster cores and, from Android 15, more GPU capacity. 15–29% on most cases.
254
+ - **`maxLongSide` and `resolution` keep the codec's YUV buffers**, scaled by the WebGPU conversion instead of an OpenGL render pass. Decoding 8 × 4K clips capped at 1280 takes 8.7 ms per frame instead of 17.
255
+ - A 4K → 4K export is now limited by the hardware encoder.
256
+
257
+ **iPhone 15 Pro (iOS 27.0.1)**: unchanged, since these changes are Android-only. Three alternating series, Skia 3.7.1 against 3.7.4, off the charger, with the same output bytes:
258
+
259
+ | Export case | Skia 3.7.1 | Skia 3.7.4 |
251
260
  | --- | --- | --- |
252
- | HEVC 1080p, rotated, 180 frames | 3.02 s | 2.92 s |
253
- | H.264 640×360, 60 frames | 1.18 s | 1.23 s |
254
- | 8 × 4K clips at native size, 360 frames | 10.0 s | 9.8 s |
255
- | 3 simultaneous 4K clips, 150 frames | 5.8 s | 5.6 s |
256
- | 8 × 4K clips scaled to 1280 (EGL decode) | 12.2 s | 12.5 s |
257
-
258
- The native 4K rows compare with the 3.6.1 run of October 16. Files exported from `ImageReader`-decoded clips are up to 5.7% smaller with 3.7.0 while sampled pixels still match CPU decoding within ±1; the cause is not yet confirmed ([verification](migration/skia-3.7.0/verification.md#pixel-8a)).
261
+ | HEVC 1080p, rotated, 180 frames | 730 ms | 737–749 ms |
262
+ | H.264 640×360, 60 frames | 177 ms | 185–217 ms |
263
+ | 8 × 4K clips scaled to 1280, 360 frames | 2.69 s | 2.68–2.74 s |
259
264
 
260
265
  **Smoother canvases on Android 10.** Dawn 157 asks for three swapchain images instead of two on Android. On Android 10, a canvas no longer makes the main thread wait for the previous frame's buffer: upstream measured 407 → 65 ms of `dequeueBuffer` wait per ~1.9 s trace for one opaque canvas on a Mi MIX 3. Skia 3.6.3 also keeps the render pool one frame ahead of the display: on a 120 Hz Pixel 8, an opaque `SurfaceView` canvas went from 66 to 110 presented frames per second. The swapchain change applies to every Skia and WebGPU canvas and the render pool change to Skia canvases, video previews included, with no code change. We did not measure them ourselves (no Android 10 device); see the [audit](migration/skia-3.7.0/upstream-audit.md#canvas-plus-fluide-sur-android-10).
261
266
 
262
- Pass `onProfile` to an export to see where the time goes on your own compositions. On the Pixel, a native-size HEVC 1080p frame spends about 2.8 ms decoding, 3.3 ms importing, 1.7 ms waiting for the GPU and 3.8 ms encoding; on the iPhone the whole frame takes under 4 ms.
267
+ Pass `onProfile` to an export to see where the time goes on your own compositions. On the Pixel, a native-size HEVC 1080p frame spends about 2.5 ms decoding, 1.9 ms importing, 0.8 ms waiting for the GPU and 3.7 ms encoding (beta.3: 2.8, 3.3, 1.7 and 3.8 ms); on the iPhone the whole frame takes under 4 ms.
263
268
 
264
269
  Reproduce on your devices with the example app: `node scripts/test-ios-device-ab-run.mjs` (iPhone) and `node scripts/test-android-device-run.mjs` (Android); see the [benchmark guide](docs/BENCHMARKS.md).
265
270
 
@@ -267,12 +272,12 @@ Reproduce on your devices with the example app: `node scripts/test-ios-device-ab
267
272
 
268
273
  | Area | Current evidence |
269
274
  | --- | --- |
270
- | Frame accuracy | At 60 Hz, the preview draws the frame due at 99–100% of vsyncs in 30, 24 and 60 fps on both phones, and 97–100% in 4K with `yuv` frames on the Pixel 8a. On a warm iPhone at 120 Hz: 73–95% in 1080p, 77–90% in 4K 60 fps. Every exported frame holds the right source frame, in both frame pixel formats ([beta.3 verification](docs/VERIFICATION_0.1.0-beta.3.md)). |
271
- | iOS | iPhone 15 Pro: 61/61 benchmark cases pass with `yuv` frames, including the three that stopped on the video memory budget with BGRA; 56/58 native XCTests pass, the other two being opt-in diagnostics ([beta.3 verification](docs/VERIFICATION_0.1.0-beta.3.md)). |
272
- | Android | Pixel 8a: CPU vs GPU decode comparison 10/10 cases (max channel error 1, including rotation, portrait and 4K); export checks of a color pattern and of a decoded rotated clip match CPU readback. With Dawn 157 the errors stay within ±1, as with 3.6.1 ([verification](migration/skia-3.7.0/verification.md#pixels)). Instrumented tests: 126/127 on beta.3 (the failure is a known 4K throughput test, see the [verification](docs/VERIFICATION_0.1.0-beta.3.md#tests-natifs)). |
273
- | JavaScript / types | 237 tests in 11 suites, typecheck, package tests and 122 benchmark-harness tests pass; CI runs them on every pull request. |
275
+ | Frame accuracy | At 60 Hz, the preview draws the frame due at 99–100% of vsyncs in 30, 24 and 60 fps on both phones, and 97–100% in 4K with `yuv` frames on the Pixel 8a. On an iPhone 15 Pro at 120 Hz with Skia 3.9.1: 99.7–100% of the frames read back are exact, and a 4K 60 fps preview shows 355–358 of its 360 frames at about 120 draws/s. Every exported frame holds the right source frame, in both frame pixel formats ([beta.3](docs/VERIFICATION_0.1.0-beta.3.md) and [3.9.1](migration/skia-3.9.1/verification.md) verifications). |
276
+ | iOS | iPhone 15 Pro: 61/61 benchmark cases pass with `yuv` frames, including the three that stopped on the video memory budget with BGRA ([beta.3 verification](docs/VERIFICATION_0.1.0-beta.3.md)). With Skia 3.9.1: 56/58 native XCTests pass, the other two being opt-in diagnostics. The smoke profile passes, and the frame-sync profile matches Skia 3.8.1's 43/58, with all 16 exports frame-exact ([3.9.1 verification](migration/skia-3.9.1/verification.md)). |
277
+ | Android | Pixel 8a, Skia 3.9.1: CPU vs GPU decode comparison 10/10 cases (max channel error 1, including rotation, portrait and 4K); export checks of a color pattern and of a decoded rotated clip match CPU readback, now read back asynchronously. The EGL-free export writes Rec.709, limited range ([3.7.4](migration/skia-3.7.4/verification.md) and [3.9.1](migration/skia-3.9.1/verification.md) verifications). Instrumented tests: 126/127. The failure, a 4K preview throughput test, fails the same way on Skia 3.8.1. |
278
+ | JavaScript / types | 247 tests in 11 suites, typecheck, package tests and 122 benchmark-harness tests pass; CI runs them on every pull request. |
274
279
  | Image / audio fidelity | Sampled SDR pixels and an exported color pattern are checked on Android; iOS exported files have the same byte size as with 3.0.6. Whole-image fidelity, HDR, audible output and A/V synchronization remain unqualified. |
275
- | Known issues | On Pixel 8a: a seek while playing a 4K sequence leaves the clock stalled, a paused seek into a 120-clip lazy montage does not show its frame, and Android 17 reports a missing file in words the harness does not recognize; all three predate beta.3. An iOS export with `yuv` frames spends 2–4 ms more per frame than with `rgba`. With `ImageReader`, 1:1 exports are 5–16% larger files for matching sampled pixels. |
280
+ | Known issues | On Pixel 8a: a seek while playing a 4K sequence leaves the clock stalled, a paused seek into a 120-clip lazy montage does not show its frame, and Android 17 reports a missing file in words the harness does not recognize; all three predate beta.3. On iPhone, a 4K 60 fps preview with the deprecated `rgba` frames shows only about 235 of 360 frames: its 33 MB buffers keep a GPU wait to stay within the memory budget. With `ImageReader`, 1:1 exports are 5–16% larger files for matching sampled pixels. |
276
281
  | Long sessions | Not yet re-run since 3.0.6; the 3.0.6 results below are the latest. |
277
282
 
278
283
  ### Earlier results (Skia 3.0.6)
@@ -1,6 +1,6 @@
1
1
  # Android validation and frame ownership
2
2
 
3
- The Android example in `../example/android` pins React Native 0.86.2, Skia 3.7.1, WebGPU 0.17.0, the shared `react-native-webgpu-dawn` 157.0.0 (chrome-m157) and Graphite Android 157.0.0; Gradle needs `node` to resolve that Dawn package. Export renders into encoder-lent AHardwareBuffers (`android/cpp/EncoderTargets.cpp`) sampled into the codec Surface by EGL, with CPU readback as the fallback. On API 29+, 8-bit SDR H.264/HEVC clips decoded at their encoded size go through an `ImageReader`, whose YUV buffers WebGPU converts, crops and rotates; since 3.6.1 each buffer is imported once, and exports sample the converted frame in place. The 3.7.0 build was assembled and run on a Pixel 8a on 2026-10-19 ([verification](../migration/skia-3.7.0/verification.md)), after the 3.6.1 build of 2026-10-16 ([verification](../migration/skia-3.6.1/verification.md)) and the 3.4.4 build of 2026-10-14/15 ([verification](../migration/skia-3.4.4/verification.md)); the evidence below is from Skia 3.0.6 / WebGPU 0.12.1 / Graphite 154.1.0. It uses Java 17, Gradle 9.3.1, Android SDK 36 and NDK 27.1.12297006. The library requires API 28; a consuming application with a lower minimum receives an explicit Gradle configuration error. The default example ABI is arm64-v8a; `RNSKV_ANDROID_ABI` selects another ABI for the validation script.
3
+ The Android example in `../example/android` pins React Native 0.86.2, Skia 3.10.0, WebGPU 0.21.3, the shared `react-native-webgpu-dawn` 158.0.0 (chrome-m158) and Graphite Android 158.0.0; Gradle needs `node` to resolve that Dawn package. Since WebGPU 0.19.0, export renders straight into the codec's input Surface through a WebGPU canvas context on it (`MakeWebGPUCanvasContextFromNativeWindow`, Vulkan swapchain), with no EGL; CPU readback, drawn into the Surface by EGL, stays the fallback. On API 29+, 8-bit SDR H.264/HEVC clips go through an `ImageReader`, whose YUV buffers WebGPU converts, crops, rotates and scales to the frame's size (capped or explicit sizes included since the 3.7.4 migration); since 3.6.1 each buffer is imported once, and exports sample the converted frame in place. The 3.7.0 build was assembled and run on a Pixel 8a on 2026-10-19 ([verification](../migration/skia-3.7.0/verification.md)), after the 3.6.1 build of 2026-10-16 ([verification](../migration/skia-3.6.1/verification.md)) and the 3.4.4 build of 2026-10-14/15 ([verification](../migration/skia-3.4.4/verification.md)); the evidence below is from Skia 3.0.6 / WebGPU 0.12.1 / Graphite 154.1.0. It uses Java 17, Gradle 9.3.1, Android SDK 36 and NDK 27.1.12297006. The library requires API 28; a consuming application with a lower minimum receives an explicit Gradle configuration error. The default example ABI is arm64-v8a; `RNSKV_ANDROID_ABI` selects another ABI for the validation script.
4
4
 
5
5
  Run `android/scripts/validate.sh build` from the repository. It assembles the example Debug and unsigned Release APKs, library AAR, standalone instrumentation APK and JVM tests. Release uses the installed `hermes-compiler` package, and both native and Metro build concurrency are bounded to two workers. The Release APK is unsigned: device installation requires signing a separate local qualification artifact. The project supplies no publishing or release signing configuration.
6
6
 
@@ -42,6 +42,8 @@ add_library(${PACKAGE_NAME}
42
42
  cpp/VideoCompositionFramesExtractorSyncHostObject.cpp
43
43
  cpp/VideoEncoderHostObject.h
44
44
  cpp/VideoEncoderHostObject.cpp
45
+ cpp/ExportHintSession.h
46
+ cpp/ExportHintSession.cpp
45
47
  cpp/VideoPlayerHostObject.h
46
48
  cpp/VideoPlayerHostObject.cpp
47
49
  cpp/VideoPlayer.h
@@ -54,8 +56,6 @@ add_library(${PACKAGE_NAME}
54
56
  cpp/NativeRgbaBuffer.cpp
55
57
  cpp/NativeHardwareBuffer.h
56
58
  cpp/NativeHardwareBuffer.cpp
57
- cpp/EncoderTargets.h
58
- cpp/EncoderTargets.cpp
59
59
  )
60
60
 
61
61
  # Specifies a path to native header files.
@@ -90,6 +90,7 @@ target_link_libraries(
90
90
  -lGLESv2
91
91
  -lEGL
92
92
  -landroid
93
+ -lnativewindow
93
94
  )
94
95
 
95
96
 
@@ -0,0 +1,114 @@
1
+ #include "ExportHintSession.h"
2
+ #include <android/performance_hint.h>
3
+ #include <dlfcn.h>
4
+ #include <time.h>
5
+ #include <unistd.h>
6
+ #include <algorithm>
7
+ #include <type_traits>
8
+
9
+ namespace RNSkiaVideo {
10
+
11
+ namespace {
12
+ int64_t nanos(clockid_t clock) {
13
+ timespec now{};
14
+ clock_gettime(clock, &now);
15
+ return static_cast<int64_t>(now.tv_sec) * 1'000'000'000 + now.tv_nsec;
16
+ }
17
+
18
+ /**
19
+ * The NDK's performance hint functions, resolved at run time: the library
20
+ * supports Android 9, hint sessions exist from Android 13, and work durations
21
+ * with a GPU part from Android 15. Null where missing.
22
+ */
23
+ struct Hints {
24
+ APerformanceHintManager* (*getManager)() = nullptr;
25
+ APerformanceHintSession* (*createSession)(APerformanceHintManager*,
26
+ const int32_t*, size_t,
27
+ int64_t) = nullptr;
28
+ int (*reportActual)(APerformanceHintSession*, int64_t) = nullptr;
29
+ void (*closeSession)(APerformanceHintSession*) = nullptr;
30
+ AWorkDuration* (*createWork)() = nullptr;
31
+ void (*releaseWork)(AWorkDuration*) = nullptr;
32
+ void (*setWorkStart)(AWorkDuration*, int64_t) = nullptr;
33
+ void (*setWorkTotal)(AWorkDuration*, int64_t) = nullptr;
34
+ void (*setWorkCpu)(AWorkDuration*, int64_t) = nullptr;
35
+ void (*setWorkGpu)(AWorkDuration*, int64_t) = nullptr;
36
+ int (*reportWork)(APerformanceHintSession*, AWorkDuration*) = nullptr;
37
+
38
+ bool sessions() const {
39
+ return getManager && createSession && reportActual && closeSession;
40
+ }
41
+ bool workDurations() const {
42
+ return createWork && releaseWork && setWorkStart && setWorkTotal &&
43
+ setWorkCpu && setWorkGpu && reportWork;
44
+ }
45
+ };
46
+
47
+ const Hints& hints() {
48
+ static const Hints functions = [] {
49
+ Hints found;
50
+ void* library = dlopen("libandroid.so", RTLD_NOW);
51
+ if (library == nullptr) return found;
52
+ const auto resolve = [library](auto& function, const char* name) {
53
+ function = reinterpret_cast<std::remove_reference_t<decltype(function)>>(
54
+ dlsym(library, name));
55
+ };
56
+ resolve(found.getManager, "APerformanceHint_getManager");
57
+ resolve(found.createSession, "APerformanceHint_createSession");
58
+ resolve(found.reportActual, "APerformanceHint_reportActualWorkDuration");
59
+ resolve(found.closeSession, "APerformanceHint_closeSession");
60
+ resolve(found.createWork, "AWorkDuration_create");
61
+ resolve(found.releaseWork, "AWorkDuration_release");
62
+ resolve(found.setWorkStart, "AWorkDuration_setWorkPeriodStartTimestampNanos");
63
+ resolve(found.setWorkTotal, "AWorkDuration_setActualTotalDurationNanos");
64
+ resolve(found.setWorkCpu, "AWorkDuration_setActualCpuDurationNanos");
65
+ resolve(found.setWorkGpu, "AWorkDuration_setActualGpuDurationNanos");
66
+ resolve(found.reportWork, "APerformanceHint_reportActualWorkDuration2");
67
+ return found;
68
+ }();
69
+ return functions;
70
+ }
71
+ } // namespace
72
+
73
+ ExportHintSession::ExportHintSession(int frameRate)
74
+ : targetNanos(1'000'000'000LL / (std::max(frameRate, 1) * 4LL)) {}
75
+
76
+ ExportHintSession::~ExportHintSession() {
77
+ if (session != nullptr) hints().closeSession(session);
78
+ }
79
+
80
+ void ExportHintSession::frameDone() {
81
+ const auto& functions = hints();
82
+ const auto now = nanos(CLOCK_MONOTONIC);
83
+ const auto cpu = nanos(CLOCK_THREAD_CPUTIME_ID);
84
+ if (session == nullptr) {
85
+ if (unavailable) return;
86
+ // The calling thread is the export's: it decodes, draws and encodes.
87
+ auto* manager = functions.sessions() ? functions.getManager() : nullptr;
88
+ const int32_t thread = gettid();
89
+ session = manager != nullptr
90
+ ? functions.createSession(manager, &thread, 1, targetNanos)
91
+ : nullptr;
92
+ unavailable = session == nullptr;
93
+ } else if (functions.workDurations()) {
94
+ const auto total = std::max<int64_t>(now - lastNanos, 1);
95
+ const auto threadCpu = std::clamp<int64_t>(cpu - lastCpuNanos, 0, total);
96
+ // The thread waits out the rest of the frame, mostly for the GPU: given
97
+ // as GPU time, which lets Android raise the GPU clock as well.
98
+ auto* work = functions.createWork();
99
+ if (work != nullptr) {
100
+ functions.setWorkStart(work, lastNanos);
101
+ functions.setWorkTotal(work, total);
102
+ functions.setWorkCpu(work, threadCpu);
103
+ functions.setWorkGpu(work, total - threadCpu);
104
+ functions.reportWork(session, work);
105
+ functions.releaseWork(work);
106
+ }
107
+ } else {
108
+ functions.reportActual(session, std::max<int64_t>(now - lastNanos, 1));
109
+ }
110
+ lastNanos = now;
111
+ lastCpuNanos = cpu;
112
+ }
113
+
114
+ } // namespace RNSkiaVideo
@@ -0,0 +1,35 @@
1
+ #pragma once
2
+
3
+ #include <cstdint>
4
+
5
+ struct APerformanceHintSession;
6
+
7
+ namespace RNSkiaVideo {
8
+
9
+ /**
10
+ * Tells Android (ADPF) how long the export takes to make each frame, so that
11
+ * it gives the export thread, and on Android 15 and later the GPU, the
12
+ * capacity it needs. An export runs as fast as it can rather than at its frame
13
+ * rate: frames are measured against a quarter of their real-time budget.
14
+ * Best effort: before Android 13, or on a device without hint sessions,
15
+ * nothing happens.
16
+ */
17
+ class ExportHintSession {
18
+ public:
19
+ explicit ExportHintSession(int frameRate);
20
+ ~ExportHintSession();
21
+ ExportHintSession(const ExportHintSession&) = delete;
22
+ ExportHintSession& operator=(const ExportHintSession&) = delete;
23
+
24
+ /** On the export thread, once a frame went to the encoder. */
25
+ void frameDone();
26
+
27
+ private:
28
+ int64_t targetNanos;
29
+ APerformanceHintSession* session = nullptr;
30
+ bool unavailable = false;
31
+ int64_t lastNanos = 0;
32
+ int64_t lastCpuNanos = 0;
33
+ };
34
+
35
+ } // namespace RNSkiaVideo
@@ -1,49 +1,36 @@
1
1
  #include "RNSVCheckedSizes.h"
2
2
  #include "VideoEncoderHostObject.h"
3
- #include "NativeHardwareBuffer.h"
4
- #include <EGL/egl.h>
3
+ #include <android/data_space.h>
4
+ #include <android/native_window_jni.h>
5
+ #include <dlfcn.h>
5
6
  #include <fbjni/ByteBuffer.h>
6
7
  #include <cmath>
7
8
  #include <limits>
8
- #include <unistd.h>
9
9
 
10
10
  namespace RNSkiaVideo {
11
11
  using namespace facebook::jni;
12
12
 
13
13
  namespace {
14
- /** Makes the encoder's EGL context current; restores the caller's (or none). */
15
- class ScopedEncoderContext {
16
- public:
17
- explicit ScopedEncoderContext(const global_ref<VideoEncoder>& encoder)
18
- : display(eglGetCurrentDisplay()), context(eglGetCurrentContext()),
19
- draw(eglGetCurrentSurface(EGL_DRAW)), read(eglGetCurrentSurface(EGL_READ)) {
20
- encoder->makeGLContextCurrent();
21
- }
22
- ~ScopedEncoderContext() {
23
- if (context != EGL_NO_CONTEXT) {
24
- eglMakeCurrent(display, draw, read, context);
25
- } else if (auto current = eglGetCurrentDisplay(); current != EGL_NO_DISPLAY) {
26
- eglMakeCurrent(current, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
27
- }
28
- }
29
- ScopedEncoderContext(const ScopedEncoderContext&) = delete;
30
- ScopedEncoderContext& operator=(const ScopedEncoderContext&) = delete;
31
-
32
- private:
33
- EGLDisplay display;
34
- EGLContext context;
35
- EGLSurface draw;
36
- EGLSurface read;
14
+ struct JSurface : JavaClass<JSurface> {
15
+ static constexpr auto kJavaDescriptor = "Landroid/view/Surface;";
37
16
  };
38
17
 
39
- /** Sync-fds handed over by JS; each is closed exactly once. */
40
- struct OwnedFences {
41
- std::vector<int> fds;
42
- ~OwnedFences() {
43
- for (int fd : fds)
44
- if (fd >= 0) close(fd);
45
- }
46
- };
18
+ /**
19
+ * Sets the presentation time of the next buffer queued to a window, as
20
+ * eglPresentationTimeANDROID does: the codec reads it as the frame's time.
21
+ * libnativewindow exports it on Android 10 and later, though the NDK does not
22
+ * declare it (it is in the LL-NDK); null where it is missing.
23
+ */
24
+ using SetBuffersTimestamp = int32_t (*)(ANativeWindow*, int64_t);
25
+ SetBuffersTimestamp setBuffersTimestamp() {
26
+ static const auto function = []() -> SetBuffersTimestamp {
27
+ void* library = dlopen("libnativewindow.so", RTLD_NOW);
28
+ return library == nullptr ? nullptr
29
+ : reinterpret_cast<SetBuffersTimestamp>(dlsym(
30
+ library, "ANativeWindow_setBuffersTimestamp"));
31
+ }();
32
+ return function;
33
+ }
47
34
  } // namespace
48
35
 
49
36
  local_ref<VideoEncoder>
@@ -79,12 +66,6 @@ void VideoEncoder::prepare() const {
79
66
  prepareMethod(self());
80
67
  }
81
68
 
82
- void VideoEncoder::makeGLContextCurrent() const {
83
- static const auto makeGLContextCurrentMethod =
84
- getClass()->getMethod<void()>("makeGLContextCurrent");
85
- makeGLContextCurrentMethod(self());
86
- }
87
-
88
69
  void VideoEncoder::encodeFrameRgba(alias_ref<JByteBuffer> pixels, jint width,
89
70
  jint height, jint bytesPerRow, jdouble time) const {
90
71
  static const auto method =
@@ -92,9 +73,21 @@ void VideoEncoder::encodeFrameRgba(alias_ref<JByteBuffer> pixels, jint width,
92
73
  method(self(), pixels, width, height, bytesPerRow, time);
93
74
  }
94
75
 
95
- void VideoEncoder::encodeTexture(jint texture, jdouble time) const {
96
- static const auto method = getClass()->getMethod<void(jint, jdouble)>("encodeTexture");
97
- method(self(), texture, time);
76
+ local_ref<jobject> VideoEncoder::getInputSurface() const {
77
+ static const auto method =
78
+ getClass()->getMethod<JSurface::javaobject()>("getInputSurface");
79
+ auto surface = method(self());
80
+ return make_local(static_cast<jobject>(surface.get()));
81
+ }
82
+
83
+ jlong VideoEncoder::beginWindowFrame(jdouble time) const {
84
+ static const auto method = getClass()->getMethod<jlong(jdouble)>("beginWindowFrame");
85
+ return method(self(), time);
86
+ }
87
+
88
+ void VideoEncoder::endWindowFrame(jlong timeUs) const {
89
+ static const auto method = getClass()->getMethod<void(jlong)>("endWindowFrame");
90
+ method(self(), timeUs);
98
91
  }
99
92
 
100
93
  void VideoEncoder::release() const {
@@ -112,11 +105,11 @@ VideoEncoderHostObject::VideoEncoderHostObject(
112
105
  std::string& outPath, int width, int height, int frameRate, int bitRate,
113
106
  std::optional<std::string> encoderName, std::optional<std::string> codec,
114
107
  alias_ref<VideoComposition> composition, int audioSampleRate,
115
- int audioChannelCount, int audioBitRate)
116
- : width(width), height(height) {
108
+ int audioChannelCount, int audioBitRate) {
117
109
  framesExtractor = make_global(VideoEncoder::create(
118
110
  outPath, width, height, frameRate, bitRate, encoderName, codec,
119
111
  composition, audioSampleRate, audioChannelCount, audioBitRate));
112
+ hints = std::make_unique<ExportHintSession>(frameRate);
120
113
  }
121
114
 
122
115
  VideoEncoderHostObject::~VideoEncoderHostObject() {
@@ -128,8 +121,7 @@ VideoEncoderHostObject::getPropertyNames(jsi::Runtime& rt) {
128
121
  std::vector<jsi::PropNameID> result;
129
122
  result.push_back(jsi::PropNameID::forUtf8(rt, std::string("prepare")));
130
123
  result.push_back(jsi::PropNameID::forUtf8(rt, std::string("encodeFrame")));
131
- result.push_back(jsi::PropNameID::forUtf8(rt, std::string("acquireFrameBuffer")));
132
- result.push_back(jsi::PropNameID::forUtf8(rt, std::string("releaseFrameBuffer")));
124
+ result.push_back(jsi::PropNameID::forUtf8(rt, std::string("acquireInputWindow")));
133
125
  result.push_back(jsi::PropNameID::forUtf8(rt, std::string("finishWriting")));
134
126
  result.push_back(jsi::PropNameID::forUtf8(rt, std::string("dispose")));
135
127
  return result;
@@ -155,39 +147,31 @@ jsi::Value VideoEncoderHostObject::get(jsi::Runtime& runtime,
155
147
  auto frame = arguments[0].asObject(runtime);
156
148
  const auto kindValue = frame.getProperty(runtime, "kind");
157
149
  const auto kind = kindValue.isString() ? kindValue.asString(runtime).utf8(runtime) : "";
158
- if (kind == "native-buffer") {
159
- // A lent buffer Skia rendered into; its sync-fds are ours to close.
160
- OwnedFences fences;
161
- const auto fenceValues = frame.getProperty(runtime, "fences");
162
- if (fenceValues.isObject()) {
163
- auto array = fenceValues.asObject(runtime).asArray(runtime);
164
- for (size_t i = 0; i < array.size(runtime); ++i) {
165
- auto value = array.getValueAtIndex(runtime, i);
166
- if (!value.isBigInt()) throw jsi::JSError(runtime, "Encoder fences must be sync-fd BigInts");
167
- // WebGPU reports an already signaled fence as -1, as a uint32.
168
- fences.fds.push_back(static_cast<int>(
169
- static_cast<uint32_t>(value.asBigInt(runtime).getUint64(runtime))));
170
- }
171
- }
172
- const auto pointerValue = frame.getProperty(runtime, "nativeBuffer");
173
- if (!pointerValue.isBigInt() || !targets)
174
- throw jsi::JSError(runtime, "Encoder frame buffer is not the one lent");
175
- const auto* buffer = reinterpret_cast<const AHardwareBuffer*>(
176
- static_cast<uintptr_t>(pointerValue.asBigInt(runtime).getUint64(runtime)));
150
+ if (kind == "window") {
151
+ // The GPU export rendered the frame into the canvas context of the
152
+ // input window: `present` queues it, with the time set here.
153
+ if (window == nullptr)
154
+ throw jsi::JSError(runtime, "Encoder input window was not acquired");
155
+ auto present = frame.getProperty(runtime, "present");
156
+ if (!present.isObject() || !present.asObject(runtime).isFunction(runtime))
157
+ throw jsi::JSError(runtime, "A window frame needs its present function");
177
158
  const auto time = checkedMediaSeconds(arguments[1].asNumber());
178
- ScopedEncoderContext scope(framesExtractor);
179
- const auto texture = targets->beginEncode(buffer, fences.fds);
180
- try {
181
- framesExtractor->encodeTexture(static_cast<jint>(texture), time);
182
- } catch (...) {
183
- targets->finishEncode();
184
- throw;
185
- }
186
- targets->finishEncode();
159
+ const auto timeUs = framesExtractor->beginWindowFrame(time);
160
+ // The swapchain tags its buffers sRGB, full range, and the codec
161
+ // would encode and label the file so. Unknown, as EGL leaves it,
162
+ // lets the codec use its default: Rec.709, limited range. Set for
163
+ // every frame, as a recreated swapchain tags the window again.
164
+ if (ANativeWindow_setBuffersDataSpace(window, ADATASPACE_UNKNOWN) != 0)
165
+ throw jsi::JSError(runtime, "Cannot set the encoder frame dataspace");
166
+ if (setBuffersTimestamp()(window, static_cast<int64_t>(timeUs) * 1000) != 0)
167
+ throw jsi::JSError(runtime, "Cannot set the encoder frame time");
168
+ present.asObject(runtime).asFunction(runtime).call(runtime);
169
+ framesExtractor->endWindowFrame(timeUs);
170
+ hints->frameDone();
187
171
  return jsi::Value::undefined();
188
172
  }
189
173
  if (kind != "rgba") {
190
- throw jsi::JSError(runtime, "Android encoder accepts RGBA pixels or a lent hardware buffer");
174
+ throw jsi::JSError(runtime, "Android encoder accepts RGBA pixels or a window frame");
191
175
  }
192
176
  const auto checkedInteger = [&](const char* name) -> int {
193
177
  const auto value = frame.getProperty(runtime, name).asNumber();
@@ -234,7 +218,7 @@ jsi::Value VideoEncoderHostObject::get(jsi::Runtime& runtime,
234
218
  framesExtractor->encodeFrameRgba(javaPixels, width, height, stride, checkedMediaSeconds(arguments[1].asNumber()));
235
219
  return jsi::Value::undefined();
236
220
  });
237
- } else if (propName == "acquireFrameBuffer") {
221
+ } else if (propName == "acquireInputWindow") {
238
222
  return getFunction(
239
223
  runtime, propName, 0,
240
224
  [this](jsi::Runtime& runtime, const jsi::Value& thisValue,
@@ -243,22 +227,17 @@ jsi::Value VideoEncoderHostObject::get(jsi::Runtime& runtime,
243
227
  if (released.test() || !prepared || finished) {
244
228
  throw jsi::JSError(runtime, "Encoder not prepared");
245
229
  }
246
- // The buffers are imported by the same Dawn device as decoded AHBs.
247
- if (!NativeHardwareBuffer::isEnabled()) {
248
- throw jsi::JSError(runtime, "Android GPU export requires AHardwareBuffer interop");
230
+ // Without it, every frame would carry the time it was queued at.
231
+ if (setBuffersTimestamp() == nullptr) {
232
+ throw jsi::JSError(runtime, "Encoder frame times need Android 10 or later");
249
233
  }
250
- if (!targets) targets = std::make_unique<EncoderTargets>(width, height);
251
- auto* buffer = targets->lend();
252
- return jsi::BigInt::fromUint64(runtime, reinterpret_cast<uintptr_t>(buffer));
253
- });
254
- } else if (propName == "releaseFrameBuffer") {
255
- return getFunction(
256
- runtime, propName, 0,
257
- [this](jsi::Runtime& runtime, const jsi::Value& thisValue,
258
- const jsi::Value* arguments, size_t count) -> jsi::Value {
259
- std::lock_guard<std::recursive_mutex> lock(encoderMutex);
260
- if (targets) targets->giveBack();
261
- return jsi::Value::undefined();
234
+ if (window == nullptr) {
235
+ auto surface = framesExtractor->getInputSurface();
236
+ window = ANativeWindow_fromSurface(Environment::current(), surface.get());
237
+ if (window == nullptr)
238
+ throw jsi::JSError(runtime, "Cannot get the encoder input window");
239
+ }
240
+ return jsi::BigInt::fromUint64(runtime, reinterpret_cast<uintptr_t>(window));
262
241
  });
263
242
  } else if (propName == "prepare") {
264
243
  return getFunction(
@@ -300,16 +279,12 @@ jsi::Value VideoEncoderHostObject::get(jsi::Runtime& runtime,
300
279
  void VideoEncoderHostObject::release() {
301
280
  std::lock_guard<std::recursive_mutex> lock(encoderMutex);
302
281
  if (!released.test_and_set()) {
303
- if (targets && !targets->empty()) {
304
- // GL names belong to the encoder context; free them before it goes.
305
- // Otherwise the destructor still releases buffers and reservations.
306
- try {
307
- ScopedEncoderContext scope(framesExtractor);
308
- targets->clear();
309
- } catch (...) {
310
- }
282
+ // A canvas context presenting to the window holds its own reference.
283
+ if (window != nullptr) {
284
+ ANativeWindow_release(window);
285
+ window = nullptr;
311
286
  }
312
- targets.reset();
287
+ hints.reset();
313
288
  framesExtractor->release();
314
289
  framesExtractor = nullptr;
315
290
  }