react-native-skia-webgpu-video 0.1.0-beta.2 → 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.
Files changed (42) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +28 -7
  3. package/android/cpp/VideoComposition.cpp +12 -0
  4. package/android/cpp/VideoCompositionFramesExtractor.cpp +6 -0
  5. package/android/cpp/VideoCompositionFramesExtractor.h +1 -0
  6. package/android/cpp/VideoCompositionFramesExtractorHostObject.cpp +1 -1
  7. package/android/src/main/java/com/azzapp/rnskv/PreviewFrameRateVote.java +104 -0
  8. package/android/src/main/java/com/azzapp/rnskv/VideoComposition.java +4 -0
  9. package/android/src/main/java/com/azzapp/rnskv/VideoCompositionDecoder.java +4 -2
  10. package/android/src/main/java/com/azzapp/rnskv/VideoCompositionFramesExtractor.java +32 -0
  11. package/docs/FRAME_PIXEL_FORMAT.md +124 -0
  12. package/docs/VERIFICATION_0.1.0-beta.3.md +147 -0
  13. package/ios/NativeResourceStats.h +9 -0
  14. package/ios/RNSVColorSpace.h +35 -4
  15. package/ios/ReactNativeSkiaVideo.mm +4 -0
  16. package/ios/VideoComposition.h +20 -0
  17. package/ios/VideoCompositionFramesExtractorHostObject.h +5 -0
  18. package/ios/VideoCompositionFramesExtractorHostObject.mm +31 -2
  19. package/ios/VideoCompositionItemDecoder.mm +21 -8
  20. package/ios/VideoFrame.mm +6 -0
  21. package/lib/module/exportFrames.js +25 -18
  22. package/lib/module/exportFrames.js.map +1 -1
  23. package/lib/module/frameInterop.js +7 -1
  24. package/lib/module/frameInterop.js.map +1 -1
  25. package/lib/module/videoCompositionPlayer.js +13 -5
  26. package/lib/module/videoCompositionPlayer.js.map +1 -1
  27. package/lib/module/yuvBlit.js +70 -0
  28. package/lib/module/yuvBlit.js.map +1 -1
  29. package/lib/typescript/src/exportFrames.d.ts +8 -8
  30. package/lib/typescript/src/exportFrames.d.ts.map +1 -1
  31. package/lib/typescript/src/frameInterop.d.ts.map +1 -1
  32. package/lib/typescript/src/types.d.ts +26 -0
  33. package/lib/typescript/src/types.d.ts.map +1 -1
  34. package/lib/typescript/src/videoCompositionPlayer.d.ts.map +1 -1
  35. package/lib/typescript/src/yuvBlit.d.ts +10 -0
  36. package/lib/typescript/src/yuvBlit.d.ts.map +1 -1
  37. package/package.json +1 -1
  38. package/src/exportFrames.ts +52 -34
  39. package/src/frameInterop.ts +18 -1
  40. package/src/types.ts +27 -0
  41. package/src/videoCompositionPlayer.ts +14 -4
  42. package/src/yuvBlit.ts +66 -0
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
@@ -45,6 +45,8 @@ 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:
@@ -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, 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 |
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
- | 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. |
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 | 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. |
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
@@ -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()
@@ -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()) return false;
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();
@@ -58,6 +58,8 @@ public class VideoCompositionFramesExtractor {
58
58
  private volatile boolean isPlaying = false;
59
59
 
60
60
  private volatile long startTime = 0;
61
+ // The position playback last started or was sought to while playing.
62
+ private volatile long playStartPosition = 0;
61
63
  private volatile long pausePosition = 0;
62
64
  private boolean isEOS = false;
63
65
  private boolean completeDispatched = false;
@@ -72,6 +74,7 @@ public class VideoCompositionFramesExtractor {
72
74
  public void doFrame(long frameTimeNanos) {
73
75
  if (releasing) {
74
76
  unwatchDisplay();
77
+ frameRateVote.withdraw();
75
78
  return;
76
79
  }
77
80
  recordedVsyncNanos = frameTimeNanos;
@@ -80,8 +83,12 @@ public class VideoCompositionFramesExtractor {
80
83
  };
81
84
  // The display the preview is on; null until the first collection, or when unknown.
82
85
  private DisplayVsyncPeriod vsyncPeriod;
86
+ // The display's highest refresh rate while playing.
87
+ private final PreviewFrameRateVote frameRateVote = new PreviewFrameRateVote();
83
88
  // When that thread last released frames; the playback loop stands back meanwhile.
84
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;
85
92
  // The release itself runs on the playback thread: the decoder's locks are held
86
93
  // there for whole codec flushes on a seek, and the UI thread must never
87
94
  // wait on them (measured: 65 ms frames when it did).
@@ -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;
@@ -243,6 +252,27 @@ public class VideoCompositionFramesExtractor {
243
252
  return isPlaying ? microTime() - startTime : pausePosition;
244
253
  }
245
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
+
246
276
  /**
247
277
  * @return the version of the frames returned by {@link #decodeCompositionFrames},
248
278
  * see {@link VideoCompositionDecoder#getFramesVersion()}
@@ -314,6 +344,7 @@ public class VideoCompositionFramesExtractor {
314
344
  pausePosition = 0;
315
345
  seekInternal(0);
316
346
  }
347
+ playStartPosition = pausePosition;
317
348
  startTime = microTime() - pausePosition;
318
349
  isPlaying = true;
319
350
  pausePosition = 0;
@@ -381,6 +412,7 @@ public class VideoCompositionFramesExtractor {
381
412
  audioPlayer.seekTo(position);
382
413
  }
383
414
  if (isPlaying) {
415
+ playStartPosition = position;
384
416
  startTime = microTime() - position;
385
417
  } else {
386
418
  pausePosition = position;
@@ -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.
@@ -0,0 +1,147 @@
1
+ # Vérification de la 0.1.0-beta.3 — 21 octobre 2026
2
+
3
+ Ce document liste ce qui a **réellement** été exécuté pour cette version :
4
+ - sur l'hôte ;
5
+ - sur **iPhone 15 Pro** (iOS 27.0.1) ;
6
+ - sur **Pixel 8a** (Android 17).
7
+
8
+ Tous les builds sont en Release, sur Skia 3.7.1 / WebGPU 0.17.0, inchangés depuis la beta.2. L'analyse du format des frames est dans [FRAME_PIXEL_FORMAT.md](FRAME_PIXEL_FORMAT.md).
9
+
10
+ ## Ce qui change
11
+
12
+ - **Horloge de `drawFrame`** (iOS, Android) : `currentTime` vaut l'heure à laquelle les frames dessinées ont été choisies. Lue plus tard, elle avançait jusqu'à une frame sur la vidéo à 60 fps, et les overlays calculés d'après elle étaient décalés d'autant.
13
+ - **`splitImportAndDraw`** ne perd plus une frame sur deux quand une nouvelle frame arrive à chaque vsync.
14
+ - **Android 15+** : une preview en lecture demande à l'écran sa fréquence maximale. Le Pixel ne descend plus à 30 Hz, et monte à 120 Hz quand le réglage de l'utilisateur le permet.
15
+ - **iOS** : la file de décodage de la preview tourne en priorité *user-interactive* au lieu d'*utility*.
16
+ - **`framePixelFormat`** (`'auto' | 'yuv' | 'rgba'`) : `auto` vaut `yuv` sur les deux plateformes. Sur iOS, le décodeur sort désormais du NV12 (12,4 Mo par frame 4K au lieu de 33,2 Mo de BGRA). `rgba` reste disponible.
17
+ - **Diagnostic** : `getResourceStats()` donne sur iOS les refus de l'anneau de frames, les files de décodage vides et les ticks de décodage lents.
18
+ - **App de test** : profil `sync` (exactitude des frames, seeks, exports, couleurs, rendu de vrais médias), option `--frame-pixel-format` des lanceurs, `--case` pour iOS, clé `CADisableMinimumFrameDurationOnPhone` (120 Hz sur ProMotion).
19
+
20
+ ## Hôte
21
+
22
+ | Contrôle | Résultat |
23
+ | --- | --- |
24
+ | Jest | **237/237 tests, 11 suites** (dont les chemins NV12 de la preview et de l'export) |
25
+ | `tsc --noEmit`, `test:package`, `benchmark:test` | OK, 5/5, 122/122 |
26
+ | `test:native` (C++ hôte, validation native, syntaxe des 50 assertions iOS historiques) | OK |
27
+ | Tests JVM | 27/27 |
28
+ | `test-skia-patch-syntax.mjs`, `test-skia-provider-ownership.mjs` | OK |
29
+ | `npm run build`, `check-package` (publint strict) | OK |
30
+
31
+ ## Exactitude de la lecture (profil `sync`)
32
+
33
+ Chaque frame des fixtures porte son numéro en code-barres, lu à chaque vsync dans `drawFrame` et comparé à l'horloge du player. « Exact » : part des vsyncs qui montrent la frame due. « Irr » : part des changements de frame décalés de plus de 3/4 de vsync. Écran à 60 Hz.
34
+
35
+ | Cas | Pixel, avant | Pixel, beta.3 | iPhone, avant | iPhone, beta.3 |
36
+ | --- | --- | --- | --- | --- |
37
+ | 30 fps | 93 % · irr 1,8 % | **99,4 %** · irr 1,8 % | 100 % | 100 % |
38
+ | 30 fps, `splitImportAndDraw` | 79 % · irr 81 % · 75 frames sautées | **99 %** · irr 0 % · 2 | 100 % | 100 % |
39
+ | 24 fps | 85 % · irr 21 % | **100 %** · irr 4,6 % | 100 % | 100 % |
40
+ | 60 fps | 51 % · 166 sautées | **99,4 %** · 2 | 80 % · 9 sautées | **100 %** |
41
+ | HEVC 1080p 60 fps | 66 % | **98,8 %** | 99,7 % (0,3 % au premier run) | **100 %** |
42
+
43
+ « Avant » : le code de `main` avec le seul profil `sync` ajouté. Les 2 à 9 frames sautées restantes tombent au démarrage d'une lecture.
44
+
45
+ **4K, `yuv` (défaut) contre `rgba`** :
46
+
47
+ | Cas | Pixel `rgba` | Pixel `yuv` | iPhone `rgba` | iPhone `yuv` |
48
+ | --- | --- | --- | --- | --- |
49
+ | HEVC 4K 60 fps | 18 % · irr 65 % · 205 sautées | **97 % · irr 0,3 %** · 9 | 66 % · irr 6,5 % · 64 | **97 % · irr 1,3 %** · 9 |
50
+ | HEVC 4K 30 fps | 38 % · irr 52 % | **100 %** · irr 0 % | 97 % · irr 1,9 % | **100 %** · irr 0 % |
51
+ | H.264 4K 30 fps | 38 % · irr 48 % | **99 %** · irr 0,6 % | 96 % · irr 6,9 % | **100 %** · irr 1,2 % |
52
+ | Vraie vidéo 4K 60 fps, 60 Mbit/s (frames distinctes) | 145/360 | **335/360** | 268/360 | 267/360 |
53
+
54
+ Le Pixel est resté à l'état thermique *nominal*. Les deux runs iPhone de ce tableau ont tourné téléphone chaud, *fair* pour `rgba` et *serious* pour `yuv`. Quatre runs alternés `rgba` / `yuv` / `rgba` / `yuv`, tous en *serious*, confirment l'avantage du `yuv` en 4K : en HEVC 4K 60 fps, 294–311 frames sur 360 contre 241–261, et 77–90 % d'exact contre 46–48 % en lisant une frame sur quatre. En 1080p et en 30 fps, les écarts restent dans la dispersion entre runs (73 à 95 % d'exact dans les deux formats). Détail dans [FRAME_PIXEL_FORMAT.md](FRAME_PIXEL_FORMAT.md#preview).
55
+
56
+ Les **seeks** tombent juste sur les deux plateformes : 22/22 sur chaque téléphone, y compris à ±0,5 ms d'une limite de frame. Sur Android, un seek en pause montre les frames intermédiaires décodées depuis la keyframe avant la bonne ; l'iPhone garde l'image précédente. Ce comportement n'a pas changé.
57
+
58
+ ## Exports
59
+
60
+ Chaque frame exportée porte son numéro de sortie sous le code-barres source, et est relue après l'export. **Tous les exports sont exacts sur les deux téléphones et dans les deux formats** (30→30, 60→30, 30→60, 24→30, HEVC 60→60, coupe, 4K→4K en 30 et 60 fps, 4K→1080p) : la bonne frame source, aucune frame perdue ou dupliquée, et le décodeur du système compte le bon nombre de frames dans chaque fichier.
61
+
62
+ | Pixel 8a | `yuv` | `rgba` |
63
+ | --- | --- | --- |
64
+ | 4K 60 → 4K 60 | **5,0 s** | 15,6 s |
65
+ | 4K 30 → 4K 30 | **3,2 s** | 8,7 s |
66
+ | 4K 60 → 1080p 30 | **2,4–2,9 s** | 8,0–8,2 s |
67
+
68
+ | iPhone 15 Pro, runs alternés en *serious* | `yuv` | `rgba` |
69
+ | --- | --- | --- |
70
+ | 4K 60 → 4K 60 | 4,2–4,3 s | 4,1–4,5 s |
71
+ | 4K 30 → 4K 30 | 2,3 s | 2,4–2,7 s |
72
+ | HEVC 1080p 60 → 640×360 | 0,9–1,4 s | **0,5 s** |
73
+ | Montage de 120 clips (suite `full`) | 18,1 s | **14,0 s** |
74
+
75
+ Sur iOS, l'export `yuv` importe et convertit chaque frame NV12 au lieu de lire le BGRA en place : environ 2 à 4 ms de plus par frame, sensibles sur les exports longs ou à petite sortie.
76
+
77
+ ## Rendu et couleurs
78
+
79
+ - **Mire SMPTE** (H.264 et HEVC 1080p, Rec.709) comparée à ffmpeg : écart maximal de 0 niveau en `yuv`, 1 en `rgba`, sur les deux téléphones.
80
+ - **Vraie vidéo** (trois clips tirés de photos d'appareil, étiquetés Rec.709) comparée à 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).
81
+ - **Clip étiqueté Display P3** : `yuv` reste à 0,2 de ffmpeg ; `rgba` convertit le gamut (iPhone : moyenne 5,8, max 26 ; Pixel : 1,3, max 16).
82
+ - **Fixtures du benchmark** (rotations, HDR HLG, portrait, SD, slow motion), `yuv` contre `rgba` : écart moyen de 0 à 0,8 niveau. Les maxima ponctuels de 5 à 17 sont sur des bords nets.
83
+ - **Pixel, `interop`** : décodage GPU contre CPU **10/10**, écarts maximaux 1,1,0,0,1,1,0,0,1,1, identiques à la 3.7.0. Export GPU contre readback : écart 0 sur la mire et sur la vidéo.
84
+
85
+ Correction faite pendant la vérification : en NV12, VideoToolbox réétiquetait en BT.709 une vidéo SD codée en BT.601 sans convertir ses échantillons (le bleu d'un jaune décalé de 11 niveaux). Seules les pistes HDR ou BT.2020 reçoivent désormais les propriétés de conversion. Voir [FRAME_PIXEL_FORMAT.md](FRAME_PIXEL_FORMAT.md#pièges-trouvés).
86
+
87
+ ## Suite `full` (65 cas, 1 répétition par format)
88
+
89
+ | | Réussis | Échecs | Arrêtés par le budget | Non exécutés |
90
+ | --- | --- | --- | --- | --- |
91
+ | Pixel `rgba` (26 min, nominal) | 57 | 4 | — | 4 |
92
+ | Pixel `yuv` (24 min, nominal) | **58** | 3 | — | 4 |
93
+ | iPhone `rgba` (17 min, *fair* puis *serious*) | 58 | — | 3 | 4 |
94
+ | iPhone `yuv` (20 min, *nominal* à *serious*) | **61** | — | — | 4 |
95
+
96
+ Les 4 cas non exécutés sont les extensions LUT, compute, Three.js et Core ML, non branchées dans l'app.
97
+
98
+ **Pixel.** Les trois échecs communs aux deux formats existaient déjà dans la beta.2 : le build de `main` (`1eeaa3b`) échoue aux mêmes cas, 2 répétitions sur 2, avec les mêmes symptômes.
99
+
100
+ | Cas | Symptôme, `main` comme beta.3 |
101
+ | --- | --- |
102
+ | `seek-while-playing` | Après un seek en lecture dans une séquence 4K, l'horloge reste vers 4,0 s pendant 4 s |
103
+ | `long-montage-120-clips` | Après un seek en pause dans un montage paresseux de 120 clips, la frame du clip 83 n'arrive pas |
104
+ | `missing-file` | Android 17 répond « Failed to instantiate extractor », que le harness ne reconnaît pas comme une erreur de fichier manquant |
105
+
106
+ Le quatrième échec en `rgba`, `4k-x8-eager`, dépasse le budget mémoire ; il passe en `yuv`.
107
+
108
+ | Pixel, `rgba` → `yuv` | Export | Mémoire max (PSS) |
109
+ | --- | --- | --- |
110
+ | 8 clips 4K, paresseux | 29,0 → **12,9 s** | 1 020 → **766 Mio** |
111
+ | Montage en fondu, eager | 27,3 → **14,4 s** | 1 468 → **1 029 Mio** |
112
+ | 3 clips 4K simultanés | 23,2 → **7,3 s** | 1 314 → 1 507 Mio |
113
+ | Exports simples (`encode-*`) | 2,4–2,6 → **1,7–1,9 s** | ≈ égal |
114
+ | Lecture continue 4K | — | 554–602 → **438–458 Mio** |
115
+
116
+ **iPhone.** En `rgba`, trois cas s'arrêtent sur le budget de la file du décodeur : `montage-all-crossfade-eager`, `4k-x8-eager` et `4k-x3-simultaneous` (problème connu des versions précédentes). **En `yuv`, les trois passent**, et aucun cas n'échoue.
117
+
118
+ | iPhone, `rgba` → `yuv` | Mémoire physique max | Export |
119
+ | --- | --- | --- |
120
+ | Lecture continue 4K | 400–405 → **336–343 Mio** | — |
121
+ | 8 clips 4K, paresseux | 670–683 → **571–573 Mio** | 4,4–4,5 → 4,5–4,9 s |
122
+ | 3 clips 4K simultanés | budget dépassé → **734 Mio, passe** | — |
123
+ | Montage en fondu, paresseux | 650 → **585 Mio** | 4,3 → 3,6 s |
124
+ | 20 clips courts | ≈ | 3,3 → **2,5 s** |
125
+ | Montage de 120 clips | ≈ | **14,0** → 18,1 s |
126
+
127
+ ## Tests natifs
128
+
129
+ - **iPhone, XCTest** : **56/58**, les deux autres étant des diagnostics qui ne tournent qu'à la demande. Six nouveaux tests : chaque frame d'un clip et d'un montage lues en NV12, le format annoncé par chaque frame, et la lecture de `framePixelFormat` (`auto` et `yuv` en NV12, `rgba` en BGRA, valeur inconnue refusée). Les deux diagnostics ont été lancés à part (débit du décodeur par format, étiquettes de couleur des clips sans étiquette).
130
+ - **Pixel, tests instrumentés** : **126/127** dans la suite complète. L'échec, `BigMontageTest.aLazyPreviewOfEight4KClipsPlaysThrough`, figure dans la référence connue (il échouait déjà sur `main` dans la suite complète). Lancée seule juste après, sur un Pixel aux cœurs à 85 °C après des heures de 4K, la classe échoue 2 fois sur 5 : le seuil de 150 frames à l'heure en 4K, et un `OutOfMemoryError` du tas Java après l'export eager de 8 clips 4K. Ces tests pilotent le décodeur Java sans `Looper`, chemin que cette version ne modifie pas. Ils n'ont pas été relancés à froid avant la release : l'attribution à la chaleur n'est pas confirmée.
131
+
132
+ ## Runs écartés
133
+
134
+ - `iphone-full-rgba-interrupted-call` : appel téléphonique pendant le run.
135
+ - `iphone-full-yuv-interrupted-phone-in-use` : téléphone utilisé pendant le run.
136
+ - Les runs `sync` iPhone antérieurs à la correction des couleurs SD et à l'étiquetage forcé des clips photo ne servent pas pour le rendu.
137
+
138
+ Les résultats sont dans `benchmark/results/*.local.json`, fichiers locaux non versionnés.
139
+
140
+ ## Toujours non qualifié
141
+
142
+ - Mémoire sur de longues sessions (dernière mesure en 3.0.6).
143
+ - HDR au-delà de la conversion HLG → SDR, son audible et synchronisation audio/vidéo.
144
+ - Les trois bugs Android de la suite `full` ci-dessus, et le seek dans une composition 4K paresseuse sur Pixel.
145
+ - Vraie vidéo 4K 60 fps à 60 Mbit/s sur iPhone chaud : 65 à 91 % des frames affichées selon les runs.
146
+ - Export iOS en `yuv` : environ 2 à 4 ms de plus par frame qu'en `rgba` (à optimiser).
147
+ - Aucune mesure iPhone à l'état thermique *nominal* pour la comparaison des formats : le téléphone est resté chaud toute la journée.