react-native-skia-webgpu-video 0.1.0-beta.2 → 0.1.0-beta.4

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 (86) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +62 -34
  3. package/android/BUILD_VALIDATION.md +1 -1
  4. package/android/CMakeLists.txt +3 -2
  5. package/android/cpp/ExportHintSession.cpp +114 -0
  6. package/android/cpp/ExportHintSession.h +35 -0
  7. package/android/cpp/VideoComposition.cpp +12 -0
  8. package/android/cpp/VideoCompositionFramesExtractor.cpp +6 -0
  9. package/android/cpp/VideoCompositionFramesExtractor.h +1 -0
  10. package/android/cpp/VideoCompositionFramesExtractorHostObject.cpp +1 -1
  11. package/android/cpp/VideoEncoderHostObject.cpp +76 -101
  12. package/android/cpp/VideoEncoderHostObject.h +14 -9
  13. package/android/cpp/VideoFrame.cpp +3 -5
  14. package/android/cpp/cpp-adapter.cpp +7 -4
  15. package/android/src/main/java/com/azzapp/rnskv/GLFrameExtractor.java +3 -9
  16. package/android/src/main/java/com/azzapp/rnskv/PreviewFrameRateVote.java +104 -0
  17. package/android/src/main/java/com/azzapp/rnskv/VideoComposition.java +4 -0
  18. package/android/src/main/java/com/azzapp/rnskv/VideoCompositionDecoder.java +8 -9
  19. package/android/src/main/java/com/azzapp/rnskv/VideoCompositionFramesExtractor.java +32 -0
  20. package/android/src/main/java/com/azzapp/rnskv/VideoEncoder.java +68 -28
  21. package/docs/FRAME_PIXEL_FORMAT.md +124 -0
  22. package/docs/MIGRATION.md +1 -1
  23. package/docs/UPSTREAM_NOTES.md +15 -2
  24. package/docs/VERIFICATION_0.1.0-beta.3.md +147 -0
  25. package/ios/NativeResourceStats.h +9 -0
  26. package/ios/RNSVColorSpace.h +35 -4
  27. package/ios/ReactNativeSkiaVideo.mm +4 -0
  28. package/ios/VideoComposition.h +20 -0
  29. package/ios/VideoCompositionFramesExtractorHostObject.h +5 -0
  30. package/ios/VideoCompositionFramesExtractorHostObject.mm +31 -2
  31. package/ios/VideoCompositionItemDecoder.mm +21 -8
  32. package/ios/VideoFrame.mm +6 -0
  33. package/lib/module/canvas.js +3 -5
  34. package/lib/module/canvas.js.map +1 -1
  35. package/lib/module/exportFrames.js +25 -20
  36. package/lib/module/exportFrames.js.map +1 -1
  37. package/lib/module/exportTarget.js +183 -40
  38. package/lib/module/exportTarget.js.map +1 -1
  39. package/lib/module/exportVideoComposition.js +53 -12
  40. package/lib/module/exportVideoComposition.js.map +1 -1
  41. package/lib/module/frameInterop.js +15 -12
  42. package/lib/module/frameInterop.js.map +1 -1
  43. package/lib/module/gpu.js +0 -2
  44. package/lib/module/gpu.js.map +1 -1
  45. package/lib/module/gpuDevice.js +12 -0
  46. package/lib/module/gpuDevice.js.map +1 -1
  47. package/lib/module/previewLifecycle.js +0 -1
  48. package/lib/module/previewLifecycle.js.map +1 -1
  49. package/lib/module/videoCompositionPlayer.js +13 -5
  50. package/lib/module/videoCompositionPlayer.js.map +1 -1
  51. package/lib/module/yuvBlit.js +98 -21
  52. package/lib/module/yuvBlit.js.map +1 -1
  53. package/lib/typescript/src/canvas.d.ts.map +1 -1
  54. package/lib/typescript/src/exportFrames.d.ts +11 -10
  55. package/lib/typescript/src/exportFrames.d.ts.map +1 -1
  56. package/lib/typescript/src/exportTarget.d.ts +53 -14
  57. package/lib/typescript/src/exportTarget.d.ts.map +1 -1
  58. package/lib/typescript/src/exportVideoComposition.d.ts +2 -1
  59. package/lib/typescript/src/exportVideoComposition.d.ts.map +1 -1
  60. package/lib/typescript/src/frameInterop.d.ts.map +1 -1
  61. package/lib/typescript/src/gpu.d.ts.map +1 -1
  62. package/lib/typescript/src/gpuDevice.d.ts +5 -1
  63. package/lib/typescript/src/gpuDevice.d.ts.map +1 -1
  64. package/lib/typescript/src/previewLifecycle.d.ts.map +1 -1
  65. package/lib/typescript/src/types.d.ts +36 -5
  66. package/lib/typescript/src/types.d.ts.map +1 -1
  67. package/lib/typescript/src/videoCompositionPlayer.d.ts.map +1 -1
  68. package/lib/typescript/src/yuvBlit.d.ts +26 -14
  69. package/lib/typescript/src/yuvBlit.d.ts.map +1 -1
  70. package/package.json +6 -6
  71. package/patches/react-native-skia+3.7.4.patch +146 -0
  72. package/scripts/skia-canvas-patch.json +22 -258
  73. package/src/canvas.ts +3 -7
  74. package/src/exportFrames.ts +53 -36
  75. package/src/exportTarget.ts +217 -56
  76. package/src/exportVideoComposition.ts +87 -26
  77. package/src/frameInterop.ts +26 -11
  78. package/src/gpu.ts +0 -2
  79. package/src/gpuDevice.ts +26 -1
  80. package/src/previewLifecycle.ts +0 -1
  81. package/src/types.ts +37 -5
  82. package/src/videoCompositionPlayer.ts +14 -4
  83. package/src/yuvBlit.ts +108 -27
  84. package/android/cpp/EncoderTargets.cpp +0 -226
  85. package/android/cpp/EncoderTargets.h +0 -70
  86. package/patches/react-native-skia+3.7.1.patch +0 -502
package/LICENSE CHANGED
@@ -1,7 +1,6 @@
1
1
  MIT License
2
2
 
3
3
  Copyright (c) 2026 Sebastien Hecart
4
- Portions Copyright (c) 2024 Azzapp
5
4
 
6
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
7
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -6,8 +6,8 @@
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
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
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" />
9
+ <img alt="React Native Skia 3.7.4" src="https://img.shields.io/badge/Skia-3.7.4-38bdf8" />
10
+ <img alt="React Native WebGPU 0.19.0" src="https://img.shields.io/badge/WebGPU-0.19.0-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
+ > **Beta.** The Skia 3.7.4 / WebGPU 0.19.0 build has run on an iPhone 15 Pro and a Pixel 8a (export speed, pixel 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; two 4K cases have known issues. See [status](#status) before adopting this in production.
31
31
 
32
32
  ## Installation
33
33
 
@@ -37,20 +37,22 @@ 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.7.4`** |
41
+ | React Native WebGPU | **`react-native-webgpu@0.19.0`** (both resolve `react-native-webgpu-dawn@157.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) |
45
45
 
46
46
  A native rebuild is required. Expo Go cannot load this module. Skia and WebGPU now share Dawn through the `react-native-webgpu-dawn` npm package: `node` must be on the `PATH` during `pod install` and Gradle builds, and both packages must resolve the same version of it. For iOS, use the deployment target required by your React Native and Skia versions; the example targets iOS 16.4 or later.
47
47
 
48
+ On ProMotion iPhones, add `CADisableMinimumFrameDurationOnPhone` (Boolean, `YES`) to your app's `Info.plist`: without it, iOS holds every frame callback of the app, the preview's included, at 60 Hz. On Android 15 and later, a playing preview asks the display for its highest refresh rate itself.
49
+
48
50
  ### Install
49
51
 
50
52
  The beta is published on npm under the `beta` tag:
51
53
 
52
54
  ```sh
53
- 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
55
+ npm install --save-exact react-native-skia@3.7.4 react-native-webgpu@0.19.0 react-native-reanimated@4.5.3 react-native-worklets@0.11.3
54
56
  npm install --save-exact react-native-skia-webgpu-video@beta
55
57
  ```
56
58
 
@@ -203,13 +205,27 @@ This budget covers **tracked video resources**, not all memory held by codecs, S
203
205
 
204
206
  | Step | iOS | Android |
205
207
  | --- | --- | --- |
206
- | Decode | AVFoundation, BGRA IOSurface-backed buffers, no extra copy (`alwaysCopiesSampleData = NO`) | API 29+, 8-bit SDR H.264/HEVC at the encoded size: `ImageReader` YUV buffers. Otherwise: EGL render into an RGBA AHardwareBuffer (or a CPU scoped-copy fallback) |
207
- | Into Skia, preview | WebGPU blit into a reusable texture, then an owned Graphite snapshot | WebGPU blit (YUV→RGB, crop and rotation in one pass), then an owned Graphite snapshot |
208
- | Into Skia, export | Sampled in place through the frame's IOSurface: no copy, no per-clip GPU wait | Same as preview |
209
- | 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 |
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: `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) |
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, rotation and scaling to the frame's size in one pass), then an owned Graphite snapshot |
210
+ | 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 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 |
210
212
  | Fallback | CPU readback, with a warning | CPU readback, with a warning |
211
213
 
212
- 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.
214
+ Every path waits for checked GPU completion before releasing source frames. 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
+
216
+ ### Frame pixel format
217
+
218
+ `framePixelFormat` chooses how a composition's frames are decoded, for its preview and its export:
219
+
220
+ ```ts
221
+ const composition = { duration, items, framePixelFormat: 'yuv' }; // 'auto' (default) | 'yuv' | 'rgba'
222
+ ```
223
+
224
+ - `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.
227
+
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.
213
229
 
214
230
  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).
215
231
 
@@ -217,33 +233,40 @@ These are integration building blocks. Three.js adapters, Core ML models, and au
217
233
 
218
234
  ## Benchmarks
219
235
 
220
- 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).
236
+ 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).
221
237
 
222
- 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).
238
+ 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.
223
239
 
224
- **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:
240
+ **Pixel 8a (Android 17)**, thermal state nominal, medians of 9 repetitions for beta.3 and 6 for beta.4:
225
241
 
226
- | Export case | 3.6.1 | 3.7.0 |
242
+ | Export case | beta.3 (Skia 3.7.1) | beta.4 (Skia 3.7.4) |
227
243
  | --- | --- | --- |
228
- | HEVC 1080p, rotated, 180 frames | 734 ms | 731 ms |
229
- | H.264 640×360, 60 frames | 173–211 ms | 168–171 ms |
230
- | 8 × 4K clips scaled to 1280, 360 frames | 2.74–2.78 s | 2.73–2.79 s |
231
-
232
- **Pixel 8a (Android 17)**:
233
-
234
- | Export case | 3.6.1 | 3.7.0 |
244
+ | 8 × 4K clips at native size, 1080 output, 360 frames | 9.8 s | **7.0 s** (−29%) |
245
+ | 8 × 4K clips scaled to 1280 | 12.7 s | **7.0 s** (−45%) |
246
+ | 3 simultaneous 4K clips, 150 frames | 5.6 s | **3.5 s** (−39%) |
247
+ | H.264 720p, rotated, 180 frames | 3.07 s | **1.82 s** (−41%) |
248
+ | HEVC 1080p, rotated, 180 frames | 3.12 s | **2.08 s** (−33%) |
249
+ | H.264 / HEVC 640×360, 60 frames | 1.25 / 1.27 s | **0.85 / 0.85 s** (−32 / −33%) |
250
+ | One 4K clip exported at 4K, H.264 / HEVC, 900 frames | 25.3 / 25.8 s | **23.0 / 22.3 s** (−9 / −13%) |
251
+
252
+ Where the time went:
253
+ - **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.
254
+ - **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.
255
+ - **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.
256
+ - **`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.
257
+ - A 4K → 4K export is now limited by the hardware encoder.
258
+
259
+ **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:
260
+
261
+ | Export case | Skia 3.7.1 | Skia 3.7.4 |
235
262
  | --- | --- | --- |
236
- | HEVC 1080p, rotated, 180 frames | 3.02 s | 2.92 s |
237
- | H.264 640×360, 60 frames | 1.18 s | 1.23 s |
238
- | 8 × 4K clips at native size, 360 frames | 10.0 s | 9.8 s |
239
- | 3 simultaneous 4K clips, 150 frames | 5.8 s | 5.6 s |
240
- | 8 × 4K clips scaled to 1280 (EGL decode) | 12.2 s | 12.5 s |
241
-
242
- 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)).
263
+ | HEVC 1080p, rotated, 180 frames | 730 ms | 737–749 ms |
264
+ | H.264 640×360, 60 frames | 177 ms | 185–217 ms |
265
+ | 8 × 4K clips scaled to 1280, 360 frames | 2.69 s | 2.68–2.74 s |
243
266
 
244
267
  **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).
245
268
 
246
- 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.
269
+ 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.
247
270
 
248
271
  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).
249
272
 
@@ -251,11 +274,12 @@ Reproduce on your devices with the example app: `node scripts/test-ios-device-ab
251
274
 
252
275
  | Area | Current evidence |
253
276
  | --- | --- |
254
- | iOS | iPhone 15 Pro: all export cases above passed in every run (72/72), with output sizes identical to 3.6.1 ([verification](migration/skia-3.7.0/verification.md)). Native XCTests were last run on the simulator with 3.6.1 (50/50); the iOS native sources have not changed since. |
255
- | 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 were last run on 3.4.4 (97/102; the 5 failures predate it). |
256
- | JavaScript / types | 217 tests in 10 suites, typecheck, package tests and 112 benchmark-harness tests pass; CI runs them on every pull request. |
277
+ | 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)). |
278
+ | 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.7.4: 56/58 native XCTests pass, the other two being opt-in diagnostics, and the smoke profile passes in 8 of 9 runs. The one failure, a preview frame import refused right after a launch on a locked phone, did not come back ([3.7.4 verification](migration/skia-3.7.4/verification.md#reste-à-faire)). |
279
+ | Android | Pixel 8a, beta.4: 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. The EGL-free export writes the same bytes as beta.3's (Rec.709, limited range) ([3.7.4 verification](migration/skia-3.7.4/verification.md)). Instrumented tests: 126/127; the failure, a 4K preview throughput test, fails the same way on beta.3. |
280
+ | JavaScript / types | 238 tests in 11 suites, typecheck, package tests and 122 benchmark-harness tests pass; CI runs them on every pull request. |
257
281
  | 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. |
258
- | Known issues | A 4K lazy composition seek times out in 1 of 3 runs on Pixel 8a, and three simultaneous 4K clips stop on the video memory budget during iPhone playback; both happen before and after this release. With `ImageReader`, 1:1 exports are 5–16% larger files for matching sampled pixels; with 3.7.0 they are up to 5.7% smaller than with 3.6.1, cause not confirmed. |
282
+ | 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. |
259
283
  | Long sessions | Not yet re-run since 3.0.6; the 3.0.6 results below are the latest. |
260
284
 
261
285
  ### Earlier results (Skia 3.0.6)
@@ -273,6 +297,10 @@ The eight-clip 4K lifecycle case remained memory intensive: sampled peaks reache
273
297
 
274
298
  A subsequent export-worklet fix reduces repeated-session allocation overhead. In matched two-montage Release runs, memory after 60 seconds idle fell from **426 to 258 MiB physical footprint on iPhone 15 Pro**, and from **738 to 577 MiB PSS on Pixel 8a**. These are different platform metrics, not a cross-device comparison. Six consecutive iPhone workloads and four Android workloads passed with flatter or decreasing late-run readings; bounded tests do not prove zero leaks. See the [native memory investigation](docs/NATIVE_MEMORY_INVESTIGATION_306.md) for checkpoints and limits.
275
299
 
300
+ ## Built with it
301
+
302
+ No projects listed yet. Using this library in an app or an open-source project? [Share it in Show and tell](https://github.com/batical/react-native-skia-webgpu-video/discussions/22) and it may be featured here.
303
+
276
304
  ## Development
277
305
 
278
306
  ```sh
@@ -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.7.4, WebGPU 0.19.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. 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
@@ -150,6 +150,18 @@ VideoComposition::fromJSIObject(jsi::Runtime& runtime,
150
150
  auto composition = VideoComposition::create(duration, items);
151
151
  composition->setFieldValue(VideoComposition::javaClassStatic()->getField<jboolean>(
152
152
  "hardwareBufferEnabled"), static_cast<jboolean>(NativeHardwareBuffer::isEnabled()));
153
+ if (jsComposition.hasProperty(runtime, "framePixelFormat")) {
154
+ auto formatProp = jsComposition.getProperty(runtime, "framePixelFormat");
155
+ std::string format = formatProp.isString() ? formatProp.asString(runtime).utf8(runtime) : "";
156
+ if (!formatProp.isUndefined() && format != "auto" && format != "yuv" && format != "rgba")
157
+ throw jsi::JSError(runtime, "framePixelFormat must be 'auto', 'yuv' or 'rgba'");
158
+ // 'auto' and 'yuv' are the same on Android: YUV wherever the track allows.
159
+ if (format == "rgba") {
160
+ composition->setFieldValue(
161
+ VideoComposition::javaClassStatic()->getField<jboolean>("rgbaFrames"),
162
+ (jboolean)JNI_TRUE);
163
+ }
164
+ }
153
165
  if (jsComposition.hasProperty(runtime, "lazyDecoders")) {
154
166
  auto lazyProp = jsComposition.getProperty(runtime, "lazyDecoders");
155
167
  if (lazyProp.isBool() && lazyProp.getBool()) {
@@ -54,6 +54,12 @@ jlong VideoCompositionFramesExtractor::getCurrentPosition() const {
54
54
  return getCurrentPositionMethod(self());
55
55
  }
56
56
 
57
+ jlong VideoCompositionFramesExtractor::getPresentedPosition() const {
58
+ static const auto getPresentedPositionMethod =
59
+ getClass()->getMethod<jlong()>("getPresentedPosition");
60
+ return getPresentedPositionMethod(self());
61
+ }
62
+
57
63
  jlong VideoCompositionFramesExtractor::getFramesVersion() const {
58
64
  static const auto getFramesVersionMethod =
59
65
  getClass()->getMethod<jlong()>("getFramesVersion");
@@ -33,6 +33,7 @@ public:
33
33
  jboolean getIsPlaying() const;
34
34
 
35
35
  jlong getCurrentPosition() const;
36
+ jlong getPresentedPosition() const;
36
37
 
37
38
  jlong getFramesVersion() const;
38
39
 
@@ -172,7 +172,7 @@ jsi::Value VideoCompositionFramesExtractorHostObject::get(
172
172
  } else if (propName == "currentTime") {
173
173
  std::lock_guard<std::recursive_mutex> lock(playerMutex);
174
174
  return {released.test() ? 0
175
- : (double)player->getCurrentPosition() / 1000000.0};
175
+ : (double)player->getPresentedPosition() / 1000000.0};
176
176
  } else if (propName == "framesVersion") {
177
177
  std::lock_guard<std::recursive_mutex> lock(playerMutex);
178
178
  return {released.test() || !prepared.test()