react-native-skia-webgpu-video 0.1.0-beta.1 → 0.1.0-beta.3
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 +32 -11
- package/android/BUILD_VALIDATION.md +1 -1
- 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/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 +4 -2
- package/android/src/main/java/com/azzapp/rnskv/VideoCompositionFramesExtractor.java +42 -4
- package/docs/FRAME_PIXEL_FORMAT.md +124 -0
- package/docs/UPSTREAM_NOTES.md +2 -0
- 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/exportFrames.js +25 -18
- package/lib/module/exportFrames.js.map +1 -1
- package/lib/module/frameInterop.js +7 -1
- package/lib/module/frameInterop.js.map +1 -1
- package/lib/module/videoCompositionPlayer.js +13 -5
- package/lib/module/videoCompositionPlayer.js.map +1 -1
- package/lib/module/yuvBlit.js +70 -0
- package/lib/module/yuvBlit.js.map +1 -1
- package/lib/typescript/src/exportFrames.d.ts +8 -8
- package/lib/typescript/src/exportFrames.d.ts.map +1 -1
- package/lib/typescript/src/frameInterop.d.ts.map +1 -1
- package/lib/typescript/src/types.d.ts +26 -0
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/lib/typescript/src/videoCompositionPlayer.d.ts +5 -3
- package/lib/typescript/src/videoCompositionPlayer.d.ts.map +1 -1
- package/lib/typescript/src/yuvBlit.d.ts +10 -0
- package/lib/typescript/src/yuvBlit.d.ts.map +1 -1
- package/package.json +7 -5
- package/scripts/skia-canvas-patch.json +1 -1
- package/src/exportFrames.ts +52 -34
- package/src/frameInterop.ts +18 -1
- package/src/types.ts +27 -0
- package/src/videoCompositionPlayer.ts +19 -7
- package/src/yuvBlit.ts +66 -0
- /package/patches/{react-native-skia+3.7.0.patch → react-native-skia+3.7.1.patch} +0 -0
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
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.
|
|
9
|
+
<img alt="React Native Skia 3.7.1" src="https://img.shields.io/badge/Skia-3.7.1-38bdf8" />
|
|
10
10
|
<img alt="React Native WebGPU 0.17.0" src="https://img.shields.io/badge/WebGPU-0.17.0-a78bfa" />
|
|
11
11
|
<a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-34d399" /></a>
|
|
12
12
|
</p>
|
|
@@ -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
|
|
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.
|
|
31
31
|
|
|
32
32
|
## Installation
|
|
33
33
|
|
|
@@ -37,7 +37,7 @@ 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.
|
|
40
|
+
| React Native Skia | **`react-native-skia@3.7.1`** |
|
|
41
41
|
| React Native WebGPU | **`react-native-webgpu@0.17.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 |
|
|
@@ -45,12 +45,14 @@ A new Skia 3 / WebGPU frame-interop layer, written from scratch with explicit re
|
|
|
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.1 react-native-webgpu@0.17.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,14 +205,28 @@ 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 |
|
|
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 |
|
|
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 |
|
|
209
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 |
|
|
210
212
|
| Fallback | CPU readback, with a warning | CPU readback, with a warning |
|
|
211
213
|
|
|
212
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.
|
|
213
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.
|
|
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
|
|
|
216
232
|
These are integration building blocks. Three.js adapters, Core ML models, and automatic Neural Engine acceleration are not included. Browser video support is not provided by this native library.
|
|
@@ -251,11 +267,12 @@ Reproduce on your devices with the example app: `node scripts/test-ios-device-ab
|
|
|
251
267
|
|
|
252
268
|
| Area | Current evidence |
|
|
253
269
|
| --- | --- |
|
|
254
|
-
|
|
|
255
|
-
|
|
|
256
|
-
|
|
|
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. |
|
|
257
274
|
| 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 |
|
|
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. |
|
|
259
276
|
| Long sessions | Not yet re-run since 3.0.6; the 3.0.6 results below are the latest. |
|
|
260
277
|
|
|
261
278
|
### Earlier results (Skia 3.0.6)
|
|
@@ -273,6 +290,10 @@ The eight-clip 4K lifecycle case remained memory intensive: sampled peaks reache
|
|
|
273
290
|
|
|
274
291
|
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
292
|
|
|
293
|
+
## Built with it
|
|
294
|
+
|
|
295
|
+
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.
|
|
296
|
+
|
|
276
297
|
## Development
|
|
277
298
|
|
|
278
299
|
```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.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.
|
|
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
|
|
|
@@ -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()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
package com.azzapp.rnskv;
|
|
2
|
+
|
|
3
|
+
import android.app.Activity;
|
|
4
|
+
import android.os.Build;
|
|
5
|
+
import android.os.SystemClock;
|
|
6
|
+
import android.util.Log;
|
|
7
|
+
import android.view.Display;
|
|
8
|
+
import android.view.View;
|
|
9
|
+
import android.view.ViewGroup;
|
|
10
|
+
|
|
11
|
+
import com.facebook.react.bridge.ReactApplicationContext;
|
|
12
|
+
|
|
13
|
+
import java.util.HashSet;
|
|
14
|
+
import java.util.Set;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Asks the display for its highest refresh rate while a preview plays, through
|
|
18
|
+
* the React Native Skia canvases of the activity (Android 15+). Main thread.
|
|
19
|
+
*
|
|
20
|
+
* Android picks the refresh rate from what the app presents. A preview that
|
|
21
|
+
* presents at 30 fps for a while (30 fps frames drawn as they come) brings a
|
|
22
|
+
* 60 Hz display down to 30 Hz, and it stays there: the next clip, at 60 or
|
|
23
|
+
* 24 fps, can only present at 30 fps and never shows the display it needs
|
|
24
|
+
* more. On a Pixel 8a, one 60 fps frame in two was never shown, and 24 fps
|
|
25
|
+
* came out irregular on a fifth of its frames. A vote tells the display.
|
|
26
|
+
*
|
|
27
|
+
* Only views that draw cast their vote: one on the window's root view is never
|
|
28
|
+
* counted. The player does not know which canvas shows it, so all of the Skia
|
|
29
|
+
* TextureView canvases vote; those that do not draw cast nothing. SurfaceView
|
|
30
|
+
* canvases vote for themselves, and the vote asks what theirs asks: the highest
|
|
31
|
+
* rate a seamless switch reaches, 120 Hz where the user allows it, so that
|
|
32
|
+
* what drawFrame animates over the video is not held to 60 Hz. Not the clips'
|
|
33
|
+
* rate: a 24 fps vote brought the display to 30 Hz, where 24 fps judders.
|
|
34
|
+
*/
|
|
35
|
+
final class PreviewFrameRateVote {
|
|
36
|
+
private static final String TAG = "PreviewFrameRateVote";
|
|
37
|
+
|
|
38
|
+
private static final String SKIA_TEXTURE_VIEW = "com.reactnative.skia.SkiaTextureView";
|
|
39
|
+
|
|
40
|
+
// Canvases mounted while playing get the vote at the next refresh.
|
|
41
|
+
private static final long REFRESH_MILLIS = 1000;
|
|
42
|
+
|
|
43
|
+
private static final Set<PreviewFrameRateVote> voting = new HashSet<>();
|
|
44
|
+
|
|
45
|
+
private static long lastAppliedMillis = 0;
|
|
46
|
+
|
|
47
|
+
/** Whether the player is playing, at each vsync it collects frames on. */
|
|
48
|
+
void update(boolean playing) {
|
|
49
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (playing ? voting.add(this) : voting.remove(this)) {
|
|
53
|
+
apply();
|
|
54
|
+
} else if (playing && SystemClock.uptimeMillis() - lastAppliedMillis >= REFRESH_MILLIS) {
|
|
55
|
+
apply();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The player is released. */
|
|
60
|
+
void withdraw() {
|
|
61
|
+
update(false);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private static void apply() {
|
|
65
|
+
lastAppliedMillis = SystemClock.uptimeMillis();
|
|
66
|
+
ReactApplicationContext context = ReactNativeSkiaVideoModule.currentReactApplicationContext();
|
|
67
|
+
Activity activity = context != null ? context.getCurrentActivity() : null;
|
|
68
|
+
if (activity == null || activity.getWindow() == null) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
float rate = voting.isEmpty() ? View.REQUESTED_FRAME_RATE_CATEGORY_DEFAULT
|
|
72
|
+
: maxRefreshRate(activity.getWindow().getDecorView().getDisplay());
|
|
73
|
+
try {
|
|
74
|
+
vote(activity.getWindow().getDecorView(), rate);
|
|
75
|
+
} catch (RuntimeException e) {
|
|
76
|
+
Log.w(TAG, "Could not vote the refresh rate, leaving it to the display", e);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The highest rate a seamless switch from the current mode reaches.
|
|
81
|
+
private static float maxRefreshRate(Display display) {
|
|
82
|
+
if (display == null) {
|
|
83
|
+
return View.REQUESTED_FRAME_RATE_CATEGORY_HIGH;
|
|
84
|
+
}
|
|
85
|
+
Display.Mode mode = display.getMode();
|
|
86
|
+
float rate = mode.getRefreshRate();
|
|
87
|
+
for (float alternative : mode.getAlternativeRefreshRates()) {
|
|
88
|
+
rate = Math.max(rate, alternative);
|
|
89
|
+
}
|
|
90
|
+
return rate;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private static void vote(View view, float rate) {
|
|
94
|
+
if (SKIA_TEXTURE_VIEW.equals(view.getClass().getName())) {
|
|
95
|
+
view.setRequestedFrameRate(rate);
|
|
96
|
+
}
|
|
97
|
+
if (view instanceof ViewGroup) {
|
|
98
|
+
ViewGroup group = (ViewGroup) view;
|
|
99
|
+
for (int i = 0; i < group.getChildCount(); i++) {
|
|
100
|
+
vote(group.getChildAt(i), rate);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -14,6 +14,10 @@ public class VideoComposition {
|
|
|
14
14
|
private boolean lazyDecoders = false;
|
|
15
15
|
private boolean hardwareBufferEnabled = false;
|
|
16
16
|
public boolean isHardwareBufferEnabled() { return hardwareBufferEnabled; }
|
|
17
|
+
// framePixelFormat: 'rgba': every frame through the GL path, none through
|
|
18
|
+
// ImageReader's YUV buffers. Set from the C++ side.
|
|
19
|
+
private boolean rgbaFrames = false;
|
|
20
|
+
public boolean isRgbaFrames() { return rgbaFrames; }
|
|
17
21
|
|
|
18
22
|
public VideoComposition(
|
|
19
23
|
double duration,
|
|
@@ -687,10 +687,12 @@ public class VideoCompositionDecoder {
|
|
|
687
687
|
/**
|
|
688
688
|
* Hardware frames at the encoded size from an 8-bit SDR track skip the GL
|
|
689
689
|
* render pass: the codec writes into ImageReader buffers WebGPU samples.
|
|
690
|
-
* A capped or explicit size keeps the GL path, which scales while drawing
|
|
690
|
+
* A capped or explicit size keeps the GL path, which scales while drawing,
|
|
691
|
+
* as does `framePixelFormat: 'rgba'`.
|
|
691
692
|
*/
|
|
692
693
|
private boolean wantsNativeImages(VideoComposition.Item item, VideoCompositionItemDecoder decoder) {
|
|
693
|
-
if (!composition.isHardwareBufferEnabled() || !decoder.isEightBitSdr())
|
|
694
|
+
if (composition.isRgbaFrames() || !composition.isHardwareBufferEnabled() || !decoder.isEightBitSdr())
|
|
695
|
+
return false;
|
|
694
696
|
int[] size = FrameSize.of(item.getWidth(), item.getHeight(), item.getMaxLongSide(),
|
|
695
697
|
decoder.getVideoWidth(), decoder.getVideoHeight(), 0);
|
|
696
698
|
return size[0] == decoder.getVideoWidth() && size[1] == decoder.getVideoHeight();
|
|
@@ -32,6 +32,7 @@ public class VideoCompositionFramesExtractor {
|
|
|
32
32
|
private static final int PLAYBACK_LOOP = 4;
|
|
33
33
|
private static final int PLAYBACK_SEEK = 5;
|
|
34
34
|
private static final int PLAYBACK_RELEASE = 6;
|
|
35
|
+
private static final int PLAYBACK_VSYNC_RELEASE = 7;
|
|
35
36
|
|
|
36
37
|
private final VideoComposition composition;
|
|
37
38
|
|
|
@@ -57,6 +58,8 @@ public class VideoCompositionFramesExtractor {
|
|
|
57
58
|
private volatile boolean isPlaying = false;
|
|
58
59
|
|
|
59
60
|
private volatile long startTime = 0;
|
|
61
|
+
// The position playback last started or was sought to while playing.
|
|
62
|
+
private volatile long playStartPosition = 0;
|
|
60
63
|
private volatile long pausePosition = 0;
|
|
61
64
|
private boolean isEOS = false;
|
|
62
65
|
private boolean completeDispatched = false;
|
|
@@ -71,6 +74,7 @@ public class VideoCompositionFramesExtractor {
|
|
|
71
74
|
public void doFrame(long frameTimeNanos) {
|
|
72
75
|
if (releasing) {
|
|
73
76
|
unwatchDisplay();
|
|
77
|
+
frameRateVote.withdraw();
|
|
74
78
|
return;
|
|
75
79
|
}
|
|
76
80
|
recordedVsyncNanos = frameTimeNanos;
|
|
@@ -79,18 +83,21 @@ public class VideoCompositionFramesExtractor {
|
|
|
79
83
|
};
|
|
80
84
|
// The display the preview is on; null until the first collection, or when unknown.
|
|
81
85
|
private DisplayVsyncPeriod vsyncPeriod;
|
|
86
|
+
// The display's highest refresh rate while playing.
|
|
87
|
+
private final PreviewFrameRateVote frameRateVote = new PreviewFrameRateVote();
|
|
82
88
|
// When that thread last released frames; the playback loop stands back meanwhile.
|
|
83
89
|
private volatile long lastVsyncReleaseNanos = 0;
|
|
90
|
+
// The vsync the frames collected last are shown at; 0 until the first.
|
|
91
|
+
private volatile long collectedVsyncNanos = 0;
|
|
84
92
|
// The release itself runs on the playback thread: the decoder's locks are held
|
|
85
93
|
// there for whole codec flushes on a seek, and the UI thread must never
|
|
86
94
|
// wait on them (measured: 65 ms frames when it did).
|
|
87
95
|
private volatile long vsyncReleasePosition = 0;
|
|
88
96
|
private final AtomicBoolean vsyncReleasePosted = new AtomicBoolean(false);
|
|
89
|
-
private final Runnable vsyncRelease = this::runVsyncRelease;
|
|
90
97
|
|
|
91
|
-
private void
|
|
98
|
+
private void vsyncReleaseInternal() {
|
|
92
99
|
vsyncReleasePosted.set(false);
|
|
93
|
-
|
|
100
|
+
decoder.render(vsyncReleasePosition);
|
|
94
101
|
}
|
|
95
102
|
private static final long VSYNC_RELEASE_TIMEOUT_NANOS = 100_000_000L;
|
|
96
103
|
|
|
@@ -187,11 +194,13 @@ public class VideoCompositionFramesExtractor {
|
|
|
187
194
|
watchDisplay();
|
|
188
195
|
return;
|
|
189
196
|
}
|
|
197
|
+
frameRateVote.update(isPlaying);
|
|
190
198
|
long period = vsyncPeriod != null ? vsyncPeriod.getPeriodNanos() : 0;
|
|
191
199
|
if (period == 0 || recordedVsyncNanos == 0) return;
|
|
192
200
|
// This frame's vsync, whether the recorder has run in this frame yet or not.
|
|
193
201
|
long since = Math.max(0, System.nanoTime() - recordedVsyncNanos);
|
|
194
202
|
long vsyncNanos = recordedVsyncNanos + (since / period) * period;
|
|
203
|
+
collectedVsyncNanos = vsyncNanos;
|
|
195
204
|
if (vsyncNanos == lastVsyncNanos) return;
|
|
196
205
|
lastVsyncNanos = vsyncNanos;
|
|
197
206
|
long vsyncPeriodNanos = period;
|
|
@@ -201,7 +210,9 @@ public class VideoCompositionFramesExtractor {
|
|
|
201
210
|
lastVsyncReleaseNanos = System.nanoTime();
|
|
202
211
|
vsyncReleasePosition = position;
|
|
203
212
|
if (vsyncReleasePosted.compareAndSet(false, true)) {
|
|
204
|
-
|
|
213
|
+
// A message, not a posted runnable: it goes through handleMessage's
|
|
214
|
+
// releasing check and turns a throw into an error event.
|
|
215
|
+
handler.sendEmptyMessage(PLAYBACK_VSYNC_RELEASE);
|
|
205
216
|
}
|
|
206
217
|
}
|
|
207
218
|
|
|
@@ -241,6 +252,27 @@ public class VideoCompositionFramesExtractor {
|
|
|
241
252
|
return isPlaying ? microTime() - startTime : pausePosition;
|
|
242
253
|
}
|
|
243
254
|
|
|
255
|
+
/**
|
|
256
|
+
* The position the frames collected in this vsync were released for: the
|
|
257
|
+
* clock at the vsync rather than at the call, a few milliseconds later.
|
|
258
|
+
* drawFrame gets it as its time, so that the time it draws at and the frames
|
|
259
|
+
* it draws agree: read at the call, the time ran up to a frame ahead of the
|
|
260
|
+
* frames, at 60 fps on most vsyncs. Without vsync release (no display, or
|
|
261
|
+
* a stalled collector), the clock at the call.
|
|
262
|
+
*
|
|
263
|
+
* @return the position in microseconds
|
|
264
|
+
*/
|
|
265
|
+
public long getPresentedPosition() {
|
|
266
|
+
long vsyncNanos = collectedVsyncNanos;
|
|
267
|
+
if (!isPlaying || vsyncNanos == 0
|
|
268
|
+
|| System.nanoTime() - lastVsyncReleaseNanos > VSYNC_RELEASE_TIMEOUT_NANOS) {
|
|
269
|
+
return getCurrentPosition();
|
|
270
|
+
}
|
|
271
|
+
// Never before the play or seek that started this run: the vsync can
|
|
272
|
+
// precede it by up to a period.
|
|
273
|
+
return Math.max(vsyncNanos / 1000 - startTime, playStartPosition);
|
|
274
|
+
}
|
|
275
|
+
|
|
244
276
|
/**
|
|
245
277
|
* @return the version of the frames returned by {@link #decodeCompositionFrames},
|
|
246
278
|
* see {@link VideoCompositionDecoder#getFramesVersion()}
|
|
@@ -312,6 +344,7 @@ public class VideoCompositionFramesExtractor {
|
|
|
312
344
|
pausePosition = 0;
|
|
313
345
|
seekInternal(0);
|
|
314
346
|
}
|
|
347
|
+
playStartPosition = pausePosition;
|
|
315
348
|
startTime = microTime() - pausePosition;
|
|
316
349
|
isPlaying = true;
|
|
317
350
|
pausePosition = 0;
|
|
@@ -379,6 +412,7 @@ public class VideoCompositionFramesExtractor {
|
|
|
379
412
|
audioPlayer.seekTo(position);
|
|
380
413
|
}
|
|
381
414
|
if (isPlaying) {
|
|
415
|
+
playStartPosition = position;
|
|
382
416
|
startTime = microTime() - position;
|
|
383
417
|
} else {
|
|
384
418
|
pausePosition = position;
|
|
@@ -438,6 +472,10 @@ public class VideoCompositionFramesExtractor {
|
|
|
438
472
|
releaseInternal();
|
|
439
473
|
return true;
|
|
440
474
|
}
|
|
475
|
+
case PLAYBACK_VSYNC_RELEASE -> {
|
|
476
|
+
vsyncReleaseInternal();
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
441
479
|
default -> {
|
|
442
480
|
return false;
|
|
443
481
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# YUV ou RGBA : le format des frames décodées
|
|
2
|
+
|
|
3
|
+
Mesures du 21 octobre 2026 sur **iPhone 15 Pro** (iOS 27.0.1) et **Pixel 8a** (Android 17), builds Release, Skia 3.7.1 / WebGPU 0.17.0. Ce document explique l'option `framePixelFormat`, ce que chaque format coûte et rend, et les pièges trouvés en la mesurant. Le détail des runs est dans le [rapport de vérification beta.3](VERIFICATION_0.1.0-beta.3.md).
|
|
4
|
+
|
|
5
|
+
## En bref
|
|
6
|
+
|
|
7
|
+
- **`auto` vaut `yuv` sur les deux plateformes** ; `rgba` reste disponible.
|
|
8
|
+
- **Android** décode en YUV depuis le début (`ImageReader`), et c'est nettement le meilleur choix. Forcer `rgba` (la passe OpenGL) divise par deux à trois la vitesse des exports 4K, et fait perdre près des deux tiers des frames d'une preview 4K 60 fps.
|
|
9
|
+
- **iOS** décodait jusqu'ici en BGRA (VideoToolbox convertit chaque frame). En NV12, une frame 4K pèse **12,4 Mo au lieu de 33,2 Mo**. Le décodeur garde cinq frames d'avance au lieu d'une ou deux, la preview 4K perd moins de frames, et les cas qui dépassaient le budget mémoire passent. Seul coût mesuré : **environ 2 à 4 ms de plus par frame à l'export**, visible sur les exports longs ou à petite sortie.
|
|
10
|
+
- **Le rendu est le même** pour la vidéo de caméra (Rec.709 ou HLG). Un fichier étiqueté Display P3 ou avec la fonction de transfert sRGB est converti en Rec.709 par `rgba`, pas par `yuv`.
|
|
11
|
+
|
|
12
|
+
## L'option
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
const composition = { duration, items, framePixelFormat: 'yuv' }; // 'auto' | 'yuv' | 'rgba'
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
| | iOS | Android |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| `yuv` | AVAssetReader sort le NV12 du décodeur. La preview l'importe comme texture externe à chaque frame et le convertit en RGB dans une passe de rendu ; l'export le convertit dans une texture par clip | `ImageReader` (pistes 8 bits SDR à leur taille encodée ; les autres restent en `rgba`), converti en RGB par la même passe |
|
|
21
|
+
| `rgba` | VideoToolbox convertit en BGRA ; l'export GPU échantillonne l'IOSurface en place, sans copie | Passe OpenGL vers un `AHardwareBuffer` RGBA |
|
|
22
|
+
| `auto` (défaut) | `yuv` | `yuv` |
|
|
23
|
+
|
|
24
|
+
L'option ne concerne que les compositions (preview et export) : le lecteur d'un clip seul, `useVideoPlayer`, reste en BGRA sur iOS.
|
|
25
|
+
|
|
26
|
+
La conversion YUV → RGB applique la matrice YCbCr et la plage de chaque buffer, sans convertir la fonction de transfert ni les primaires : c'est ce qui évite d'éclaircir les tons moyens à chaque export (voir `yuvBlit.ts`). Le HDR est d'abord ramené en SDR Rec.709 par la plateforme, dans les deux formats.
|
|
27
|
+
|
|
28
|
+
## Mesures
|
|
29
|
+
|
|
30
|
+
### Décodeur seul (iOS)
|
|
31
|
+
|
|
32
|
+
XCTest `DecodeThroughputTests` : AVAssetReader lit les 360 frames d'un clip HEVC 4K 60 fps, sans React Native ni GPU, en alternant les formats sur trois tours. Téléphone en état thermique *serious*.
|
|
33
|
+
|
|
34
|
+
| Format | Débit | CPU par frame | Taille d'une frame |
|
|
35
|
+
| --- | --- | --- | --- |
|
|
36
|
+
| BGRA + propriétés Rec.709 | 105–127 fps | 0,42–0,47 ms | 33,2 Mo |
|
|
37
|
+
| NV12 + propriétés Rec.709 | 93–139 fps | 0,45–0,55 ms | 12,4 Mo |
|
|
38
|
+
| NV12 brut | 120–137 fps | 0,47–0,62 ms | 12,4 Mo |
|
|
39
|
+
|
|
40
|
+
La conversion BGRA est faite par le matériel et ne coûte presque rien en débit. La différence est la **mémoire**, et ce qu'elle permet : la file du décodeur de la preview est plafonnée à 64 Mo, soit une ou deux frames d'avance en BGRA 4K, cinq en NV12.
|
|
41
|
+
|
|
42
|
+
### Preview
|
|
43
|
+
|
|
44
|
+
Profil `sync` : chaque frame porte son numéro, lu à chaque vsync. « Exact » : part des vsyncs qui montrent la frame due ; « frames » : frames distinctes affichées.
|
|
45
|
+
|
|
46
|
+
**Pixel 8a**, 60 Hz, état thermique *nominal* :
|
|
47
|
+
|
|
48
|
+
| Cas | `yuv` | `rgba` |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| HEVC 4K 60 fps | **96,9 % exact**, 344/360 frames | 18,4 %, 132/360 |
|
|
51
|
+
| HEVC 4K 30 fps | **100 %**, 179/180 | 38,1 %, 123/180 |
|
|
52
|
+
| H.264 4K 30 fps | **98,8 %**, 177/180 | 38,4 %, 128/180 |
|
|
53
|
+
| Vraie vidéo 4K 60 fps (fleurs, 60 Mbit/s) | **335/360** | 145/360 |
|
|
54
|
+
| HEVC 1080p 60 fps | 99,4 % | 92,7 % |
|
|
55
|
+
| 30 fps, 24 fps, coupe | 99,7–100 % | 99,7–100 % |
|
|
56
|
+
|
|
57
|
+
**iPhone 15 Pro**, 120 Hz, quatre runs alternés `rgba` / `yuv` / `rgba` / `yuv`, tous en état *serious* (le téléphone n'a pas refroidi entre les runs ; l'alternance répartit cette chaleur sur les deux formats). Deux valeurs par format :
|
|
58
|
+
|
|
59
|
+
| Cas | `yuv` | `rgba` |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| HEVC 4K 60 fps (frames, sans lecture du numéro) | **311, 294** /360 | 241, 261 |
|
|
62
|
+
| HEVC 4K 60 fps (exact, une frame sur quatre lue) | **77 %, 90 %** | 46 %, 48 % |
|
|
63
|
+
| Vraie vidéo 4K 60 fps (frames) | **270, 327** /360 | 234, 284 |
|
|
64
|
+
| HEVC 4K 30 fps du benchmark (frames) | **235, 239** /240 | 223, 224 |
|
|
65
|
+
| Vraie vidéo 4K 30 fps (frames) | 173, 178 /180 | 173, 169 |
|
|
66
|
+
| 30, 60 fps et HEVC 1080p 60 fps (exact) | 73–95 % | 71–91 % |
|
|
67
|
+
|
|
68
|
+
En 1080p et en 30 fps, les écarts entre formats restent dans la dispersion d'un run à l'autre, très forte sur un téléphone chaud.
|
|
69
|
+
|
|
70
|
+
### Export
|
|
71
|
+
|
|
72
|
+
Chaque frame exportée a été relue : la bonne frame source dans tous les cas, dans les deux formats, sur les deux téléphones.
|
|
73
|
+
|
|
74
|
+
| Cas | Pixel `yuv` | Pixel `rgba` | iPhone `yuv` | iPhone `rgba` |
|
|
75
|
+
| --- | --- | --- | --- | --- |
|
|
76
|
+
| 4K 60 → 4K 60 | **5,0 s** | 15,6 s | 4,2–4,3 s | 4,1–4,5 s |
|
|
77
|
+
| 4K 30 → 4K 30 | **3,2 s** | 8,7 s | 2,3 s | 2,4–2,7 s |
|
|
78
|
+
| 4K 60 → 1080p 30 | **2,4–2,9 s** | 8,0–8,2 s | 1,4–2,2 s | 1,7 s |
|
|
79
|
+
| HEVC 1080p 60 → 640×360 | 4,1 s | 8,9 s | 0,9–1,4 s | **0,5 s** |
|
|
80
|
+
| Montage de 120 clips (suite `full`) | **63,5 s** | 95,9 s | 18,1 s | **14,0 s** |
|
|
81
|
+
|
|
82
|
+
Sur iOS, l'export en `yuv` importe chaque frame NV12 et la convertit dans une texture, là où le BGRA est lu en place. Ce surcoût d'environ 2 à 4 ms par frame est invisible en 4K, où le décodage domine, mais pèse sur les sorties petites ou les exports longs. Pour un export iOS où la vitesse prime sur la mémoire, `framePixelFormat: 'rgba'` reste le plus rapide.
|
|
83
|
+
|
|
84
|
+
### Mémoire
|
|
85
|
+
|
|
86
|
+
Pics de la suite `full` (une répétition par format). Pixel : PSS ; iPhone : empreinte physique.
|
|
87
|
+
|
|
88
|
+
| Cas | Pixel `yuv` | Pixel `rgba` | iPhone `yuv` | iPhone `rgba` |
|
|
89
|
+
| --- | --- | --- | --- | --- |
|
|
90
|
+
| Lecture continue 4K | **438–458 Mio** | 554–602 | **336–343 Mio** | 400–405 |
|
|
91
|
+
| Clip seul 4K | 698–707 | 795–830 | **601–639** | 657–677 |
|
|
92
|
+
| 8 clips 4K, paresseux | **766–785** | 1 020–1 037 | **571–573** | 670–683 |
|
|
93
|
+
| 3 clips 4K simultanés | 1 507 | 1 314 | **734, passe** | arrêté par le budget |
|
|
94
|
+
| 8 clips 4K, eager | 1 562, passe | arrêté par le budget | 1 089, passe | arrêté par le budget |
|
|
95
|
+
|
|
96
|
+
Les cas « eager » qui s'arrêtaient sur le budget en `rgba` vont jusqu'au bout en `yuv`, d'où une mémoire plus haute sur ceux-là.
|
|
97
|
+
|
|
98
|
+
## Rendu
|
|
99
|
+
|
|
100
|
+
Trois contrôles, la même frame décodée dans chaque format :
|
|
101
|
+
|
|
102
|
+
- **Mire SMPTE** (H.264 et HEVC 1080p, étiquetées Rec.709) comparée à ffmpeg : écart maximal de **0 niveau en `yuv`, 1 en `rgba`**, sur les deux téléphones.
|
|
103
|
+
- **Vraie vidéo** : trois clips tirés de photos d'appareil (panoramique, grain, débits de téléphone, étiquetés Rec.709) comparés à ffmpeg sur 24 points à trois instants. Écart moyen de **0,07 à 0,16 en `yuv`** sur les deux téléphones (max 1). En `rgba` : 0,15 à 0,48 sur le Pixel, 0,5 à 0,8 sur l'iPhone (max 5).
|
|
104
|
+
- **Fixtures du benchmark** (rotations, HDR HLG 10 bits, portrait, 638×358, slow motion) : `yuv` contre `rgba`, écart moyen de 0 à 0,8 niveau. Les maxima de 5 à 17 niveaux sont ponctuels, sur des bords nets entre aplats saturés, où VideoToolbox (ou la passe OpenGL) et la passe GPU n'interpolent pas la chrominance de la même façon.
|
|
105
|
+
|
|
106
|
+
## Pièges trouvés
|
|
107
|
+
|
|
108
|
+
**VideoToolbox réétiquette sans convertir.** Avec une sortie NV12, demander les propriétés Rec.709 fait étiqueter le buffer en BT.709 sans convertir les échantillons. Pour une vidéo SD sans étiquette, codée en BT.601, le GPU appliquait alors la mauvaise matrice : le bleu d'un jaune décalé de 11 niveaux, 4,7 en moyenne sur un clip 320×240. Le XCTest `ColorMatrixDiagnosticsTests` le montre : le BGRA de VideoToolbox correspond au NV12 converti en BT.601 (écart 0,4), pas en BT.709 (6,3). Correction : en NV12, seules les pistes HDR ou BT.2020 reçoivent ces propriétés (pour le tone mapping) ; les autres gardent l'étiquette réelle du décodeur, que le GPU applique. Écart après correction : max 1 (638×358) et 3 (320×240), moyenne 0,15 à 0,32.
|
|
109
|
+
|
|
110
|
+
**Les étiquettes Display P3 et sRGB.** `rgba` convertit vers Rec.709 ce que le fichier déclare (primaires P3, fonction de transfert sRGB) : VideoToolbox sur iOS, la passe OpenGL sur Android. `yuv` n'applique que la matrice et la plage. Sur un clip étiqueté P3 (fixture `tagged-p3-flowers`), `yuv` reste à 0,2 niveau de ffmpeg (qui ignore aussi les primaires), `rgba` s'en écarte de 5,8 en moyenne sur l'iPhone (max 26) et de 1,3 sur le Pixel (max 16). La vidéo de caméra n'est pas concernée ; certains enregistrements d'écran ou exports d'éditeurs le sont.
|
|
111
|
+
|
|
112
|
+
**Les fixtures héritaient des étiquettes des photos.** ffmpeg recopiait dans les clips de test les primaires P3, la fonction de transfert sRGB ou le profil ICC des photos sources, malgré `-color_primaries bt709`. Le script force désormais les étiquettes avec `setparams` et retire le profil ICC, puis les vérifie avec `ffprobe`.
|
|
113
|
+
|
|
114
|
+
**La file de décodage iOS tournait en priorité *utility*.** Sur un iPhone chaud, iOS la reléguait sur les cœurs basse consommation : des gels de 300 à 500 ms. Elle tourne désormais en *user-interactive* ; le même run passe de 56 à 244 frames sur 360 en 4K 60 fps.
|
|
115
|
+
|
|
116
|
+
## Recommandation
|
|
117
|
+
|
|
118
|
+
Garder `auto` (`yuv`). Choisir `rgba` sur iOS seulement pour :
|
|
119
|
+
- un export où la vitesse compte plus que la mémoire ;
|
|
120
|
+
- des fichiers étiquetés Display P3 ou sRGB dont on veut la conversion colorimétrique.
|
|
121
|
+
|
|
122
|
+
Pistes pour la suite :
|
|
123
|
+
- réduire le coût de l'import NV12 à l'export sur iOS, par exemple en réutilisant les imports d'un même IOSurface ;
|
|
124
|
+
- mesurer à froid, sur plusieurs répétitions, les écarts 1080p de l'iPhone qui restent dans le bruit.
|
package/docs/UPSTREAM_NOTES.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Points amont à revoir lors d'une mise à jour Skia
|
|
2
2
|
|
|
3
|
+
**Mise à jour du 20 octobre 2026 :** **`react-native-skia@3.7.1`**, toujours avec `react-native-webgpu@0.17.0` et Dawn/Graphite 157.0.0. La 3.7.1 ne contient qu'une correction iOS ([#4273](https://github.com/wcandillon/react-native-skia/pull/4273)) : `SkiaView.mm` présente désormais à la pleine fréquence de l'écran sur les appareils ProMotion. Les sept fichiers modifiés par le patch local sont identiques octet pour octet à ceux de la 3.7.0 : le patch s'applique tel quel, seuls sa version et son nom changent. Nos PR amont [#4274](https://github.com/wcandillon/react-native-skia/pull/4274), [#4275](https://github.com/wcandillon/react-native-skia/pull/4275) et [#4276](https://github.com/wcandillon/react-native-skia/pull/4276) sont encore ouvertes : le patch ne rétrécit pas. Vérifié le 20 octobre sur appareils, en Release : iPhone 15 Pro, tests natifs 50/50 et profil smoke 15/15 (l'app déclare Skia 3.7.1, écran à 120 Hz) ; Pixel 8a, contrôle `interop` 10/10 (erreur maximale par canal de 1, comme en 3.7.0, chemins GPU et readback d'accord à l'export) et smoke 15/15. Les mesures d'export de la 3.7.0 restent la référence : le code d'export ne change pas.
|
|
4
|
+
|
|
3
5
|
**Migration du 19 octobre 2026 :** le code et les dépendances déclarées ciblent désormais **`react-native-skia@3.7.0` / `react-native-webgpu@0.17.0`**, sur Graphite **157.0.0** et **`react-native-webgpu-dawn@157.0.0`** (Skia `chrome/m157`). Les deux paquets doivent monter ensemble : Gradle et le podspec refusent deux Dawn différents. Le patch local garde 61 de ses 62 éditions ; seule tombe l'include `<stdexcept>`, désormais présent en amont. Le Dawn 157 demande trois images FIFO à la swapchain Android, ce qui supprime l'attente du thread principal dans `dequeueBuffer` sur Android 10 ; il garde aussi la robustesse des shaders sur Android, nos WGSL compris. L'[audit](../migration/skia-3.7.0/upstream-audit.md) détaille ces changements ; la [vérification](../migration/skia-3.7.0/verification.md) donne les mesures sur iPhone 15 Pro et Pixel 8a. Après une mise à jour, supprimer le bundle JS généré par Gradle avant de construire un APK de qualification : sa tâche ne voit pas le `node_modules` racine.
|
|
4
6
|
|
|
5
7
|
**Migration du 16 octobre 2026 :** le code et les dépendances déclarées ciblaient alors **`react-native-skia@3.6.1` / `react-native-webgpu@0.16.0`**, toujours sur Graphite **154.3.0** et **`react-native-webgpu-dawn@154.3.0`** : les binaires Skia et Dawn ne changent pas. L'[audit](../migration/skia-3.6.1/upstream-audit.md) détaille les changements amont, la régression de couleurs des frames décodées Android introduite par WebGPU 0.15 et sa correction, puis l'import en place à l'export Android. La [vérification](../migration/skia-3.6.1/verification.md) donne les mesures sur iPhone 15 Pro et Pixel 8a. **À chaque mise à jour WebGPU, relancer le contrôle `interop` du Pixel** : il compare les pixels décodés et exportés au chemin CPU, et c'est lui qui a révélé cette régression.
|