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.
Files changed (47) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +32 -11
  3. package/android/BUILD_VALIDATION.md +1 -1
  4. package/android/cpp/VideoComposition.cpp +12 -0
  5. package/android/cpp/VideoCompositionFramesExtractor.cpp +6 -0
  6. package/android/cpp/VideoCompositionFramesExtractor.h +1 -0
  7. package/android/cpp/VideoCompositionFramesExtractorHostObject.cpp +1 -1
  8. package/android/src/main/java/com/azzapp/rnskv/PreviewFrameRateVote.java +104 -0
  9. package/android/src/main/java/com/azzapp/rnskv/VideoComposition.java +4 -0
  10. package/android/src/main/java/com/azzapp/rnskv/VideoCompositionDecoder.java +4 -2
  11. package/android/src/main/java/com/azzapp/rnskv/VideoCompositionFramesExtractor.java +42 -4
  12. package/docs/FRAME_PIXEL_FORMAT.md +124 -0
  13. package/docs/UPSTREAM_NOTES.md +2 -0
  14. package/docs/VERIFICATION_0.1.0-beta.3.md +147 -0
  15. package/ios/NativeResourceStats.h +9 -0
  16. package/ios/RNSVColorSpace.h +35 -4
  17. package/ios/ReactNativeSkiaVideo.mm +4 -0
  18. package/ios/VideoComposition.h +20 -0
  19. package/ios/VideoCompositionFramesExtractorHostObject.h +5 -0
  20. package/ios/VideoCompositionFramesExtractorHostObject.mm +31 -2
  21. package/ios/VideoCompositionItemDecoder.mm +21 -8
  22. package/ios/VideoFrame.mm +6 -0
  23. package/lib/module/exportFrames.js +25 -18
  24. package/lib/module/exportFrames.js.map +1 -1
  25. package/lib/module/frameInterop.js +7 -1
  26. package/lib/module/frameInterop.js.map +1 -1
  27. package/lib/module/videoCompositionPlayer.js +13 -5
  28. package/lib/module/videoCompositionPlayer.js.map +1 -1
  29. package/lib/module/yuvBlit.js +70 -0
  30. package/lib/module/yuvBlit.js.map +1 -1
  31. package/lib/typescript/src/exportFrames.d.ts +8 -8
  32. package/lib/typescript/src/exportFrames.d.ts.map +1 -1
  33. package/lib/typescript/src/frameInterop.d.ts.map +1 -1
  34. package/lib/typescript/src/types.d.ts +26 -0
  35. package/lib/typescript/src/types.d.ts.map +1 -1
  36. package/lib/typescript/src/videoCompositionPlayer.d.ts +5 -3
  37. package/lib/typescript/src/videoCompositionPlayer.d.ts.map +1 -1
  38. package/lib/typescript/src/yuvBlit.d.ts +10 -0
  39. package/lib/typescript/src/yuvBlit.d.ts.map +1 -1
  40. package/package.json +7 -5
  41. package/scripts/skia-canvas-patch.json +1 -1
  42. package/src/exportFrames.ts +52 -34
  43. package/src/frameInterop.ts +18 -1
  44. package/src/types.ts +27 -0
  45. package/src/videoCompositionPlayer.ts +19 -7
  46. package/src/yuvBlit.ts +66 -0
  47. /package/patches/{react-native-skia+3.7.0.patch → react-native-skia+3.7.1.patch} +0 -0
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.7.0",
2
+ "version": "3.7.1",
3
3
  "files": {
4
4
  "cpp/api/JsiSkCanvas.h": {
5
5
  "before": "91e6f97f34bef620740fabf7a6bb516c462a18daa72afb46ef5a84d1cd4f3158",
@@ -18,13 +18,18 @@ import {
18
18
  reserveVideoMemory,
19
19
  } from "./memory";
20
20
  import type { VideoFrame } from "./types";
21
- import { blitYuvFrame, releaseYuvImports, yuvFrameOrigin } from "./yuvBlit";
21
+ import {
22
+ blitNv12Frame,
23
+ blitYuvFrame,
24
+ releaseYuvImports,
25
+ yuvFrameOrigin,
26
+ } from "./yuvBlit";
22
27
  import type { YuvImport } from "./yuvBlit";
23
28
 
24
- /** A decoded frame sampled in place, without an owned snapshot: on iOS its
25
- * IOSurface, on Android its producer's RGBA texture, which the decoder
26
- * buffer was just converted into. The decoder lease stays open while the
27
- * image is in use. */
29
+ /** A decoded frame sampled in place, without an owned snapshot: a BGRA
30
+ * frame's IOSurface on iOS, or for a YUV frame (Android, iOS NV12) its
31
+ * producer's RGBA texture, which the decoder buffer was just converted into.
32
+ * The decoder lease stays open while the image is in use. */
28
33
  export type ExportFrameImport = {
29
34
  id: number;
30
35
  frame: VideoFrame;
@@ -37,7 +42,7 @@ export type ExportFrameImport = {
37
42
  aliases?: WeakSet<VideoFrame>;
38
43
  };
39
44
 
40
- /** Android: the texture a producer's decoded frames are converted into. */
45
+ /** The texture a producer's YUV frames are converted into. */
41
46
  type ProducerTexture = { texture: GPUTexture; reservation: number };
42
47
 
43
48
  /** Per export session, keyed by producer. `enabled` turns off after a first
@@ -48,7 +53,7 @@ export type ExportFrameImports = {
48
53
  producers: Set<number>;
49
54
  enabled: boolean;
50
55
  imported: boolean;
51
- /** Android: one RGBA texture per producer, rewritten by each frame. */
56
+ /** One RGBA texture per producer of YUV frames, rewritten by each frame. */
52
57
  textures: Map<number, ProducerTexture>;
53
58
  /** Android: decoder buffers imported once (see yuvBlit.ts). */
54
59
  yuv: Map<number, YuvImport[]>;
@@ -65,6 +70,8 @@ type NativeBufferTexture = {
65
70
  nativeBuffer: bigint;
66
71
  region?: { x: number; y: number; width: number; height: number };
67
72
  rotation?: number;
73
+ /** iOS: the decoder's NV12 buffer (`framePixelFormat: 'yuv'`), or BGRA. */
74
+ format?: "nv12" | "bgra8";
68
75
  };
69
76
 
70
77
  // Captured on the RN runtime. Android samples its ImageReader YUV frames
@@ -117,8 +124,8 @@ export const releaseExportFrame = (entry: ExportFrameImport): void => {
117
124
  entry.frame.dispose?.();
118
125
  };
119
126
 
120
- /** Android: releases a producer's texture and decoder buffer imports. Call
121
- * only once its last frame's GPU work has completed. */
127
+ /** Releases a producer's texture and decoder buffer imports. Call only once
128
+ * its last frame's GPU work has completed. */
122
129
  const releaseProducerTexture = (
123
130
  imports: ExportFrameImports,
124
131
  producer: number,
@@ -133,10 +140,11 @@ const releaseProducerTexture = (
133
140
  }
134
141
  };
135
142
 
136
- /** Android: converts a decoded ImageReader frame into its producer's RGBA
137
- * texture, wrapped as a Skia image. The conversion is submitted ahead of the
138
- * frame's drawing, on the same queue; the export's checked flush waits for
139
- * both before the lease or the texture is reused. */
143
+ /** Converts a decoded YUV frame (an Android ImageReader buffer, or an iOS
144
+ * NV12 one) into its producer's RGBA texture, wrapped as a Skia image. The
145
+ * conversion is submitted ahead of the frame's drawing, on the same queue;
146
+ * the export's checked flush waits for both before the lease or the texture
147
+ * is reused. */
140
148
  const importYuvExportFrame = (
141
149
  imports: ExportFrameImports,
142
150
  frame: VideoFrame,
@@ -158,12 +166,17 @@ const importYuvExportFrame = (
158
166
  try {
159
167
  entry.nativeFrame = createNativeVideoGpuFrame(source.nativeBuffer);
160
168
  const rotation = source.rotation ?? 0;
161
- const origin = yuvFrameOrigin(
162
- source.region!,
163
- rotation,
164
- entry.nativeFrame,
165
- frame,
166
- );
169
+ // iOS NV12: the whole buffer, in decoded orientation.
170
+ const nv12 = source.format === "nv12";
171
+ if (
172
+ nv12 &&
173
+ (entry.nativeFrame.width !== frame.width ||
174
+ entry.nativeFrame.height !== frame.height)
175
+ )
176
+ throw new Error("Native video buffer dimensions mismatch");
177
+ const origin = nv12
178
+ ? { x: 0, y: 0 }
179
+ : yuvFrameOrigin(source.region!, rotation, entry.nativeFrame, frame);
167
180
  const device = getVideoGpuDevice();
168
181
  target = imports.textures.get(producer);
169
182
  if (
@@ -199,17 +212,19 @@ const importYuvExportFrame = (
199
212
  }
200
213
  // Can throw after submitting: drain below before giving the lease back.
201
214
  submitted = true;
202
- blitYuvFrame(
203
- device,
204
- imports.yuv,
205
- producer,
206
- entry.nativeFrame,
207
- rotation,
208
- origin,
209
- frame.width,
210
- frame.height,
211
- target.texture,
212
- );
215
+ if (nv12) blitNv12Frame(device, entry.nativeFrame, target.texture);
216
+ else
217
+ blitYuvFrame(
218
+ device,
219
+ imports.yuv,
220
+ producer,
221
+ entry.nativeFrame,
222
+ rotation,
223
+ origin,
224
+ frame.width,
225
+ frame.height,
226
+ target.texture,
227
+ );
213
228
  entry.image = Skia.Image.MakeImageFromGPUTexture(target.texture);
214
229
  if (!entry.image) throw new Error("Cannot wrap the decoded frame");
215
230
  return entry;
@@ -233,15 +248,18 @@ const importYuvExportFrame = (
233
248
  };
234
249
 
235
250
  /** Wraps a decoded BGRA CVPixelBuffer's IOSurface as a Skia image, read-only.
236
- * The decoder already finished writing it; no producer fence is needed. On
237
- * Android, converts the decoded YUV frame instead (see importYuvExportFrame). */
251
+ * The decoder already finished writing it; no producer fence is needed. A
252
+ * YUV frame (Android, iOS NV12) is converted instead (see importYuvExportFrame). */
238
253
  export const importExportFrame = (
239
254
  frame: VideoFrame,
240
255
  imports?: ExportFrameImports,
241
256
  ): ExportFrameImport => {
242
257
  "worklet";
243
- if (convertsYuv) {
244
- if (!imports) throw new Error("Android in-place frames need their imports");
258
+ if (
259
+ convertsYuv ||
260
+ (frame.texture as NativeBufferTexture).format === "nv12"
261
+ ) {
262
+ if (!imports) throw new Error("YUV in-place frames need their imports");
245
263
  return importYuvExportFrame(imports, frame);
246
264
  }
247
265
  const entry: ExportFrameImport = {
@@ -15,7 +15,12 @@ import {
15
15
  releaseVideoMemory,
16
16
  reserveVideoMemory,
17
17
  } from "./memory";
18
- import { blitYuvFrame, releaseYuvImports, yuvFrameOrigin } from "./yuvBlit";
18
+ import {
19
+ blitNv12Frame,
20
+ blitYuvFrame,
21
+ releaseYuvImports,
22
+ yuvFrameOrigin,
23
+ } from "./yuvBlit";
19
24
  import type { YuvImport } from "./yuvBlit";
20
25
 
21
26
  type FrameTexture =
@@ -26,6 +31,8 @@ type FrameTexture =
26
31
  * this crop rectangle turned clockwise by `rotation` for display. */
27
32
  region?: { x: number; y: number; width: number; height: number };
28
33
  rotation?: number;
34
+ /** iOS: the decoder's NV12 buffer (`framePixelFormat: 'yuv'`), or BGRA. */
35
+ format?: "nv12" | "bgra8";
29
36
  }
30
37
  | {
31
38
  kind: "rgba";
@@ -441,6 +448,7 @@ export const imageFromVideoFrame = (frame: VideoFrame): SkImage | null => {
441
448
  nativeSource = createNativeVideoGpuFrame(texture.nativeBuffer);
442
449
  const region = texture.region;
443
450
  const rotation = texture.rotation ?? 0;
451
+ const nv12 = !region && texture.format === "nv12";
444
452
  let origin = { x: 0, y: 0 };
445
453
  if (region) {
446
454
  // An Android codec buffer: padded, YUV, in encoded orientation. The
@@ -449,6 +457,12 @@ export const imageFromVideoFrame = (frame: VideoFrame): SkImage | null => {
449
457
  // driver-defined YUV formats "bgra8", yet Dawn imports them as YUV:
450
458
  // the native side checked the format, so the label is not read.
451
459
  origin = yuvFrameOrigin(region, rotation, nativeSource, frame);
460
+ } else if (nv12) {
461
+ if (
462
+ nativeSource.width !== frame.width ||
463
+ nativeSource.height !== frame.height
464
+ )
465
+ throw new Error("Native video buffer dimensions mismatch");
452
466
  } else if (
453
467
  // RN WebGPU labels a single-plane Android RGBA AHB "bgra8". Dawn
454
468
  // reads the actual AHB format; the string distinguishes RGB from
@@ -478,6 +492,9 @@ export const imageFromVideoFrame = (frame: VideoFrame): SkImage | null => {
478
492
  frame.height,
479
493
  cache.uploadTexture!,
480
494
  );
495
+ } else if (nv12) {
496
+ // Decoded orientation, as below: drawVideoFrame applies rotation.
497
+ blitNv12Frame(device, nativeSource, cache.uploadTexture!);
481
498
  } else {
482
499
  // Keep decoded orientation: drawVideoFrame applies rotation once.
483
500
  device.queue.copyExternalImageToTexture(
package/src/types.ts CHANGED
@@ -149,6 +149,26 @@ export type VideoPlayer = {
149
149
  on(name: "error", listener: (error: any) => void): () => void;
150
150
  };
151
151
 
152
+ /**
153
+ * The pixel format decoded frames are handed over in, before they are drawn.
154
+ *
155
+ * - `yuv`: the decoder's own YUV 4:2:0 buffers, converted to RGB on the GPU
156
+ * when a frame is drawn. A frame takes 1.5 bytes a pixel rather than 4
157
+ * (12.4 MB rather than 33.2 MB in 4K), so the decoder holds more frames
158
+ * ahead for the same memory. On Android only 8-bit SDR tracks decoded at
159
+ * their own size; other tracks stay `rgba`.
160
+ * - `rgba`: frames converted to RGB as they are decoded (iOS: by VideoToolbox,
161
+ * Android: by an OpenGL pass), which a GPU export samples in place.
162
+ * - `auto`: `yuv`, on both platforms.
163
+ *
164
+ * Camera footage (Rec.709, or HLG tone mapped to Rec.709) renders the same
165
+ * either way: both convert with the frame's YCbCr matrix and range. On iOS,
166
+ * `rgba` also converts a file tagged with other primaries or another SDR
167
+ * transfer function (Display P3, sRGB) to Rec.709; `yuv` takes its samples as
168
+ * Rec.709, as Android does in both formats.
169
+ */
170
+ export type VideoFramePixelFormat = "auto" | "yuv" | "rgba";
171
+
152
172
  /**
153
173
  * Represents a video composition.
154
174
  */
@@ -171,6 +191,13 @@ export type VideoComposition = {
171
191
  * until its decoder has opened. The export always waits for it.
172
192
  */
173
193
  lazyDecoders?: boolean;
194
+ /**
195
+ * The pixel format the video items' frames are decoded into, for the
196
+ * preview and the export, see {@link VideoFramePixelFormat}.
197
+ *
198
+ * @default 'auto'
199
+ */
200
+ framePixelFormat?: VideoFramePixelFormat;
174
201
  };
175
202
 
176
203
  type VideoCompositionItemBase = {
@@ -80,7 +80,8 @@ type UseVideoCompositionPlayerOptions<T = undefined> = {
80
80
  * video only changes at its own frame rate. A drawer whose picture depends on
81
81
  * time only through the frames (or only at known moments, a transition say)
82
82
  * returns false elsewhere, and the player then redraws when a new frame is
83
- * decoded instead. Must be a worklet.
83
+ * decoded instead. Ignored when `drawWhenPaused` is `true`, which redraws at
84
+ * every vsync. Must be a worklet.
84
85
  */
85
86
  redrawOnTime?: (previousTime: number, currentTime: number) => boolean;
86
87
  /**
@@ -88,8 +89,9 @@ type UseVideoCompositionPlayerOptions<T = undefined> = {
88
89
  * of both in the same one. On Android the import (YUV blit and its checked
89
90
  * GPU wait) and the draw (snapshot and its checked GPU wait) cost about as
90
91
  * much as each other, and together they overrun a 90–120 Hz frame budget on
91
- * the UI thread. The picture then trails the clock by one vsync.
92
- * The first frame of a session is still drawn as soon as it is imported.
92
+ * the UI thread. The option applies on iOS too. The picture then trails the
93
+ * clock by one vsync. The first frame of a session is still drawn as soon
94
+ * as it is imported.
93
95
  * @default false
94
96
  */
95
97
  splitImportAndDraw?: boolean;
@@ -317,6 +319,8 @@ export const useVideoComposition = <T = undefined>({
317
319
  const lastImportedVersion = useSharedValue(-1);
318
320
  // Whether the previous vsync imported and left its draw to this one.
319
321
  const drawDeferred = useSharedValue(false);
322
+ // Whether the previous vsync brought new frames.
323
+ const importedLastVsync = useSharedValue(false);
320
324
  // A new drawFrame can draw something else from the same time and frames.
321
325
  useEffect(() => {
322
326
  lastDrawnTime.value = -1;
@@ -382,14 +386,22 @@ export const useVideoComposition = <T = undefined>({
382
386
  sourcesGeneration.value = generation;
383
387
  const currentTime = framesExtractor.currentTime;
384
388
  const framesVersion = framesExtractor.framesVersion;
385
- if (splitImportAndDraw && framesVersion !== lastImportedVersion.value) {
389
+ const imported =
390
+ splitImportAndDraw && framesVersion !== lastImportedVersion.value;
391
+ const importedBefore = importedLastVsync.value;
392
+ // Version 0: no frame decoded yet.
393
+ importedLastVsync.value = imported && framesVersion > 0;
394
+ if (imported) {
386
395
  // ownVideoFrames above has just imported the new frames: leave the
387
- // draw to the next vsync, which finds them in the import cache. Never
388
- // twice running: a frame that lands on the vsync owed a draw would
389
- // push it again, and the frame before it would never be shown.
396
+ // draw to the next vsync, which finds them in the import cache. Not
397
+ // when the previous vsync brought new frames too: frames arriving at
398
+ // every vsync (60 fps, or a display gone down to 30 Hz) leave no vsync
399
+ // free for the draw, and the next frame would replace this one before
400
+ // it is shown. Never twice running either, for the same reason.
390
401
  lastImportedVersion.value = framesVersion;
391
402
  if (
392
403
  !drawDeferred.value &&
404
+ !importedBefore &&
393
405
  currentFrame.value !== null &&
394
406
  frameGeneration.value === generation
395
407
  ) {
package/src/yuvBlit.ts CHANGED
@@ -30,6 +30,8 @@ type YuvBlit = {
30
30
  sampler: GPUSampler;
31
31
  module: GPUShaderModule;
32
32
  pipelines: Map<GPUTextureFormat, GPURenderPipeline>;
33
+ /** The whole frame, as is: the uniforms of blitNv12Frame, written once. */
34
+ wholeFrame?: GPUBuffer;
33
35
  };
34
36
 
35
37
  // An ImageReader holds four buffers (GLFrameExtractor.READER_IMAGES); the
@@ -329,3 +331,67 @@ export const blitYuvFrame = (
329
331
  entry.wrapper.endAccess()?.fence.destroy();
330
332
  }
331
333
  };
334
+
335
+ /**
336
+ * iOS: draws a whole NV12 decoder buffer into `target`, converting YUV to RGB
337
+ * with the buffer's YCbCr matrix and range, in its decoded orientation (the
338
+ * frame's rotation applies when it is drawn, as for BGRA frames). Submits the
339
+ * draw. AVAssetReader hands out buffers from a pool rather than a fixed few,
340
+ * so each frame is imported for this draw alone, unlike Android's: the import
341
+ * ends right after the submit (Dawn keeps the surface for the in-flight
342
+ * draw), while the caller holds the frame's lease until the draw completed.
343
+ */
344
+ export const blitNv12Frame = (
345
+ device: GPUDevice,
346
+ source: NativeVideoFrame,
347
+ target: GPUTexture,
348
+ ): void => {
349
+ "worklet";
350
+ const blit = getYuvBlit(device);
351
+ const pipeline = getPipeline(device, blit, target.format);
352
+ if (!blit.wholeFrame) {
353
+ blit.wholeFrame = device.createBuffer({
354
+ label: "video NV12 blit uniforms",
355
+ size: 16,
356
+ usage: videoUniformBufferUsage,
357
+ });
358
+ device.queue.writeBuffer(blit.wholeFrame, 0, new Float32Array([1, 1, 0, 0]));
359
+ }
360
+ const external = device.importExternalTexture({
361
+ label: "video decoder buffer",
362
+ source,
363
+ sourceColorSpace,
364
+ });
365
+ try {
366
+ const encoder = device.createCommandEncoder({ label: "video NV12 blit" });
367
+ const pass = encoder.beginRenderPass({
368
+ label: "video NV12 blit",
369
+ colorAttachments: [
370
+ {
371
+ view: target.createView(),
372
+ loadOp: "clear",
373
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
374
+ storeOp: "store",
375
+ },
376
+ ],
377
+ });
378
+ pass.setPipeline(pipeline);
379
+ pass.setBindGroup(
380
+ 0,
381
+ device.createBindGroup({
382
+ label: "video NV12 blit",
383
+ layout: blit.layout,
384
+ entries: [
385
+ { binding: 0, resource: external },
386
+ { binding: 1, resource: blit.sampler },
387
+ { binding: 2, resource: { buffer: blit.wholeFrame } },
388
+ ],
389
+ }),
390
+ );
391
+ pass.draw(3);
392
+ pass.end();
393
+ device.queue.submit([encoder.finish()]);
394
+ } finally {
395
+ (external as GPUExternalTexture & { destroy?: () => void }).destroy?.();
396
+ }
397
+ };