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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +62 -34
  3. package/android/BUILD_VALIDATION.md +1 -1
  4. package/android/CMakeLists.txt +3 -2
  5. package/android/cpp/ExportHintSession.cpp +114 -0
  6. package/android/cpp/ExportHintSession.h +35 -0
  7. package/android/cpp/VideoComposition.cpp +12 -0
  8. package/android/cpp/VideoCompositionFramesExtractor.cpp +6 -0
  9. package/android/cpp/VideoCompositionFramesExtractor.h +1 -0
  10. package/android/cpp/VideoCompositionFramesExtractorHostObject.cpp +1 -1
  11. package/android/cpp/VideoEncoderHostObject.cpp +76 -101
  12. package/android/cpp/VideoEncoderHostObject.h +14 -9
  13. package/android/cpp/VideoFrame.cpp +3 -5
  14. package/android/cpp/cpp-adapter.cpp +7 -4
  15. package/android/src/main/java/com/azzapp/rnskv/GLFrameExtractor.java +3 -9
  16. package/android/src/main/java/com/azzapp/rnskv/PreviewFrameRateVote.java +104 -0
  17. package/android/src/main/java/com/azzapp/rnskv/VideoComposition.java +4 -0
  18. package/android/src/main/java/com/azzapp/rnskv/VideoCompositionDecoder.java +8 -9
  19. package/android/src/main/java/com/azzapp/rnskv/VideoCompositionFramesExtractor.java +32 -0
  20. package/android/src/main/java/com/azzapp/rnskv/VideoEncoder.java +68 -28
  21. package/docs/FRAME_PIXEL_FORMAT.md +124 -0
  22. package/docs/MIGRATION.md +1 -1
  23. package/docs/UPSTREAM_NOTES.md +15 -2
  24. package/docs/VERIFICATION_0.1.0-beta.3.md +147 -0
  25. package/ios/NativeResourceStats.h +9 -0
  26. package/ios/RNSVColorSpace.h +35 -4
  27. package/ios/ReactNativeSkiaVideo.mm +4 -0
  28. package/ios/VideoComposition.h +20 -0
  29. package/ios/VideoCompositionFramesExtractorHostObject.h +5 -0
  30. package/ios/VideoCompositionFramesExtractorHostObject.mm +31 -2
  31. package/ios/VideoCompositionItemDecoder.mm +21 -8
  32. package/ios/VideoFrame.mm +6 -0
  33. package/lib/module/canvas.js +3 -5
  34. package/lib/module/canvas.js.map +1 -1
  35. package/lib/module/exportFrames.js +25 -20
  36. package/lib/module/exportFrames.js.map +1 -1
  37. package/lib/module/exportTarget.js +183 -40
  38. package/lib/module/exportTarget.js.map +1 -1
  39. package/lib/module/exportVideoComposition.js +53 -12
  40. package/lib/module/exportVideoComposition.js.map +1 -1
  41. package/lib/module/frameInterop.js +15 -12
  42. package/lib/module/frameInterop.js.map +1 -1
  43. package/lib/module/gpu.js +0 -2
  44. package/lib/module/gpu.js.map +1 -1
  45. package/lib/module/gpuDevice.js +12 -0
  46. package/lib/module/gpuDevice.js.map +1 -1
  47. package/lib/module/previewLifecycle.js +0 -1
  48. package/lib/module/previewLifecycle.js.map +1 -1
  49. package/lib/module/videoCompositionPlayer.js +13 -5
  50. package/lib/module/videoCompositionPlayer.js.map +1 -1
  51. package/lib/module/yuvBlit.js +98 -21
  52. package/lib/module/yuvBlit.js.map +1 -1
  53. package/lib/typescript/src/canvas.d.ts.map +1 -1
  54. package/lib/typescript/src/exportFrames.d.ts +11 -10
  55. package/lib/typescript/src/exportFrames.d.ts.map +1 -1
  56. package/lib/typescript/src/exportTarget.d.ts +53 -14
  57. package/lib/typescript/src/exportTarget.d.ts.map +1 -1
  58. package/lib/typescript/src/exportVideoComposition.d.ts +2 -1
  59. package/lib/typescript/src/exportVideoComposition.d.ts.map +1 -1
  60. package/lib/typescript/src/frameInterop.d.ts.map +1 -1
  61. package/lib/typescript/src/gpu.d.ts.map +1 -1
  62. package/lib/typescript/src/gpuDevice.d.ts +5 -1
  63. package/lib/typescript/src/gpuDevice.d.ts.map +1 -1
  64. package/lib/typescript/src/previewLifecycle.d.ts.map +1 -1
  65. package/lib/typescript/src/types.d.ts +36 -5
  66. package/lib/typescript/src/types.d.ts.map +1 -1
  67. package/lib/typescript/src/videoCompositionPlayer.d.ts.map +1 -1
  68. package/lib/typescript/src/yuvBlit.d.ts +26 -14
  69. package/lib/typescript/src/yuvBlit.d.ts.map +1 -1
  70. package/package.json +6 -6
  71. package/patches/react-native-skia+3.7.4.patch +146 -0
  72. package/scripts/skia-canvas-patch.json +22 -258
  73. package/src/canvas.ts +3 -7
  74. package/src/exportFrames.ts +53 -36
  75. package/src/exportTarget.ts +217 -56
  76. package/src/exportVideoComposition.ts +87 -26
  77. package/src/frameInterop.ts +26 -11
  78. package/src/gpu.ts +0 -2
  79. package/src/gpuDevice.ts +26 -1
  80. package/src/previewLifecycle.ts +0 -1
  81. package/src/types.ts +37 -5
  82. package/src/videoCompositionPlayer.ts +14 -4
  83. package/src/yuvBlit.ts +108 -27
  84. package/android/cpp/EncoderTargets.cpp +0 -226
  85. package/android/cpp/EncoderTargets.h +0 -70
  86. package/patches/react-native-skia+3.7.1.patch +0 -502
@@ -35,10 +35,16 @@ import {
35
35
  import { enqueueExport, exportAbortError } from "./exportQueue";
36
36
  import {
37
37
  acquireEncoderTarget,
38
+ acquireWindowTarget,
38
39
  closeEncoderTarget,
40
+ closeEncoderWindow,
41
+ presentWindowTarget,
39
42
  probeEncoderTarget,
43
+ probeEncoderWindow,
44
+ releaseEncoderWindow,
40
45
  supportsEncoderTargets,
41
46
  type EncoderTarget,
47
+ type EncoderWindow,
42
48
  } from "./exportTarget";
43
49
  import { yieldToRuntime } from "./runtimeYield";
44
50
  import {
@@ -82,7 +88,8 @@ export type ExportProfile = {
82
88
  targetMs: number;
83
89
  /** drawFrame, before/afterDrawFrame and progress scheduling. */
84
90
  drawMs: number;
85
- /** The checked flush(true): waiting for the GPU to complete the frame. */
91
+ /** The checked flush(true): waiting for the GPU to complete the frame,
92
+ * which Android's window export does once it decoded the next frame. */
86
93
  gpuMs: number;
87
94
  /** Readback when used, submission to the encoder including its
88
95
  * backpressure, and the final file write. */
@@ -136,11 +143,16 @@ type ExportState = {
136
143
  /** Draw into a lent encoder buffer instead of reading pixels back. */
137
144
  gpu: boolean;
138
145
  target: EncoderTarget | null;
146
+ /** Android GPU export: the encoder's input window, drawn into directly. */
147
+ window: EncoderWindow | null;
139
148
  /** iOS GPU export: decoded frames sampled in place, by producer. */
140
149
  imports: ExportFrameImports | null;
141
150
  frames: Record<string, VideoFrame>;
142
151
  processor: VideoFrameProcessor | null;
143
152
  pendingAfterDraw: (() => void) | null;
153
+ /** Android window: the last frame was submitted without its checked GPU
154
+ * wait, which comes before anything it sampled is released. */
155
+ unchecked?: boolean;
144
156
  reservations: number[];
145
157
  index: number;
146
158
  setupError?: unknown;
@@ -290,6 +302,16 @@ const executeExport = <T>(args: VideoExportArguments<T>): Promise<void> =>
290
302
  if (clock.last >= 0) profile[stage] += now - clock.last;
291
303
  clock.last = now;
292
304
  };
305
+ /** Android window: the checked GPU wait the last frame left for later,
306
+ * so that the GPU converted and drew it while the CPU presented it and
307
+ * decoded the next one. An empty recording: Graphite still waits for
308
+ * every command submitted so far. */
309
+ const checkWindowFrame = () => {
310
+ "worklet";
311
+ if (!state.unchecked) return;
312
+ state.window!.slots[state.window!.next]!.surface!.flush(true);
313
+ state.unchecked = false;
314
+ };
293
315
  const finish = async (
294
316
  error: unknown,
295
317
  aborted = false,
@@ -313,6 +335,7 @@ const executeExport = <T>(args: VideoExportArguments<T>): Promise<void> =>
313
335
  };
314
336
  // Submit any recording left by a drawing exception before an
315
337
  // adapter is allowed to destroy textures referenced by that canvas.
338
+ clean(checkWindowFrame);
316
339
  clean(() => state.surface?.flush(true));
317
340
  clean(() => state.target?.surface?.flush(true));
318
341
  if (cleanup.safeToRelease && state.pendingAfterDraw) {
@@ -329,6 +352,10 @@ const executeExport = <T>(args: VideoExportArguments<T>): Promise<void> =>
329
352
  }
330
353
  }
331
354
  if (clean(() => state.extractor?.dispose())) state.extractor = null;
355
+ // Android: the window's swapchain lets go of the codec's buffers
356
+ // before the encoder goes. Its own textures are freed further down.
357
+ const window = state.window;
358
+ if (window) clean(() => closeEncoderWindow(window));
332
359
  if (clean(() => state.encoder?.dispose())) state.encoder = null;
333
360
  if (cleanup.safeToRelease) {
334
361
  clean(() => state.surface?.flush(true));
@@ -341,10 +368,20 @@ const executeExport = <T>(args: VideoExportArguments<T>): Promise<void> =>
341
368
  state.imports = null;
342
369
  clean(() => clearVideoFrameImages(true));
343
370
  }
344
- if (cleanup.safeToRelease && clean(() => state.canvas?.dispose()))
345
- state.canvas = null;
346
- if (cleanup.safeToRelease && clean(() => state.surface?.dispose()))
371
+ // Disposing the surface detaches its canvas.
372
+ if (cleanup.safeToRelease && clean(() => state.surface?.dispose())) {
347
373
  state.surface = null;
374
+ state.canvas = null;
375
+ }
376
+ // The target, if any, is one of the window's slots.
377
+ if (
378
+ cleanup.safeToRelease &&
379
+ window &&
380
+ clean(() => releaseEncoderWindow(window))
381
+ ) {
382
+ state.window = null;
383
+ state.target = null;
384
+ }
348
385
  const target = state.target;
349
386
  if (cleanup.safeToRelease && target) {
350
387
  if (clean(() => closeEncoderTarget(target))) state.target = null;
@@ -394,6 +431,8 @@ const executeExport = <T>(args: VideoExportArguments<T>): Promise<void> =>
394
431
  const owned = runWithVideoAutoreleasePool(() => {
395
432
  const decoded = state.extractor!.decodeCompositionFrames(time);
396
433
  lap("decodeMs");
434
+ checkWindowFrame();
435
+ lap("gpuMs");
397
436
  const imports = state.imports;
398
437
  // In place, a producer's frame is released by its next import.
399
438
  const imported = imports
@@ -427,11 +466,13 @@ const executeExport = <T>(args: VideoExportArguments<T>): Promise<void> =>
427
466
  const draw = () => {
428
467
  "worklet";
429
468
  if (state.gpu)
430
- state.target = acquireEncoderTarget(
431
- state.encoder!,
432
- options.width,
433
- options.height,
434
- );
469
+ state.target = state.window
470
+ ? acquireWindowTarget(state.window)
471
+ : acquireEncoderTarget(
472
+ state.encoder!,
473
+ options.width,
474
+ options.height,
475
+ );
435
476
  lap("targetMs");
436
477
  const target = state.target;
437
478
  const surface = target ? target.surface! : state.surface!;
@@ -454,18 +495,25 @@ const executeExport = <T>(args: VideoExportArguments<T>): Promise<void> =>
454
495
  height: options.height,
455
496
  });
456
497
  lap("drawMs");
457
- surface.flush(true);
498
+ // Android window, no context to release: the next frame waits
499
+ // for this one (see checkWindowFrame).
500
+ const unchecked =
501
+ !!state.window &&
502
+ !!target &&
503
+ !afterDrawFrame &&
504
+ !state.processor;
505
+ surface.flush(!unchecked);
506
+ state.unchecked = unchecked;
458
507
  drawn.drained = true;
459
508
  lap("gpuMs");
460
- if (target) {
461
- closeEncoderTarget(target, true);
509
+ if (target && state.window) {
510
+ state.target = null;
511
+ presentWindowTarget(state.encoder!, state.window, target, time);
512
+ } else if (target) {
513
+ closeEncoderTarget(target);
462
514
  state.target = null;
463
515
  state.encoder!.encodeFrame(
464
- {
465
- kind: "native-buffer",
466
- nativeBuffer: target.pointer,
467
- fences: target.fences,
468
- },
516
+ { kind: "native-buffer", nativeBuffer: target.pointer },
469
517
  time,
470
518
  );
471
519
  } else {
@@ -615,6 +663,7 @@ const executeExport = <T>(args: VideoExportArguments<T>): Promise<void> =>
615
663
  readback: null,
616
664
  gpu: false,
617
665
  target: null,
666
+ window: null,
618
667
  imports: null,
619
668
  frames: {},
620
669
  processor: null,
@@ -666,16 +715,28 @@ const executeExport = <T>(args: VideoExportArguments<T>): Promise<void> =>
666
715
  );
667
716
  state.encoder.prepare();
668
717
  if (gpuExport) {
669
- // Skia draws straight into the encoder's buffers; the probe
670
- // keeps devices without IOSurface interop on the readback path.
718
+ // Skia draws straight into the encoder's buffers: lent pool
719
+ // buffers on iOS, the input window on Android. The probe keeps
720
+ // devices where that fails on the readback path.
671
721
  state.hasRecorder = true;
672
- const failure = supportsEncoderTargets(state.encoder)
673
- ? probeEncoderTarget(
674
- state.encoder,
675
- options.width,
676
- options.height,
677
- )
678
- : new Error("Encoder cannot lend frame buffers");
722
+ let failure: unknown;
723
+ if (bgra) {
724
+ failure = supportsEncoderTargets(state.encoder)
725
+ ? probeEncoderTarget(
726
+ state.encoder,
727
+ options.width,
728
+ options.height,
729
+ )
730
+ : new Error("Encoder cannot lend frame buffers");
731
+ } else {
732
+ const probe = probeEncoderWindow(
733
+ state.encoder,
734
+ options.width,
735
+ options.height,
736
+ );
737
+ state.window = probe.window;
738
+ failure = probe.failure;
739
+ }
679
740
  state.gpu = failure === null;
680
741
  // Decoded frames are sampled in place too: on iOS through the
681
742
  // same IOSurface import, on Android once converted from YUV.
@@ -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
+ yuvFrameRegion,
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";
@@ -109,7 +116,6 @@ const clearEntry = (cache: Cache, producer: number) => {
109
116
  * The caller has either not submitted a copy or has completed its GPU fence. */
110
117
  const clearSurfaceView = (cache: Cache) => {
111
118
  "worklet";
112
- cache.canvas?.dispose();
113
119
  cache.canvas = null;
114
120
  cache.stage?.dispose();
115
121
  cache.stage = null;
@@ -441,14 +447,22 @@ export const imageFromVideoFrame = (frame: VideoFrame): SkImage | null => {
441
447
  nativeSource = createNativeVideoGpuFrame(texture.nativeBuffer);
442
448
  const region = texture.region;
443
449
  const rotation = texture.rotation ?? 0;
444
- let origin = { x: 0, y: 0 };
450
+ const nv12 = !region && texture.format === "nv12";
451
+ let upright: ReturnType<typeof yuvFrameRegion> | null = null;
445
452
  if (region) {
446
453
  // An Android codec buffer: padded, YUV, in encoded orientation. The
447
- // blit converts, crops and turns it into the display-sized scratch,
448
- // as the GL path's SurfaceTexture transform did. RN WebGPU labels
449
- // driver-defined YUV formats "bgra8", yet Dawn imports them as YUV:
450
- // the native side checked the format, so the label is not read.
451
- origin = yuvFrameOrigin(region, rotation, nativeSource, frame);
454
+ // blit converts, crops, turns and scales it into the frame-sized
455
+ // scratch, as the GL path's SurfaceTexture transform did. RN WebGPU
456
+ // labels driver-defined YUV formats "bgra8", yet Dawn imports them
457
+ // as YUV: the native side checked the format, so the label is not
458
+ // read.
459
+ upright = yuvFrameRegion(region, rotation, nativeSource);
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
@@ -473,11 +487,12 @@ export const imageFromVideoFrame = (frame: VideoFrame): SkImage | null => {
473
487
  key,
474
488
  nativeSource,
475
489
  rotation,
476
- origin,
477
- frame.width,
478
- frame.height,
490
+ upright!,
479
491
  cache.uploadTexture!,
480
492
  );
493
+ } else if (nv12) {
494
+ // Decoded orientation, as below: drawVideoFrame applies rotation.
495
+ blitNv12Frame(device, nativeSource, cache.uploadTexture!);
481
496
  } else {
482
497
  // Keep decoded orientation: drawVideoFrame applies rotation once.
483
498
  device.queue.copyExternalImageToTexture(
package/src/gpu.ts CHANGED
@@ -178,7 +178,6 @@ export const createVideoGpuScope = (options?: {
178
178
  } catch (error) {
179
179
  // No texture has been exposed or submitted to any consumer yet.
180
180
  try {
181
- resource.canvas?.dispose();
182
181
  resource.canvas = null;
183
182
  resource.surface?.dispose();
184
183
  resource.surface = null;
@@ -265,7 +264,6 @@ export const createVideoGpuScope = (options?: {
265
264
  try {
266
265
  resource.image?.dispose();
267
266
  resource.image = null;
268
- resource.canvas?.dispose();
269
267
  resource.canvas = null;
270
268
  resource.surface?.dispose();
271
269
  resource.surface = null;
package/src/gpuDevice.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  importDevice,
9
9
  installWebGPU,
10
10
  } from "react-native-webgpu";
11
- import type { NativeVideoFrame } from "react-native-webgpu";
11
+ import type { NativeVideoFrame, RNCanvasContext } from "react-native-webgpu";
12
12
  import RNSkiaVideoModule from "./RNSkiaVideoModule";
13
13
 
14
14
  // Evaluate on the RN runtime. NativeObject serializers carry these objects to
@@ -61,11 +61,36 @@ export const createNativeVideoGpuFrame = (
61
61
  return nativeWebGPU.createVideoFrameFromNativeBuffer(pointer);
62
62
  };
63
63
 
64
+ /** Android: a canvas context presenting to a native window that is not a
65
+ * view, such as an encoder's input Surface. */
66
+ export const createNativeWindowGpuContext = (
67
+ window: bigint,
68
+ width: number,
69
+ height: number,
70
+ ): RNCanvasContext => {
71
+ "worklet";
72
+ if (
73
+ typeof nativeWebGPU?.MakeWebGPUCanvasContextFromNativeWindow !== "function"
74
+ )
75
+ throw new Error(
76
+ "React Native WebGPU cannot present to a native window; 0.19.0 or later is required",
77
+ );
78
+ return nativeWebGPU.MakeWebGPUCanvasContextFromNativeWindow(
79
+ window,
80
+ width,
81
+ height,
82
+ );
83
+ };
84
+
64
85
  export const nativeVideoTextureUsage =
65
86
  GPUTextureUsage.RENDER_ATTACHMENT |
66
87
  GPUTextureUsage.TEXTURE_BINDING |
67
88
  GPUTextureUsage.COPY_SRC;
68
89
 
90
+ // An encoder's input window only receives copies of the frames Skia drew.
91
+ export const encoderWindowTextureUsage =
92
+ GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_DST;
93
+
69
94
  // CPU RGBA uploads additionally need COPY_DST. Keep the native-buffer blit's
70
95
  // descriptor unchanged: that path renders into its destination attachment.
71
96
  export const rgbaVideoTextureUsage =
@@ -33,7 +33,6 @@ export const disposePreviewResources = (resources: PreviewResources) => {
33
33
  frame.dispose?.();
34
34
  resources.frames.shift();
35
35
  }
36
- resources.canvas?.dispose();
37
36
  resources.canvas = null;
38
37
  resources.surface?.dispose();
39
38
  resources.surface = null;
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 = {
@@ -394,14 +421,19 @@ export type VideoEncoder = {
394
421
  */
395
422
  encodeFrame(texture: unknown, time: number): void;
396
423
  /**
397
- * Lends the next pool buffer to draw into on the GPU: a CVPixelBuffer on
398
- * iOS, an RGBA AHardwareBuffer on Android. Pass it back as a native-buffer
399
- * frame (on Android with the sync-fds ending its GPU access, in `fences`),
400
- * or release it. One at a time.
424
+ * iOS: lends the next pool buffer (a CVPixelBuffer) to draw into on the
425
+ * GPU. Pass it back as a native-buffer frame, or release it. One at a time.
401
426
  */
402
427
  acquireFrameBuffer?(): bigint;
403
- /** Returns a lent buffer that will not be encoded. */
428
+ /** iOS: returns a lent buffer that will not be encoded. */
404
429
  releaseFrameBuffer?(): void;
430
+ /**
431
+ * Android: the codec's input Surface, as an ANativeWindow pointer for a
432
+ * WebGPU canvas context to present to. Each frame is then encoded as
433
+ * `{ kind: "window", present }`: the encoder sets the frame's time on the
434
+ * window and calls `present`, which presents the context.
435
+ */
436
+ acquireInputWindow?(): bigint;
405
437
  /*
406
438
  * Finish writing the video to the output file.
407
439
  */
@@ -319,6 +319,8 @@ export const useVideoComposition = <T = undefined>({
319
319
  const lastImportedVersion = useSharedValue(-1);
320
320
  // Whether the previous vsync imported and left its draw to this one.
321
321
  const drawDeferred = useSharedValue(false);
322
+ // Whether the previous vsync brought new frames.
323
+ const importedLastVsync = useSharedValue(false);
322
324
  // A new drawFrame can draw something else from the same time and frames.
323
325
  useEffect(() => {
324
326
  lastDrawnTime.value = -1;
@@ -384,14 +386,22 @@ export const useVideoComposition = <T = undefined>({
384
386
  sourcesGeneration.value = generation;
385
387
  const currentTime = framesExtractor.currentTime;
386
388
  const framesVersion = framesExtractor.framesVersion;
387
- 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) {
388
395
  // ownVideoFrames above has just imported the new frames: leave the
389
- // draw to the next vsync, which finds them in the import cache. Never
390
- // twice running: a frame that lands on the vsync owed a draw would
391
- // 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.
392
401
  lastImportedVersion.value = framesVersion;
393
402
  if (
394
403
  !drawDeferred.value &&
404
+ !importedBefore &&
395
405
  currentFrame.value !== null &&
396
406
  frameGeneration.value === generation
397
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
@@ -66,40 +68,52 @@ struct VsOut { @builtin(position) position: vec4f, @location(0) uv: vec2f };
66
68
  // primaries to sRGB, and every export brightens the midtones again.
67
69
  const sourceColorSpace = { primaries: "srgb", transfer: "srgb" } as const;
68
70
 
69
- /** The corner of a decoder buffer's crop rectangle once the buffer is turned
70
- * clockwise for display: the blit takes its source origin in that turned
71
- * space. Throws unless the rectangle lies in the buffer and turns into the
72
- * frame's display size. */
73
- export const yuvFrameOrigin = (
71
+ /** A decoder buffer's crop rectangle once the buffer is turned clockwise for
72
+ * display: the blit samples that upright region, and draws it at the size of
73
+ * its target, which a capped or explicit frame size makes smaller. Throws
74
+ * unless the rectangle lies in the buffer. */
75
+ export const yuvFrameRegion = (
74
76
  region: { x: number; y: number; width: number; height: number },
75
77
  rotation: number,
76
78
  buffer: { width: number; height: number },
77
- frame: { width: number; height: number },
78
- ): { x: number; y: number } => {
79
+ ): { x: number; y: number; width: number; height: number } => {
79
80
  "worklet";
80
81
  const turned = rotation === 90 || rotation === 270;
81
82
  if (
82
83
  (rotation !== 0 && !turned && rotation !== 180) ||
83
84
  region.x < 0 ||
84
85
  region.y < 0 ||
86
+ region.width <= 0 ||
87
+ region.height <= 0 ||
85
88
  region.x + region.width > buffer.width ||
86
- region.y + region.height > buffer.height ||
87
- (turned ? region.height : region.width) !== frame.width ||
88
- (turned ? region.width : region.height) !== frame.height
89
+ region.y + region.height > buffer.height
89
90
  ) {
90
91
  throw new Error("Native video buffer region or YUV layout mismatch");
91
92
  }
92
- const { width, height } = buffer;
93
+ const width = turned ? region.height : region.width;
94
+ const height = turned ? region.width : region.height;
93
95
  if (rotation === 90)
94
- return { x: height - region.y - region.height, y: region.x };
96
+ return {
97
+ x: buffer.height - region.y - region.height,
98
+ y: region.x,
99
+ width,
100
+ height,
101
+ };
95
102
  if (rotation === 180)
96
103
  return {
97
- x: width - region.x - region.width,
98
- y: height - region.y - region.height,
104
+ x: buffer.width - region.x - region.width,
105
+ y: buffer.height - region.y - region.height,
106
+ width,
107
+ height,
99
108
  };
100
109
  if (rotation === 270)
101
- return { x: region.y, y: width - region.x - region.width };
102
- return { x: region.x, y: region.y };
110
+ return {
111
+ x: region.y,
112
+ y: buffer.width - region.x - region.width,
113
+ width,
114
+ height,
115
+ };
116
+ return { x: region.x, y: region.y, width, height };
103
117
  };
104
118
 
105
119
  const getYuvBlit = (device: GPUDevice): YuvBlit => {
@@ -249,10 +263,11 @@ const importYuvBuffer = (
249
263
  };
250
264
 
251
265
  /**
252
- * Draws the upright `width`×`height` region at `origin` of a decoder buffer
253
- * into `target`, converting YUV to RGB; the buffer is imported on its first
254
- * frame. Submits the draw: the caller waits for it before releasing the
255
- * frame's lease or `source`, as after copyExternalImageToTexture.
266
+ * Draws the upright `region` of a decoder buffer (see yuvFrameRegion) into
267
+ * the whole of `target`, converting YUV to RGB and scaling it to the target's
268
+ * size; the buffer is imported on its first frame. Submits the draw: the
269
+ * caller waits for it before releasing the frame's lease or `source`, as
270
+ * after copyExternalImageToTexture.
256
271
  */
257
272
  export const blitYuvFrame = (
258
273
  device: GPUDevice,
@@ -260,9 +275,7 @@ export const blitYuvFrame = (
260
275
  producer: number,
261
276
  source: NativeVideoFrame,
262
277
  rotation: number,
263
- origin: { x: number; y: number },
264
- width: number,
265
- height: number,
278
+ region: { x: number; y: number; width: number; height: number },
266
279
  target: GPUTexture,
267
280
  ): void => {
268
281
  "worklet";
@@ -295,10 +308,10 @@ export const blitYuvFrame = (
295
308
  const frameWidth = turned ? source.height : source.width;
296
309
  const frameHeight = turned ? source.width : source.height;
297
310
  const mapping = [
298
- width / frameWidth,
299
- height / frameHeight,
300
- origin.x / frameWidth,
301
- origin.y / frameHeight,
311
+ region.width / frameWidth,
312
+ region.height / frameHeight,
313
+ region.x / frameWidth,
314
+ region.y / frameHeight,
302
315
  ];
303
316
  if (mapping.some((value, i) => value !== entry.mapping[i])) {
304
317
  device.queue.writeBuffer(entry.uniforms, 0, new Float32Array(mapping));
@@ -329,3 +342,71 @@ export const blitYuvFrame = (
329
342
  entry.wrapper.endAccess()?.fence.destroy();
330
343
  }
331
344
  };
345
+
346
+ /**
347
+ * iOS: draws a whole NV12 decoder buffer into `target`, converting YUV to RGB
348
+ * with the buffer's YCbCr matrix and range, in its decoded orientation (the
349
+ * frame's rotation applies when it is drawn, as for BGRA frames). Submits the
350
+ * draw. AVAssetReader hands out buffers from a pool rather than a fixed few,
351
+ * so each frame is imported for this draw alone, unlike Android's: the import
352
+ * ends right after the submit (Dawn keeps the surface for the in-flight
353
+ * draw), while the caller holds the frame's lease until the draw completed.
354
+ */
355
+ export const blitNv12Frame = (
356
+ device: GPUDevice,
357
+ source: NativeVideoFrame,
358
+ target: GPUTexture,
359
+ ): void => {
360
+ "worklet";
361
+ const blit = getYuvBlit(device);
362
+ const pipeline = getPipeline(device, blit, target.format);
363
+ if (!blit.wholeFrame) {
364
+ blit.wholeFrame = device.createBuffer({
365
+ label: "video NV12 blit uniforms",
366
+ size: 16,
367
+ usage: videoUniformBufferUsage,
368
+ });
369
+ device.queue.writeBuffer(
370
+ blit.wholeFrame,
371
+ 0,
372
+ new Float32Array([1, 1, 0, 0]),
373
+ );
374
+ }
375
+ const external = device.importExternalTexture({
376
+ label: "video decoder buffer",
377
+ source,
378
+ sourceColorSpace,
379
+ });
380
+ try {
381
+ const encoder = device.createCommandEncoder({ label: "video NV12 blit" });
382
+ const pass = encoder.beginRenderPass({
383
+ label: "video NV12 blit",
384
+ colorAttachments: [
385
+ {
386
+ view: target.createView(),
387
+ loadOp: "clear",
388
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
389
+ storeOp: "store",
390
+ },
391
+ ],
392
+ });
393
+ pass.setPipeline(pipeline);
394
+ pass.setBindGroup(
395
+ 0,
396
+ device.createBindGroup({
397
+ label: "video NV12 blit",
398
+ layout: blit.layout,
399
+ entries: [
400
+ { binding: 0, resource: external },
401
+ { binding: 1, resource: blit.sampler },
402
+ { binding: 2, resource: { buffer: blit.wholeFrame } },
403
+ ],
404
+ }),
405
+ );
406
+ pass.draw(3);
407
+ pass.end();
408
+ device.queue.submit([encoder.finish()]);
409
+ } finally {
410
+ (external as GPUExternalTexture & { destroy?: () => void }).destroy?.();
411
+ }
412
+ };