react-native-skia-webgpu-video 0.1.0-beta.3 → 0.1.0

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 (81) hide show
  1. package/README.md +48 -43
  2. package/android/BUILD_VALIDATION.md +1 -1
  3. package/android/CMakeLists.txt +3 -2
  4. package/android/cpp/ExportHintSession.cpp +114 -0
  5. package/android/cpp/ExportHintSession.h +35 -0
  6. package/android/cpp/VideoEncoderHostObject.cpp +76 -101
  7. package/android/cpp/VideoEncoderHostObject.h +14 -9
  8. package/android/cpp/VideoFrame.cpp +3 -5
  9. package/android/cpp/cpp-adapter.cpp +7 -4
  10. package/android/src/main/java/com/azzapp/rnskv/GLFrameExtractor.java +3 -9
  11. package/android/src/main/java/com/azzapp/rnskv/VideoCompositionDecoder.java +8 -11
  12. package/android/src/main/java/com/azzapp/rnskv/VideoEncoder.java +68 -28
  13. package/docs/FRAME_PIXEL_FORMAT.md +17 -2
  14. package/docs/MIGRATION.md +1 -1
  15. package/docs/UPSTREAM_NOTES.md +42 -2
  16. package/ios/RNSVColorSpace.h +8 -3
  17. package/lib/module/canvas.js +3 -5
  18. package/lib/module/canvas.js.map +1 -1
  19. package/lib/module/exportFrames.js +8 -10
  20. package/lib/module/exportFrames.js.map +1 -1
  21. package/lib/module/exportTarget.js +183 -40
  22. package/lib/module/exportTarget.js.map +1 -1
  23. package/lib/module/exportVideoComposition.js +78 -35
  24. package/lib/module/exportVideoComposition.js.map +1 -1
  25. package/lib/module/frameInterop.js +323 -82
  26. package/lib/module/frameInterop.js.map +1 -1
  27. package/lib/module/gpu.js +1 -3
  28. package/lib/module/gpu.js.map +1 -1
  29. package/lib/module/gpuDevice.js +12 -0
  30. package/lib/module/gpuDevice.js.map +1 -1
  31. package/lib/module/memory.js +7 -0
  32. package/lib/module/memory.js.map +1 -1
  33. package/lib/module/previewLifecycle.js +2 -1
  34. package/lib/module/previewLifecycle.js.map +1 -1
  35. package/lib/module/videoCompositionPlayer.js +65 -14
  36. package/lib/module/videoCompositionPlayer.js.map +1 -1
  37. package/lib/module/videoFrameImage.js +1 -1
  38. package/lib/module/yuvBlit.js +31 -24
  39. package/lib/module/yuvBlit.js.map +1 -1
  40. package/lib/typescript/src/canvas.d.ts.map +1 -1
  41. package/lib/typescript/src/exportFrames.d.ts +3 -2
  42. package/lib/typescript/src/exportFrames.d.ts.map +1 -1
  43. package/lib/typescript/src/exportTarget.d.ts +53 -14
  44. package/lib/typescript/src/exportTarget.d.ts.map +1 -1
  45. package/lib/typescript/src/exportVideoComposition.d.ts +2 -1
  46. package/lib/typescript/src/exportVideoComposition.d.ts.map +1 -1
  47. package/lib/typescript/src/frameInterop.d.ts +16 -5
  48. package/lib/typescript/src/frameInterop.d.ts.map +1 -1
  49. package/lib/typescript/src/gpu.d.ts.map +1 -1
  50. package/lib/typescript/src/gpuDevice.d.ts +5 -1
  51. package/lib/typescript/src/gpuDevice.d.ts.map +1 -1
  52. package/lib/typescript/src/memory.d.ts +2 -0
  53. package/lib/typescript/src/memory.d.ts.map +1 -1
  54. package/lib/typescript/src/previewLifecycle.d.ts +2 -0
  55. package/lib/typescript/src/previewLifecycle.d.ts.map +1 -1
  56. package/lib/typescript/src/types.d.ts +18 -12
  57. package/lib/typescript/src/types.d.ts.map +1 -1
  58. package/lib/typescript/src/videoCompositionPlayer.d.ts +4 -5
  59. package/lib/typescript/src/videoCompositionPlayer.d.ts.map +1 -1
  60. package/lib/typescript/src/videoFrameImage.d.ts +1 -1
  61. package/lib/typescript/src/yuvBlit.d.ts +16 -14
  62. package/lib/typescript/src/yuvBlit.d.ts.map +1 -1
  63. package/package.json +6 -6
  64. package/patches/react-native-skia+3.10.0.patch +150 -0
  65. package/scripts/skia-canvas-patch.json +22 -258
  66. package/src/canvas.ts +3 -7
  67. package/src/exportFrames.ts +10 -11
  68. package/src/exportTarget.ts +217 -56
  69. package/src/exportVideoComposition.ts +130 -85
  70. package/src/frameInterop.ts +389 -96
  71. package/src/gpu.ts +1 -3
  72. package/src/gpuDevice.ts +26 -1
  73. package/src/memory.ts +6 -0
  74. package/src/previewLifecycle.ts +4 -1
  75. package/src/types.ts +18 -12
  76. package/src/videoCompositionPlayer.ts +76 -23
  77. package/src/videoFrameImage.ts +1 -1
  78. package/src/yuvBlit.ts +46 -31
  79. package/android/cpp/EncoderTargets.cpp +0 -226
  80. package/android/cpp/EncoderTargets.h +0 -70
  81. package/patches/react-native-skia+3.7.1.patch +0 -502
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 =
package/src/memory.ts CHANGED
@@ -37,6 +37,12 @@ export const frameByteSize = (width: number, height: number): number => {
37
37
  return bytes;
38
38
  };
39
39
 
40
+ /** The budget's counters, from any runtime; null without them. */
41
+ export const videoMemoryBudget = (): VideoMemoryStats | null => {
42
+ "worklet";
43
+ return RNSkiaVideoModule.getMemoryBudgetStats?.() ?? null;
44
+ };
45
+
40
46
  export const reserveVideoMemory = (bytes: number, label: string): number => {
41
47
  "worklet";
42
48
  return RNSkiaVideoModule.reserveMemory?.(bytes, label) ?? 0;
@@ -5,6 +5,8 @@ import type { VideoFrame } from "./types";
5
5
 
6
6
  type PreviewResources = {
7
7
  surface?: SkSurface | null;
8
+ /** The preview's second buffer (see useVideoCompositionPlayer). */
9
+ spareSurface?: SkSurface | null;
8
10
  canvas?: SkCanvas | null;
9
11
  images?: Array<SkImage | null>;
10
12
  frames?: VideoFrame[];
@@ -33,10 +35,11 @@ export const disposePreviewResources = (resources: PreviewResources) => {
33
35
  frame.dispose?.();
34
36
  resources.frames.shift();
35
37
  }
36
- resources.canvas?.dispose();
37
38
  resources.canvas = null;
38
39
  resources.surface?.dispose();
39
40
  resources.surface = null;
41
+ resources.spareSurface?.dispose();
42
+ resources.spareSurface = null;
40
43
  releaseVideoMemory(resources.reservation ?? 0);
41
44
  resources.reservation = 0;
42
45
  } catch (error) {
package/src/types.ts CHANGED
@@ -157,15 +157,16 @@ export type VideoPlayer = {
157
157
  * (12.4 MB rather than 33.2 MB in 4K), so the decoder holds more frames
158
158
  * ahead for the same memory. On Android only 8-bit SDR tracks decoded at
159
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.
160
+ * - `rgba` (deprecated): frames converted to RGB as they are decoded (iOS: by
161
+ * VideoToolbox, Android: by an OpenGL pass). It no longer has an advantage:
162
+ * exports run as fast in `yuv`, which renders the same colors, and a 4K60
163
+ * preview keeps fewer frames on screen in `rgba`, whose frames take 4 bytes
164
+ * a pixel. It still works; use `auto`.
162
165
  * - `auto`: `yuv`, on both platforms.
163
166
  *
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.
167
+ * Both convert with the frame's YCbCr matrix and range. On iOS, both convert
168
+ * HDR (HLG, PQ), BT.2020, Display P3 and sRGB files to Rec.709 through
169
+ * AVFoundation. Android takes SDR samples as Rec.709 in both formats.
169
170
  */
170
171
  export type VideoFramePixelFormat = "auto" | "yuv" | "rgba";
171
172
 
@@ -421,14 +422,19 @@ export type VideoEncoder = {
421
422
  */
422
423
  encodeFrame(texture: unknown, time: number): void;
423
424
  /**
424
- * Lends the next pool buffer to draw into on the GPU: a CVPixelBuffer on
425
- * iOS, an RGBA AHardwareBuffer on Android. Pass it back as a native-buffer
426
- * frame (on Android with the sync-fds ending its GPU access, in `fences`),
427
- * or release it. One at a time.
425
+ * iOS: lends the next pool buffer (a CVPixelBuffer) to draw into on the
426
+ * GPU. Pass it back as a native-buffer frame, or release it. One at a time.
428
427
  */
429
428
  acquireFrameBuffer?(): bigint;
430
- /** Returns a lent buffer that will not be encoded. */
429
+ /** iOS: returns a lent buffer that will not be encoded. */
431
430
  releaseFrameBuffer?(): void;
431
+ /**
432
+ * Android: the codec's input Surface, as an ANativeWindow pointer for a
433
+ * WebGPU canvas context to present to. Each frame is then encoded as
434
+ * `{ kind: "window", present }`: the encoder sets the frame's time on the
435
+ * window and calls `present`, which presents the context.
436
+ */
437
+ acquireInputWindow?(): bigint;
432
438
  /*
433
439
  * Finish writing the video to the output file.
434
440
  */
@@ -19,10 +19,18 @@ import RNSkiaVideoModule from "./RNSkiaVideoModule";
19
19
  import useEventListener from "./utils/useEventListener";
20
20
  import { PixelRatio } from "react-native";
21
21
  import { scheduleOnRN } from "react-native-worklets";
22
- import { ownVideoFrames, releaseVideoFrameImage } from "./frameInterop";
22
+ import {
23
+ ownVideoFrames,
24
+ releaseVideoFrameImage,
25
+ videoFrameImportWaits,
26
+ } from "./frameInterop";
23
27
  import { frameByteSize, reserveVideoMemory } from "./memory";
24
28
  import { disposePreviewResources } from "./previewLifecycle";
25
29
 
30
+ // Draws in a row the preview submits without waiting for the GPU: one frame
31
+ // of a 24 fps video spans five vsyncs at 120 Hz, redrawn as time goes by.
32
+ const maxUncheckedDraws = 6;
33
+
26
34
  type UseVideoCompositionPlayerOptions<T = undefined> = {
27
35
  /**
28
36
  * The video composition to play.
@@ -87,11 +95,10 @@ type UseVideoCompositionPlayerOptions<T = undefined> = {
87
95
  /**
88
96
  * Import a newly decoded frame in one vsync and draw it in the next, instead
89
97
  * of both in the same one. On Android the import (YUV blit and its checked
90
- * GPU wait) and the draw (snapshot and its checked GPU wait) cost about as
91
- * much as each other, and together they overrun a 90–120 Hz frame budget on
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.
98
+ * GPU wait) and the draw cost about as much as each other, and together they
99
+ * overrun a 90–120 Hz frame budget on the UI thread. The option applies on
100
+ * iOS too. The picture then trails the clock by one vsync. The first frame
101
+ * of a session is still drawn as soon as it is imported.
95
102
  * @default false
96
103
  */
97
104
  splitImportAndDraw?: boolean;
@@ -276,6 +283,15 @@ export const useVideoComposition = <T = undefined>({
276
283
 
277
284
  const surfaceSharedValue = useSharedValue<SkSurface | null>(null);
278
285
  const canvasSharedValue = useSharedValue<SkCanvas | null>(null);
286
+ // The preview draws into two surfaces in turn, and shows each through an
287
+ // image sharing its texture (asImage): no copy per draw, as a snapshot
288
+ // made. The picture on screen stays whole while the next is drawn into the
289
+ // other surface.
290
+ const spareSurfaceSharedValue = useSharedValue<SkSurface | null>(null);
291
+ const spareCanvasSharedValue = useSharedValue<SkCanvas | null>(null);
292
+ const bufferImages = useSharedValue<Array<SkImage | null>>([]);
293
+ // The buffer on screen: 0, 1 (the spare), or -1 before the first draw.
294
+ const frontBuffer = useSharedValue(-1);
279
295
  const surfaceReservation = useSharedValue(0);
280
296
  const surfaceWidth = useSharedValue(0);
281
297
  const surfaceHeight = useSharedValue(0);
@@ -283,8 +299,10 @@ export const useVideoComposition = <T = undefined>({
283
299
  "worklet";
284
300
  const resources = {
285
301
  surface: surfaceSharedValue.value,
302
+ spareSurface: spareSurfaceSharedValue.value,
286
303
  canvas: canvasSharedValue.value,
287
- images: [currentFrame.value, pendingSnapshot.value],
304
+ // currentFrame is one of the buffers' images.
305
+ images: [...bufferImages.value, pendingSnapshot.value],
288
306
  frames: Object.values(previousSources.value),
289
307
  reservation: surfaceReservation.value,
290
308
  afterDraw: pendingAfterDraw.value?.run,
@@ -300,6 +318,10 @@ export const useVideoComposition = <T = undefined>({
300
318
  sourcesGeneration.value = 0;
301
319
  surfaceSharedValue.value = null;
302
320
  canvasSharedValue.value = null;
321
+ spareSurfaceSharedValue.value = null;
322
+ spareCanvasSharedValue.value = null;
323
+ bufferImages.value = [];
324
+ frontBuffer.value = -1;
303
325
  surfaceReservation.value = 0;
304
326
  surfaceWidth.value = 0;
305
327
  surfaceHeight.value = 0;
@@ -321,6 +343,10 @@ export const useVideoComposition = <T = undefined>({
321
343
  const drawDeferred = useSharedValue(false);
322
344
  // Whether the previous vsync brought new frames.
323
345
  const importedLastVsync = useSharedValue(false);
346
+ // Draws submitted without waiting for the GPU since the last wait, the
347
+ // checked wait that ends every import of a new frame included.
348
+ const uncheckedDraws = useSharedValue(0);
349
+ const importWaits = useSharedValue(0);
324
350
  // A new drawFrame can draw something else from the same time and frames.
325
351
  useEffect(() => {
326
352
  lastDrawnTime.value = -1;
@@ -386,6 +412,13 @@ export const useVideoComposition = <T = undefined>({
386
412
  sourcesGeneration.value = generation;
387
413
  const currentTime = framesExtractor.currentTime;
388
414
  const framesVersion = framesExtractor.framesVersion;
415
+ // An import (above, or of another player on this runtime) ends with a
416
+ // checked wait for all the GPU work submitted before it.
417
+ const waits = videoFrameImportWaits();
418
+ if (waits !== importWaits.value) {
419
+ importWaits.value = waits;
420
+ uncheckedDraws.value = 0;
421
+ }
389
422
  const imported =
390
423
  splitImportAndDraw && framesVersion !== lastImportedVersion.value;
391
424
  const importedBefore = importedLastVsync.value;
@@ -460,8 +493,9 @@ export const useVideoComposition = <T = undefined>({
460
493
  // picture by the ratio between them, so the surface is re-keyed on its
461
494
  // dimensions the way exportVideoComposition already re-keys its own.
462
495
  const retired = {
463
- images: [currentFrame.value, pendingSnapshot.value],
496
+ images: [...bufferImages.value, pendingSnapshot.value],
464
497
  surface,
498
+ spareSurface: spareSurfaceSharedValue.value,
465
499
  canvas: canvasSharedValue.value,
466
500
  reservation: surfaceReservation.value,
467
501
  afterDraw: pendingAfterDraw.value?.run,
@@ -471,10 +505,14 @@ export const useVideoComposition = <T = undefined>({
471
505
  pendingAfterDraw.value = null;
472
506
  canvasSharedValue.value = null;
473
507
  surfaceSharedValue.value = null;
508
+ spareSurfaceSharedValue.value = null;
509
+ spareCanvasSharedValue.value = null;
510
+ bufferImages.value = [];
511
+ frontBuffer.value = -1;
474
512
  surfaceReservation.value = 0;
475
513
  const retiredFailure = disposePreviewResources(retired);
476
514
  if (retiredFailure.failed) throw retiredFailure.error;
477
- // Surface + immutable snapshot/COW backing while replacing the preview.
515
+ // The two surfaces the preview draws into in turn.
478
516
  const reservation = reserveVideoMemory(
479
517
  frameByteSize(pixelWidth, pixelHeight) * 2,
480
518
  "composition preview",
@@ -485,6 +523,13 @@ export const useVideoComposition = <T = undefined>({
485
523
  surfaceSharedValue.value = surface;
486
524
  if (!surface) throw new Error("Cannot allocate preview surface");
487
525
  canvasSharedValue.value = getVideoCanvas(surface);
526
+ const spare = Skia.Surface.MakeOffscreen(pixelWidth, pixelHeight);
527
+ spareSurfaceSharedValue.value = spare;
528
+ if (!spare) throw new Error("Cannot allocate preview surface");
529
+ spareCanvasSharedValue.value = getVideoCanvas(spare);
530
+ const first = surface.asImage();
531
+ bufferImages.value = [first];
532
+ bufferImages.value = [first, spare.asImage()];
488
533
  surfaceWidth.value = pixelWidth;
489
534
  surfaceHeight.value = pixelHeight;
490
535
  } catch (error) {
@@ -497,8 +542,13 @@ export const useVideoComposition = <T = undefined>({
497
542
  console.warn("Failed to create surface");
498
543
  return;
499
544
  }
545
+ // Draw into the buffer that is not on screen.
546
+ const back = frontBuffer.value === 0 ? 1 : 0;
547
+ if (back === 1) surface = spareSurfaceSharedValue.value!;
548
+ const backSurface = surface;
500
549
 
501
- const canvas = canvasSharedValue.value!;
550
+ const canvas =
551
+ back === 1 ? spareCanvasSharedValue.value! : canvasSharedValue.value!;
502
552
  // Cleared as the export clears it: a drawFrame that leaves part of the
503
553
  // canvas alone showed the previous frames there, and not in the export.
504
554
  canvas.drawColor(Skia.Color("#00000000"), BlendMode.Clear);
@@ -523,21 +573,24 @@ export const useVideoComposition = <T = undefined>({
523
573
  width: pixelWidth,
524
574
  height: pixelHeight,
525
575
  });
526
- const previous = currentFrame.value;
527
- const next = surface.makeImageSnapshot(
528
- undefined,
529
- previous ?? undefined,
530
- );
531
- if (next !== previous) pendingSnapshot.value = next;
532
- surface.flush(true);
576
+ const next = bufferImages.value[back]!;
577
+ // Waiting for the GPU here held the UI thread for the work of every
578
+ // Skia view on the shared context: a vsync in three with 4K frames on
579
+ // iOS with Skia 3.9.1, every other one before. The draw samples only
580
+ // images Skia owns, which the GPU work keeps alive, so it waits only
581
+ // when afterDrawFrame releases a context of the caller's, and after a
582
+ // few draws without an import, so that the GPU queue stays bounded.
583
+ const wait =
584
+ afterDrawFrame !== undefined ||
585
+ uncheckedDraws.value >= maxUncheckedDraws;
586
+ backSurface.flush(wait);
587
+ uncheckedDraws.value = wait ? 0 : uncheckedDraws.value + 1;
533
588
  drawn.submitted = true;
534
589
  frameGeneration.value = generation;
535
- if (next === previous) currentFrame.modify(undefined, true);
536
- else {
537
- currentFrame.value = next;
538
- previous?.dispose();
539
- }
540
- pendingSnapshot.value = null;
590
+ // The image shares the surface's texture: it shows this draw once
591
+ // the flush above has submitted it.
592
+ currentFrame.value = next;
593
+ frontBuffer.value = back;
541
594
  } catch (error) {
542
595
  drawn.failed = true;
543
596
  drawn.error = error;
@@ -41,7 +41,7 @@ export type VideoFrameFit = "fill" | "cover" | "contain";
41
41
  *
42
42
  * @param frame the frame, drawn in the tick it was handed out in
43
43
  * @param output an image returned for a previous frame, recycled rather than
44
- * allocating a new wrapper. Accepted for compatibility; owned snapshots are used in v3
44
+ * allocating a new wrapper. Accepted for compatibility; the image wraps a texture the library owns
45
45
  * @returns the image and its picture, or null when the frame has no texture
46
46
  */
47
47
  export const makeVideoFrameImage = (
package/src/yuvBlit.ts CHANGED
@@ -68,40 +68,52 @@ struct VsOut { @builtin(position) position: vec4f, @location(0) uv: vec2f };
68
68
  // primaries to sRGB, and every export brightens the midtones again.
69
69
  const sourceColorSpace = { primaries: "srgb", transfer: "srgb" } as const;
70
70
 
71
- /** The corner of a decoder buffer's crop rectangle once the buffer is turned
72
- * clockwise for display: the blit takes its source origin in that turned
73
- * space. Throws unless the rectangle lies in the buffer and turns into the
74
- * frame's display size. */
75
- 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 = (
76
76
  region: { x: number; y: number; width: number; height: number },
77
77
  rotation: number,
78
78
  buffer: { width: number; height: number },
79
- frame: { width: number; height: number },
80
- ): { x: number; y: number } => {
79
+ ): { x: number; y: number; width: number; height: number } => {
81
80
  "worklet";
82
81
  const turned = rotation === 90 || rotation === 270;
83
82
  if (
84
83
  (rotation !== 0 && !turned && rotation !== 180) ||
85
84
  region.x < 0 ||
86
85
  region.y < 0 ||
86
+ region.width <= 0 ||
87
+ region.height <= 0 ||
87
88
  region.x + region.width > buffer.width ||
88
- region.y + region.height > buffer.height ||
89
- (turned ? region.height : region.width) !== frame.width ||
90
- (turned ? region.width : region.height) !== frame.height
89
+ region.y + region.height > buffer.height
91
90
  ) {
92
91
  throw new Error("Native video buffer region or YUV layout mismatch");
93
92
  }
94
- const { width, height } = buffer;
93
+ const width = turned ? region.height : region.width;
94
+ const height = turned ? region.width : region.height;
95
95
  if (rotation === 90)
96
- 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
+ };
97
102
  if (rotation === 180)
98
103
  return {
99
- x: width - region.x - region.width,
100
- 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,
101
108
  };
102
109
  if (rotation === 270)
103
- return { x: region.y, y: width - region.x - region.width };
104
- 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 };
105
117
  };
106
118
 
107
119
  const getYuvBlit = (device: GPUDevice): YuvBlit => {
@@ -251,10 +263,11 @@ const importYuvBuffer = (
251
263
  };
252
264
 
253
265
  /**
254
- * Draws the upright `width`×`height` region at `origin` of a decoder buffer
255
- * into `target`, converting YUV to RGB; the buffer is imported on its first
256
- * frame. Submits the draw: the caller waits for it before releasing the
257
- * 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.
258
271
  */
259
272
  export const blitYuvFrame = (
260
273
  device: GPUDevice,
@@ -262,9 +275,7 @@ export const blitYuvFrame = (
262
275
  producer: number,
263
276
  source: NativeVideoFrame,
264
277
  rotation: number,
265
- origin: { x: number; y: number },
266
- width: number,
267
- height: number,
278
+ region: { x: number; y: number; width: number; height: number },
268
279
  target: GPUTexture,
269
280
  ): void => {
270
281
  "worklet";
@@ -280,7 +291,7 @@ export const blitYuvFrame = (
280
291
  );
281
292
  if (index < 0) {
282
293
  // The least recently drawn buffer goes first. Its last frame completed:
283
- // every import ends with a checked flush before the next one starts.
294
+ // fewer imports than this are ever on the GPU (see frameInterop.ts).
284
295
  if (entries.length >= maxImportsPerProducer) {
285
296
  releaseYuvImport(entries[0]!);
286
297
  entries.shift();
@@ -297,10 +308,10 @@ export const blitYuvFrame = (
297
308
  const frameWidth = turned ? source.height : source.width;
298
309
  const frameHeight = turned ? source.width : source.height;
299
310
  const mapping = [
300
- width / frameWidth,
301
- height / frameHeight,
302
- origin.x / frameWidth,
303
- origin.y / frameHeight,
311
+ region.width / frameWidth,
312
+ region.height / frameHeight,
313
+ region.x / frameWidth,
314
+ region.y / frameHeight,
304
315
  ];
305
316
  if (mapping.some((value, i) => value !== entry.mapping[i])) {
306
317
  device.queue.writeBuffer(entry.uniforms, 0, new Float32Array(mapping));
@@ -326,8 +337,8 @@ export const blitYuvFrame = (
326
337
  pass.end();
327
338
  device.queue.submit([encoder.finish()]);
328
339
  } finally {
329
- // The caller's checked flush covers the draw; the release fence is not
330
- // needed to hand the buffer back.
340
+ // The caller hands the decoder buffer back once the GPU is done with the
341
+ // draw; the release fence is not needed for that.
331
342
  entry.wrapper.endAccess()?.fence.destroy();
332
343
  }
333
344
  };
@@ -355,7 +366,11 @@ export const blitNv12Frame = (
355
366
  size: 16,
356
367
  usage: videoUniformBufferUsage,
357
368
  });
358
- device.queue.writeBuffer(blit.wholeFrame, 0, new Float32Array([1, 1, 0, 0]));
369
+ device.queue.writeBuffer(
370
+ blit.wholeFrame,
371
+ 0,
372
+ new Float32Array([1, 1, 0, 0]),
373
+ );
359
374
  }
360
375
  const external = device.importExternalTexture({
361
376
  label: "video decoder buffer",