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.
- package/LICENSE +0 -1
- package/README.md +62 -34
- package/android/BUILD_VALIDATION.md +1 -1
- package/android/CMakeLists.txt +3 -2
- package/android/cpp/ExportHintSession.cpp +114 -0
- package/android/cpp/ExportHintSession.h +35 -0
- package/android/cpp/VideoComposition.cpp +12 -0
- package/android/cpp/VideoCompositionFramesExtractor.cpp +6 -0
- package/android/cpp/VideoCompositionFramesExtractor.h +1 -0
- package/android/cpp/VideoCompositionFramesExtractorHostObject.cpp +1 -1
- package/android/cpp/VideoEncoderHostObject.cpp +76 -101
- package/android/cpp/VideoEncoderHostObject.h +14 -9
- package/android/cpp/VideoFrame.cpp +3 -5
- package/android/cpp/cpp-adapter.cpp +7 -4
- package/android/src/main/java/com/azzapp/rnskv/GLFrameExtractor.java +3 -9
- package/android/src/main/java/com/azzapp/rnskv/PreviewFrameRateVote.java +104 -0
- package/android/src/main/java/com/azzapp/rnskv/VideoComposition.java +4 -0
- package/android/src/main/java/com/azzapp/rnskv/VideoCompositionDecoder.java +8 -9
- package/android/src/main/java/com/azzapp/rnskv/VideoCompositionFramesExtractor.java +32 -0
- package/android/src/main/java/com/azzapp/rnskv/VideoEncoder.java +68 -28
- package/docs/FRAME_PIXEL_FORMAT.md +124 -0
- package/docs/MIGRATION.md +1 -1
- package/docs/UPSTREAM_NOTES.md +15 -2
- package/docs/VERIFICATION_0.1.0-beta.3.md +147 -0
- package/ios/NativeResourceStats.h +9 -0
- package/ios/RNSVColorSpace.h +35 -4
- package/ios/ReactNativeSkiaVideo.mm +4 -0
- package/ios/VideoComposition.h +20 -0
- package/ios/VideoCompositionFramesExtractorHostObject.h +5 -0
- package/ios/VideoCompositionFramesExtractorHostObject.mm +31 -2
- package/ios/VideoCompositionItemDecoder.mm +21 -8
- package/ios/VideoFrame.mm +6 -0
- package/lib/module/canvas.js +3 -5
- package/lib/module/canvas.js.map +1 -1
- package/lib/module/exportFrames.js +25 -20
- package/lib/module/exportFrames.js.map +1 -1
- package/lib/module/exportTarget.js +183 -40
- package/lib/module/exportTarget.js.map +1 -1
- package/lib/module/exportVideoComposition.js +53 -12
- package/lib/module/exportVideoComposition.js.map +1 -1
- package/lib/module/frameInterop.js +15 -12
- package/lib/module/frameInterop.js.map +1 -1
- package/lib/module/gpu.js +0 -2
- package/lib/module/gpu.js.map +1 -1
- package/lib/module/gpuDevice.js +12 -0
- package/lib/module/gpuDevice.js.map +1 -1
- package/lib/module/previewLifecycle.js +0 -1
- package/lib/module/previewLifecycle.js.map +1 -1
- package/lib/module/videoCompositionPlayer.js +13 -5
- package/lib/module/videoCompositionPlayer.js.map +1 -1
- package/lib/module/yuvBlit.js +98 -21
- package/lib/module/yuvBlit.js.map +1 -1
- package/lib/typescript/src/canvas.d.ts.map +1 -1
- package/lib/typescript/src/exportFrames.d.ts +11 -10
- package/lib/typescript/src/exportFrames.d.ts.map +1 -1
- package/lib/typescript/src/exportTarget.d.ts +53 -14
- package/lib/typescript/src/exportTarget.d.ts.map +1 -1
- package/lib/typescript/src/exportVideoComposition.d.ts +2 -1
- package/lib/typescript/src/exportVideoComposition.d.ts.map +1 -1
- package/lib/typescript/src/frameInterop.d.ts.map +1 -1
- package/lib/typescript/src/gpu.d.ts.map +1 -1
- package/lib/typescript/src/gpuDevice.d.ts +5 -1
- package/lib/typescript/src/gpuDevice.d.ts.map +1 -1
- package/lib/typescript/src/previewLifecycle.d.ts.map +1 -1
- package/lib/typescript/src/types.d.ts +36 -5
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/lib/typescript/src/videoCompositionPlayer.d.ts.map +1 -1
- package/lib/typescript/src/yuvBlit.d.ts +26 -14
- package/lib/typescript/src/yuvBlit.d.ts.map +1 -1
- package/package.json +6 -6
- package/patches/react-native-skia+3.7.4.patch +146 -0
- package/scripts/skia-canvas-patch.json +22 -258
- package/src/canvas.ts +3 -7
- package/src/exportFrames.ts +53 -36
- package/src/exportTarget.ts +217 -56
- package/src/exportVideoComposition.ts +87 -26
- package/src/frameInterop.ts +26 -11
- package/src/gpu.ts +0 -2
- package/src/gpuDevice.ts +26 -1
- package/src/previewLifecycle.ts +0 -1
- package/src/types.ts +37 -5
- package/src/videoCompositionPlayer.ts +14 -4
- package/src/yuvBlit.ts +108 -27
- package/android/cpp/EncoderTargets.cpp +0 -226
- package/android/cpp/EncoderTargets.h +0 -70
- package/patches/react-native-skia+3.7.1.patch +0 -502
package/LICENSE
CHANGED
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.
|
|
10
|
-
<img alt="React Native WebGPU 0.
|
|
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.
|
|
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.
|
|
41
|
-
| React Native WebGPU | **`react-native-webgpu@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.
|
|
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,
|
|
207
|
-
| Into Skia, preview |
|
|
208
|
-
| Into Skia, export |
|
|
209
|
-
| Export output | Skia draws into the encoder's CVPixelBuffer (IOSurface) | Skia draws into
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
**
|
|
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.
|
|
242
|
+
| Export case | beta.3 (Skia 3.7.1) | beta.4 (Skia 3.7.4) |
|
|
227
243
|
| --- | --- | --- |
|
|
228
|
-
|
|
|
229
|
-
|
|
|
230
|
-
|
|
|
231
|
-
|
|
232
|
-
**
|
|
233
|
-
|
|
234
|
-
|
|
|
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 |
|
|
237
|
-
| H.264 640×360, 60 frames |
|
|
238
|
-
| 8 × 4K clips
|
|
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.
|
|
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
|
-
|
|
|
255
|
-
|
|
|
256
|
-
|
|
|
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 |
|
|
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.
|
|
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
|
|
package/android/CMakeLists.txt
CHANGED
|
@@ -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");
|
|
@@ -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->
|
|
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()
|