react-native-skia-webgpu-video 0.1.0-beta.4 → 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 (46) hide show
  1. package/README.md +21 -23
  2. package/android/BUILD_VALIDATION.md +1 -1
  3. package/docs/FRAME_PIXEL_FORMAT.md +16 -1
  4. package/docs/UPSTREAM_NOTES.md +28 -1
  5. package/ios/RNSVColorSpace.h +8 -3
  6. package/lib/module/exportFrames.js +2 -2
  7. package/lib/module/exportFrames.js.map +1 -1
  8. package/lib/module/exportVideoComposition.js +25 -23
  9. package/lib/module/exportVideoComposition.js.map +1 -1
  10. package/lib/module/frameInterop.js +314 -70
  11. package/lib/module/frameInterop.js.map +1 -1
  12. package/lib/module/gpu.js +1 -1
  13. package/lib/module/gpu.js.map +1 -1
  14. package/lib/module/memory.js +7 -0
  15. package/lib/module/memory.js.map +1 -1
  16. package/lib/module/previewLifecycle.js +2 -0
  17. package/lib/module/previewLifecycle.js.map +1 -1
  18. package/lib/module/videoCompositionPlayer.js +65 -14
  19. package/lib/module/videoCompositionPlayer.js.map +1 -1
  20. package/lib/module/videoFrameImage.js +1 -1
  21. package/lib/module/yuvBlit.js +3 -3
  22. package/lib/typescript/src/exportVideoComposition.d.ts.map +1 -1
  23. package/lib/typescript/src/frameInterop.d.ts +16 -5
  24. package/lib/typescript/src/frameInterop.d.ts.map +1 -1
  25. package/lib/typescript/src/memory.d.ts +2 -0
  26. package/lib/typescript/src/memory.d.ts.map +1 -1
  27. package/lib/typescript/src/previewLifecycle.d.ts +2 -0
  28. package/lib/typescript/src/previewLifecycle.d.ts.map +1 -1
  29. package/lib/typescript/src/types.d.ts +8 -7
  30. package/lib/typescript/src/types.d.ts.map +1 -1
  31. package/lib/typescript/src/videoCompositionPlayer.d.ts +4 -5
  32. package/lib/typescript/src/videoCompositionPlayer.d.ts.map +1 -1
  33. package/lib/typescript/src/videoFrameImage.d.ts +1 -1
  34. package/package.json +6 -6
  35. package/patches/{react-native-skia+3.7.4.patch → react-native-skia+3.10.0.patch} +7 -3
  36. package/scripts/skia-canvas-patch.json +6 -6
  37. package/src/exportFrames.ts +2 -2
  38. package/src/exportVideoComposition.ts +43 -59
  39. package/src/frameInterop.ts +380 -85
  40. package/src/gpu.ts +1 -1
  41. package/src/memory.ts +6 -0
  42. package/src/previewLifecycle.ts +4 -0
  43. package/src/types.ts +8 -7
  44. package/src/videoCompositionPlayer.ts +76 -23
  45. package/src/videoFrameImage.ts +1 -1
  46. package/src/yuvBlit.ts +3 -3
@@ -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
@@ -291,7 +291,7 @@ export const blitYuvFrame = (
291
291
  );
292
292
  if (index < 0) {
293
293
  // The least recently drawn buffer goes first. Its last frame completed:
294
- // 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).
295
295
  if (entries.length >= maxImportsPerProducer) {
296
296
  releaseYuvImport(entries[0]!);
297
297
  entries.shift();
@@ -337,8 +337,8 @@ export const blitYuvFrame = (
337
337
  pass.end();
338
338
  device.queue.submit([encoder.finish()]);
339
339
  } finally {
340
- // The caller's checked flush covers the draw; the release fence is not
341
- // 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.
342
342
  entry.wrapper.endAccess()?.fence.destroy();
343
343
  }
344
344
  };