react-native-skia-webgpu-video 0.0.0-stage → 0.1.0-beta.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 (236) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +303 -2
  3. package/android/BUILD_VALIDATION.md +62 -0
  4. package/android/CMakeLists.txt +99 -0
  5. package/android/build-validation.json +262 -0
  6. package/android/build.gradle +185 -0
  7. package/android/cpp/EncoderTargets.cpp +226 -0
  8. package/android/cpp/EncoderTargets.h +70 -0
  9. package/android/cpp/JNIHelpers.cpp +15 -0
  10. package/android/cpp/JNIHelpers.h +20 -0
  11. package/android/cpp/NativeEventDispatcher.cpp +59 -0
  12. package/android/cpp/NativeEventDispatcher.h +31 -0
  13. package/android/cpp/NativeHardwareBuffer.cpp +397 -0
  14. package/android/cpp/NativeHardwareBuffer.h +34 -0
  15. package/android/cpp/NativeRgbaBuffer.cpp +45 -0
  16. package/android/cpp/NativeRgbaBuffer.h +9 -0
  17. package/android/cpp/ReturnedVideoFrames.h +37 -0
  18. package/android/cpp/RgbaFrameLease.h +53 -0
  19. package/android/cpp/VideoCapabilities.cpp +87 -0
  20. package/android/cpp/VideoCapabilities.h +44 -0
  21. package/android/cpp/VideoComposition.cpp +170 -0
  22. package/android/cpp/VideoComposition.h +36 -0
  23. package/android/cpp/VideoCompositionFramesExtractor.cpp +82 -0
  24. package/android/cpp/VideoCompositionFramesExtractor.h +48 -0
  25. package/android/cpp/VideoCompositionFramesExtractorHostObject.cpp +230 -0
  26. package/android/cpp/VideoCompositionFramesExtractorHostObject.h +42 -0
  27. package/android/cpp/VideoCompositionFramesExtractorSyncHostObject.cpp +133 -0
  28. package/android/cpp/VideoCompositionFramesExtractorSyncHostObject.h +46 -0
  29. package/android/cpp/VideoEncoderHostObject.cpp +318 -0
  30. package/android/cpp/VideoEncoderHostObject.h +76 -0
  31. package/android/cpp/VideoFrame.cpp +293 -0
  32. package/android/cpp/VideoFrame.h +27 -0
  33. package/android/cpp/VideoPlayer.cpp +96 -0
  34. package/android/cpp/VideoPlayer.h +52 -0
  35. package/android/cpp/VideoPlayerHostObject.cpp +225 -0
  36. package/android/cpp/VideoPlayerHostObject.h +43 -0
  37. package/android/cpp/cpp-adapter.cpp +316 -0
  38. package/android/gradle.properties +5 -0
  39. package/android/instrumentation-inventory.json +178 -0
  40. package/android/proguard-rules.pro +4 -0
  41. package/android/src/main/AndroidManifest.xml +2 -0
  42. package/android/src/main/java/com/azzapp/rnskv/AudioCompositionExporter.java +537 -0
  43. package/android/src/main/java/com/azzapp/rnskv/AudioCompositionPlayer.java +321 -0
  44. package/android/src/main/java/com/azzapp/rnskv/BufferLifetime.java +55 -0
  45. package/android/src/main/java/com/azzapp/rnskv/DecodeRequests.java +100 -0
  46. package/android/src/main/java/com/azzapp/rnskv/DecoderWindow.java +55 -0
  47. package/android/src/main/java/com/azzapp/rnskv/EGLResourcesHolder.java +132 -0
  48. package/android/src/main/java/com/azzapp/rnskv/EGLUtils.java +225 -0
  49. package/android/src/main/java/com/azzapp/rnskv/FrameSize.java +50 -0
  50. package/android/src/main/java/com/azzapp/rnskv/GLFrameExtractor.java +510 -0
  51. package/android/src/main/java/com/azzapp/rnskv/JNIHelpers.java +12 -0
  52. package/android/src/main/java/com/azzapp/rnskv/NativeEventDispatcher.java +25 -0
  53. package/android/src/main/java/com/azzapp/rnskv/NativeHardwareBuffer.java +62 -0
  54. package/android/src/main/java/com/azzapp/rnskv/NativeRgbaBuffer.java +35 -0
  55. package/android/src/main/java/com/azzapp/rnskv/ReactNativeSkiaVideoModule.java +45 -0
  56. package/android/src/main/java/com/azzapp/rnskv/ReactNativeSkiaVideoPackage.java +43 -0
  57. package/android/src/main/java/com/azzapp/rnskv/ReactNativeSkiaVideoSpec.java +9 -0
  58. package/android/src/main/java/com/azzapp/rnskv/RgbaBufferAllocator.java +83 -0
  59. package/android/src/main/java/com/azzapp/rnskv/RgbaLayout.java +33 -0
  60. package/android/src/main/java/com/azzapp/rnskv/SurfaceOutputPolicy.java +13 -0
  61. package/android/src/main/java/com/azzapp/rnskv/TextureRenderer.java +150 -0
  62. package/android/src/main/java/com/azzapp/rnskv/TimeHelpers.java +11 -0
  63. package/android/src/main/java/com/azzapp/rnskv/VideoCapabilities.java +354 -0
  64. package/android/src/main/java/com/azzapp/rnskv/VideoComposition.java +147 -0
  65. package/android/src/main/java/com/azzapp/rnskv/VideoCompositionDecoder.java +772 -0
  66. package/android/src/main/java/com/azzapp/rnskv/VideoCompositionFramesExtractor.java +354 -0
  67. package/android/src/main/java/com/azzapp/rnskv/VideoCompositionFramesExtractorSync.java +241 -0
  68. package/android/src/main/java/com/azzapp/rnskv/VideoCompositionItemDecoder.java +492 -0
  69. package/android/src/main/java/com/azzapp/rnskv/VideoEncoder.java +639 -0
  70. package/android/src/main/java/com/azzapp/rnskv/VideoFrame.java +74 -0
  71. package/android/src/main/java/com/azzapp/rnskv/VideoPlayer.java +361 -0
  72. package/cpp/RNSVCheckedSizes.h +54 -0
  73. package/cpp/RNSVEventEmitter.cpp +199 -0
  74. package/cpp/RNSVEventEmitter.h +31 -0
  75. package/cpp/RNSVHostObject.cpp +79 -0
  76. package/cpp/RNSVHostObject.h +67 -0
  77. package/cpp/RNSVMemoryBudget.cpp +68 -0
  78. package/cpp/RNSVMemoryBudget.h +100 -0
  79. package/cpp/RNSVRuntimeLifecycleMonitor.cpp +88 -0
  80. package/cpp/RNSVRuntimeLifecycleMonitor.h +33 -0
  81. package/docs/ANDROID_DEVICE_306.md +95 -0
  82. package/docs/ANDROID_EXPORT_CACHE_306.md +48 -0
  83. package/docs/ANDROID_MEMORY_RETENTION_306.md +67 -0
  84. package/docs/BENCHMARKS.md +368 -0
  85. package/docs/IOS_DEVICE_TESTS.md +115 -0
  86. package/docs/IOS_SIMULATOR_TESTS.md +74 -0
  87. package/docs/MIGRATION.md +82 -0
  88. package/docs/NATIVE_MEMORY_INVESTIGATION_306.md +88 -0
  89. package/docs/PERFORMANCE_AB_IPHONE_303.md +196 -0
  90. package/docs/PERFORMANCE_AB_SIMULATOR.md +135 -0
  91. package/docs/PERFORMANCE_AB_SOAK_SIMULATOR.md +84 -0
  92. package/docs/PERFORMANCE_IPHONE_306_OPTIMIZATION.md +76 -0
  93. package/docs/PERFORMANCE_SIMULATOR.md +89 -0
  94. package/docs/PERFORMANCE_SIMULATOR_INITIAL.md +96 -0
  95. package/docs/PERFORMANCE_SOAK_IPHONE_303.md +41 -0
  96. package/docs/UPSTREAM_NOTES.md +188 -0
  97. package/docs/assets/banner.svg +26 -0
  98. package/examples/webgpu-overlay.ts +147 -0
  99. package/ios/AVAssetTrackUtils.h +24 -0
  100. package/ios/AudioCompositionUtils.h +27 -0
  101. package/ios/AudioCompositionUtils.mm +113 -0
  102. package/ios/DecoderWindow.h +52 -0
  103. package/ios/NativeResourceStats.h +32 -0
  104. package/ios/RNSVColorSpace.h +50 -0
  105. package/ios/RNSVJSIUtils.h +46 -0
  106. package/ios/RNSVScopedPixelBuffer.h +17 -0
  107. package/ios/RNSVVideoPlayer.h +46 -0
  108. package/ios/RNSVVideoPlayer.mm +410 -0
  109. package/ios/ReactNativeSkiaVideo.h +8 -0
  110. package/ios/ReactNativeSkiaVideo.mm +310 -0
  111. package/ios/SeekPolicy.h +27 -0
  112. package/ios/VideoComposition.h +148 -0
  113. package/ios/VideoCompositionFramesExtractorHostObject.h +105 -0
  114. package/ios/VideoCompositionFramesExtractorHostObject.mm +686 -0
  115. package/ios/VideoCompositionFramesExtractorSyncHostObject.h +37 -0
  116. package/ios/VideoCompositionFramesExtractorSyncHostObject.mm +168 -0
  117. package/ios/VideoCompositionItemDecoder.h +60 -0
  118. package/ios/VideoCompositionItemDecoder.mm +480 -0
  119. package/ios/VideoEncoderHostObject.h +83 -0
  120. package/ios/VideoEncoderHostObject.mm +706 -0
  121. package/ios/VideoFrame.h +66 -0
  122. package/ios/VideoFrame.mm +108 -0
  123. package/ios/VideoPlayerHostObject.h +52 -0
  124. package/ios/VideoPlayerHostObject.mm +332 -0
  125. package/lib/module/NativeReactNativeSkiaVideo.js +5 -0
  126. package/lib/module/NativeReactNativeSkiaVideo.js.map +1 -0
  127. package/lib/module/RNSkiaVideoModule.js +20 -0
  128. package/lib/module/RNSkiaVideoModule.js.map +1 -0
  129. package/lib/module/canvas.js +18 -0
  130. package/lib/module/canvas.js.map +1 -0
  131. package/lib/module/exportCache.js +22 -0
  132. package/lib/module/exportCache.js.map +1 -0
  133. package/lib/module/exportFrames.js +338 -0
  134. package/lib/module/exportFrames.js.map +1 -0
  135. package/lib/module/exportQueue.js +52 -0
  136. package/lib/module/exportQueue.js.map +1 -0
  137. package/lib/module/exportTarget.js +137 -0
  138. package/lib/module/exportTarget.js.map +1 -0
  139. package/lib/module/exportVideoComposition.js +492 -0
  140. package/lib/module/exportVideoComposition.js.map +1 -0
  141. package/lib/module/frameInterop.js +532 -0
  142. package/lib/module/frameInterop.js.map +1 -0
  143. package/lib/module/gpu.js +222 -0
  144. package/lib/module/gpu.js.map +1 -0
  145. package/lib/module/gpuDevice.js +54 -0
  146. package/lib/module/gpuDevice.js.map +1 -0
  147. package/lib/module/index.js +36 -0
  148. package/lib/module/index.js.map +1 -0
  149. package/lib/module/memory.js +38 -0
  150. package/lib/module/memory.js.map +1 -0
  151. package/lib/module/package.json +1 -0
  152. package/lib/module/previewLifecycle.js +52 -0
  153. package/lib/module/previewLifecycle.js.map +1 -0
  154. package/lib/module/runtimeYield.js +10 -0
  155. package/lib/module/runtimeYield.js.map +1 -0
  156. package/lib/module/types.js +4 -0
  157. package/lib/module/types.js.map +1 -0
  158. package/lib/module/utils/useEventListener.js +18 -0
  159. package/lib/module/utils/useEventListener.js.map +1 -0
  160. package/lib/module/videoCompositionPlayer.js +348 -0
  161. package/lib/module/videoCompositionPlayer.js.map +1 -0
  162. package/lib/module/videoFrameImage.js +185 -0
  163. package/lib/module/videoFrameImage.js.map +1 -0
  164. package/lib/module/videoPlayer.js +172 -0
  165. package/lib/module/videoPlayer.js.map +1 -0
  166. package/lib/module/yuvBlit.js +299 -0
  167. package/lib/module/yuvBlit.js.map +1 -0
  168. package/lib/typescript/package.json +1 -0
  169. package/lib/typescript/src/NativeReactNativeSkiaVideo.d.ts +7 -0
  170. package/lib/typescript/src/NativeReactNativeSkiaVideo.d.ts.map +1 -0
  171. package/lib/typescript/src/RNSkiaVideoModule.d.ts +4 -0
  172. package/lib/typescript/src/RNSkiaVideoModule.d.ts.map +1 -0
  173. package/lib/typescript/src/canvas.d.ts +4 -0
  174. package/lib/typescript/src/canvas.d.ts.map +1 -0
  175. package/lib/typescript/src/exportCache.d.ts +6 -0
  176. package/lib/typescript/src/exportCache.d.ts.map +1 -0
  177. package/lib/typescript/src/exportFrames.d.ts +57 -0
  178. package/lib/typescript/src/exportFrames.d.ts.map +1 -0
  179. package/lib/typescript/src/exportQueue.d.ts +5 -0
  180. package/lib/typescript/src/exportQueue.d.ts.map +1 -0
  181. package/lib/typescript/src/exportTarget.d.ts +28 -0
  182. package/lib/typescript/src/exportTarget.d.ts.map +1 -0
  183. package/lib/typescript/src/exportVideoComposition.d.ts +68 -0
  184. package/lib/typescript/src/exportVideoComposition.d.ts.map +1 -0
  185. package/lib/typescript/src/frameInterop.d.ts +17 -0
  186. package/lib/typescript/src/frameInterop.d.ts.map +1 -0
  187. package/lib/typescript/src/gpu.d.ts +39 -0
  188. package/lib/typescript/src/gpu.d.ts.map +1 -0
  189. package/lib/typescript/src/gpuDevice.d.ts +11 -0
  190. package/lib/typescript/src/gpuDevice.d.ts.map +1 -0
  191. package/lib/typescript/src/index.d.ts +21 -0
  192. package/lib/typescript/src/index.d.ts.map +1 -0
  193. package/lib/typescript/src/memory.d.ts +25 -0
  194. package/lib/typescript/src/memory.d.ts.map +1 -0
  195. package/lib/typescript/src/previewLifecycle.d.ts +22 -0
  196. package/lib/typescript/src/previewLifecycle.d.ts.map +1 -0
  197. package/lib/typescript/src/runtimeYield.d.ts +4 -0
  198. package/lib/typescript/src/runtimeYield.d.ts.map +1 -0
  199. package/lib/typescript/src/types.d.ts +631 -0
  200. package/lib/typescript/src/types.d.ts.map +1 -0
  201. package/lib/typescript/src/utils/useEventListener.d.ts +5 -0
  202. package/lib/typescript/src/utils/useEventListener.d.ts.map +1 -0
  203. package/lib/typescript/src/videoCompositionPlayer.d.ts +85 -0
  204. package/lib/typescript/src/videoCompositionPlayer.d.ts.map +1 -0
  205. package/lib/typescript/src/videoFrameImage.d.ts +60 -0
  206. package/lib/typescript/src/videoFrameImage.d.ts.map +1 -0
  207. package/lib/typescript/src/videoPlayer.d.ts +99 -0
  208. package/lib/typescript/src/videoPlayer.d.ts.map +1 -0
  209. package/lib/typescript/src/yuvBlit.d.ts +50 -0
  210. package/lib/typescript/src/yuvBlit.d.ts.map +1 -0
  211. package/package.json +154 -4
  212. package/patches/react-native-skia+3.7.0.patch +502 -0
  213. package/react-native-skia-webgpu-video.podspec +35 -0
  214. package/scripts/apply-skia-canvas-patch.mjs +48 -0
  215. package/scripts/skia-canvas-patch.json +291 -0
  216. package/src/NativeReactNativeSkiaVideo.ts +8 -0
  217. package/src/RNSkiaVideoModule.ts +27 -0
  218. package/src/canvas.ts +22 -0
  219. package/src/exportCache.ts +27 -0
  220. package/src/exportFrames.ts +421 -0
  221. package/src/exportQueue.ts +65 -0
  222. package/src/exportTarget.ts +179 -0
  223. package/src/exportVideoComposition.ts +718 -0
  224. package/src/frameInterop.ts +684 -0
  225. package/src/gpu.ts +290 -0
  226. package/src/gpuDevice.ts +80 -0
  227. package/src/index.tsx +45 -0
  228. package/src/memory.ts +48 -0
  229. package/src/previewLifecycle.ts +60 -0
  230. package/src/runtimeYield.ts +6 -0
  231. package/src/types.ts +661 -0
  232. package/src/utils/useEventListener.ts +22 -0
  233. package/src/videoCompositionPlayer.ts +520 -0
  234. package/src/videoFrameImage.ts +205 -0
  235. package/src/videoPlayer.ts +318 -0
  236. package/src/yuvBlit.ts +331 -0
@@ -0,0 +1,684 @@
1
+ import { getVideoCanvas } from "./canvas";
2
+ import { Platform } from "react-native";
3
+ import { AlphaType, BlendMode, ColorType, Skia } from "react-native-skia";
4
+ import type { SkCanvas, SkImage, SkSurface } from "react-native-skia";
5
+ import type { VideoFrame } from "./types";
6
+ import type { NativeVideoFrame } from "react-native-webgpu";
7
+ import {
8
+ getVideoGpuDevice,
9
+ createNativeVideoGpuFrame,
10
+ nativeVideoTextureUsage,
11
+ rgbaVideoTextureUsage,
12
+ } from "./gpuDevice";
13
+ import {
14
+ frameByteSize,
15
+ releaseVideoMemory,
16
+ reserveVideoMemory,
17
+ } from "./memory";
18
+ import { blitYuvFrame, releaseYuvImports, yuvFrameOrigin } from "./yuvBlit";
19
+ import type { YuvImport } from "./yuvBlit";
20
+
21
+ type FrameTexture =
22
+ | {
23
+ kind: "native-buffer";
24
+ nativeBuffer: bigint;
25
+ /** Android ImageReader frames: the codec's YUV buffer, whose picture is
26
+ * this crop rectangle turned clockwise by `rotation` for display. */
27
+ region?: { x: number; y: number; width: number; height: number };
28
+ rotation?: number;
29
+ }
30
+ | {
31
+ kind: "rgba";
32
+ data: ArrayBuffer;
33
+ bytesPerRow: number;
34
+ copyPixelsTo?: (destination: ArrayBuffer) => void;
35
+ }
36
+ | { kind: "skia-image"; image: SkImage };
37
+ type Entry = {
38
+ id: number;
39
+ image: SkImage | null;
40
+ reservation: number;
41
+ releasedSources: WeakSet<VideoFrame>;
42
+ };
43
+ type PendingSource = {
44
+ owned: SkImage | null;
45
+ reservation: number;
46
+ frame: VideoFrame;
47
+ needsDrain: boolean;
48
+ sourceReleased: boolean;
49
+ nativeSource?: NativeVideoFrame | null;
50
+ };
51
+ type Cache = {
52
+ images: Map<number, Entry>;
53
+ stage: SkSurface | null;
54
+ canvas: SkCanvas | null;
55
+ width: number;
56
+ height: number;
57
+ format: GPUTextureFormat | null;
58
+ uploadTexture: GPUTexture | null;
59
+ uploadReservation: number;
60
+ cpuPixels: Uint8Array<ArrayBuffer> | null;
61
+ cpuReservation: number;
62
+ cpuBytesPerRow: number;
63
+ pendingSources: PendingSource[];
64
+ /** Android decoder buffers imported once, per producer. */
65
+ yuvImports: Map<number, YuvImport[]>;
66
+ };
67
+
68
+ // Capture a scalar on the RN runtime instead of importing Platform inside a
69
+ // worklet. iOS keeps its existing native-buffer and RGBA compatibility paths.
70
+ const uploadAndroidRgba = Platform.OS === "android";
71
+
72
+ const getCache = (): Cache => {
73
+ "worklet";
74
+ const runtime = globalThis as typeof globalThis & {
75
+ __rnskwgpuFrames?: Cache;
76
+ };
77
+ if (!runtime.__rnskwgpuFrames) {
78
+ runtime.__rnskwgpuFrames = {
79
+ images: new Map(),
80
+ stage: null,
81
+ canvas: null,
82
+ width: 0,
83
+ height: 0,
84
+ format: null,
85
+ uploadTexture: null,
86
+ uploadReservation: 0,
87
+ cpuPixels: null,
88
+ cpuReservation: 0,
89
+ cpuBytesPerRow: 0,
90
+ pendingSources: [],
91
+ yuvImports: new Map(),
92
+ };
93
+ }
94
+ return runtime.__rnskwgpuFrames;
95
+ };
96
+
97
+ const clearEntry = (cache: Cache, producer: number) => {
98
+ "worklet";
99
+ const entry = cache.images.get(producer);
100
+ if (entry) {
101
+ entry.image?.dispose();
102
+ entry.image = null;
103
+ releaseVideoMemory(entry.reservation);
104
+ cache.images.delete(producer);
105
+ }
106
+ };
107
+
108
+ /** Release only the current Skia view, never the shared texture it wraps.
109
+ * The caller has either not submitted a copy or has completed its GPU fence. */
110
+ const clearSurfaceView = (cache: Cache) => {
111
+ "worklet";
112
+ cache.canvas?.dispose();
113
+ cache.canvas = null;
114
+ cache.stage?.dispose();
115
+ cache.stage = null;
116
+ };
117
+
118
+ const clearCpuPixels = (cache: Cache) => {
119
+ "worklet";
120
+ cache.cpuPixels = null;
121
+ releaseVideoMemory(cache.cpuReservation);
122
+ cache.cpuReservation = 0;
123
+ cache.cpuBytesPerRow = 0;
124
+ };
125
+
126
+ const clearStagingSurface = (cache: Cache) => {
127
+ "worklet";
128
+ cache.stage?.flush(true);
129
+ clearSurfaceView(cache);
130
+ cache.uploadTexture?.destroy();
131
+ cache.uploadTexture = null;
132
+ releaseVideoMemory(cache.uploadReservation);
133
+ cache.uploadReservation = 0;
134
+ clearCpuPixels(cache);
135
+ cache.width = cache.height = 0;
136
+ cache.format = null;
137
+ };
138
+
139
+ /** Record explicit recovery work after a possibly submitted external copy.
140
+ * Clearing scratch cannot modify an already owned snapshot. The checked fence
141
+ * must succeed before either native lease or the texture can be released. */
142
+ const drainNativeCopies = (cache: Cache): void => {
143
+ "worklet";
144
+ if (!cache.stage || !cache.canvas)
145
+ throw new Error("Video import drain surface is unavailable");
146
+ cache.canvas.drawColor(Skia.Color("#00000000"), BlendMode.Clear);
147
+ cache.stage.flush(true);
148
+ };
149
+
150
+ /** Clear a runtime's imported images after ending all its producers.
151
+ * Do not clear a paused producer's current frame: its native source has
152
+ * already been released, and the owned snapshot is its remaining pixels. */
153
+ export const clearVideoFrameImages = (releaseStagingSurface = false): void => {
154
+ "worklet";
155
+ const cache = getCache();
156
+ if (cache.pendingSources.length) {
157
+ // A failed drain retains leases; only a successful fence can release them.
158
+ if (cache.pendingSources.some((source) => source.needsDrain)) {
159
+ drainNativeCopies(cache);
160
+ for (const source of cache.pendingSources) source.needsDrain = false;
161
+ }
162
+ while (cache.pendingSources.length) {
163
+ const source = cache.pendingSources[0]!;
164
+ source.owned?.dispose();
165
+ source.owned = null;
166
+ source.nativeSource?.release();
167
+ source.nativeSource = null;
168
+ if (!source.sourceReleased) {
169
+ source.frame.dispose?.();
170
+ source.sourceReleased = true;
171
+ }
172
+ releaseVideoMemory(source.reservation);
173
+ cache.pendingSources.shift();
174
+ }
175
+ }
176
+ clearSurfaceView(cache);
177
+ for (const producer of cache.images.keys()) clearEntry(cache, producer);
178
+ if (releaseStagingSurface) {
179
+ clearStagingSurface(cache);
180
+ // Every import's last frame completed: the queue drained above.
181
+ releaseYuvImports(cache.yuvImports);
182
+ }
183
+ };
184
+
185
+ /** Release an item which has left a composition or a producer being closed. */
186
+ export const releaseVideoFrameImage = (frame: VideoFrame): void => {
187
+ "worklet";
188
+ // Synthetic processor images without decoder identities belong to their
189
+ // GPU scope. They must not evict the fallback native producer cache.
190
+ if (
191
+ frame.producerId === undefined &&
192
+ frame.id === undefined &&
193
+ (frame.texture as FrameTexture | null)?.kind === "skia-image"
194
+ )
195
+ return;
196
+ const producer = frame.producerId ?? frame.id ?? -1;
197
+ const cache = getCache();
198
+ const entry = cache.images.get(producer);
199
+ if (entry && entry.id === (frame.id ?? -1)) clearEntry(cache, producer);
200
+ // Each import ends with a checked flush; after a failed one, only the
201
+ // drain in clearVideoFrameImages may release the decoder buffers.
202
+ if (!cache.pendingSources.length)
203
+ releaseYuvImports(cache.yuvImports, producer);
204
+ if (cache.images.size === 0) clearVideoFrameImages(true);
205
+ };
206
+
207
+ /** Returns immutable, owned pixels. WebGPU uploads Android RGBA pixels or blits
208
+ * native buffers into one reusable texture, then snapshots a fresh Skia view.
209
+ * Graphite-backed snapshots bypass the raster ImageProvider's image-count LRU.
210
+ * Wait for completion before releasing sources or rewriting the scratch texture. */
211
+ export const imageFromVideoFrame = (frame: VideoFrame): SkImage | null => {
212
+ "worklet";
213
+ const cache = getCache();
214
+ const producer = frame.producerId ?? frame.id;
215
+ if (producer !== undefined && frame.id !== undefined) {
216
+ const existing = cache.images.get(producer);
217
+ if (existing?.id === frame.id && existing.image) {
218
+ // Android can issue a fresh lease for the same paused picture. The
219
+ // snapshot already owns its pixels, so close that unused native lease.
220
+ // Weak tracking avoids retaining raw buffers or closing one wrapper twice.
221
+ if (!existing.releasedSources.has(frame)) {
222
+ frame.dispose?.();
223
+ existing.releasedSources.add(frame);
224
+ }
225
+ return existing.image;
226
+ }
227
+ }
228
+ let reservation = 0;
229
+ let image: SkImage | null = null;
230
+ let copyPending = false;
231
+ let ownsSurfaceView = false;
232
+ let nativeSource: NativeVideoFrame | null = null;
233
+ let sourceDrained = false;
234
+ let releaseSource = true;
235
+ let importFailed = false;
236
+ try {
237
+ if (cache.pendingSources.length) {
238
+ // A caller may retry the same native frame (or an alias of its stable
239
+ // identity). Its existing pending entry still owns both leases; do not
240
+ // let this rejected re-entry revoke the original decoder reservation.
241
+ if (
242
+ cache.pendingSources.some(
243
+ (source) =>
244
+ source.frame === frame ||
245
+ (frame.id !== undefined &&
246
+ source.frame.id === frame.id &&
247
+ source.frame.producerId === frame.producerId),
248
+ )
249
+ )
250
+ releaseSource = false;
251
+ throw new Error(
252
+ "Previous video import could not drain its GPU work; restart the native runtime",
253
+ );
254
+ }
255
+ const texture = frame.texture as FrameTexture | null | undefined;
256
+ if (!texture) return null;
257
+ if (texture.kind === "skia-image") return texture.image;
258
+ if (texture.kind !== "rgba" && texture.kind !== "native-buffer") {
259
+ throw new Error(
260
+ "Unsupported frame transport; use the Skia 3 video backend",
261
+ );
262
+ }
263
+ const bytes = frameByteSize(frame.width, frame.height);
264
+ // Keep the published image valid until its replacement has succeeded.
265
+ // Account for the replacement's peak memory rather than evict paused pixels.
266
+ const key = producer ?? -1;
267
+ if (!cache.images.has(key) && cache.images.size >= 32) {
268
+ throw new Error(
269
+ "Too many cached video producers; close unused extractors",
270
+ );
271
+ }
272
+ let rgbaPixels: Uint8Array<ArrayBuffer> | null = null;
273
+ let requiredRgbaBytes = 0;
274
+ let copyRgbaPixels = false;
275
+ if (texture.kind === "rgba") {
276
+ const required = texture.bytesPerRow * frame.height;
277
+ if (
278
+ !Number.isSafeInteger(texture.bytesPerRow) ||
279
+ !Number.isSafeInteger(required) ||
280
+ texture.bytesPerRow < frame.width * 4 ||
281
+ (uploadAndroidRgba &&
282
+ (texture.bytesPerRow > 0xffffffff || texture.bytesPerRow % 4 !== 0))
283
+ ) {
284
+ throw new Error("Invalid RGBA video frame storage");
285
+ }
286
+ requiredRgbaBytes = required;
287
+ // Native Android data is lazy. Inspect the optional copy protocol before
288
+ // reading that getter, which would otherwise retain a Java backing per
289
+ // external ArrayBuffer until Hermes and ART have both collected it.
290
+ copyRgbaPixels =
291
+ uploadAndroidRgba && typeof texture.copyPixelsTo === "function";
292
+ if (!copyRgbaPixels) {
293
+ const pixels = new Uint8Array(texture.data);
294
+ if (pixels.byteLength < required)
295
+ throw new Error("Invalid RGBA video frame storage");
296
+ if (uploadAndroidRgba) {
297
+ // Exclude unused trailing storage without another pixel copy.
298
+ rgbaPixels = new Uint8Array(texture.data, 0, required);
299
+ } else {
300
+ // Preserve the iOS compatibility raster path.
301
+ reservation = reserveVideoMemory(
302
+ pixels.byteLength,
303
+ "owned video image",
304
+ );
305
+ const data = Skia.Data.fromBytes(pixels);
306
+ try {
307
+ image = Skia.Image.MakeRasterImage(
308
+ {
309
+ width: frame.width,
310
+ height: frame.height,
311
+ colorType: ColorType.RGBA_8888,
312
+ alphaType: AlphaType.Opaque,
313
+ },
314
+ data,
315
+ texture.bytesPerRow,
316
+ );
317
+ } finally {
318
+ data.dispose();
319
+ }
320
+ }
321
+ }
322
+ }
323
+ if (texture.kind === "native-buffer" || rgbaPixels || copyRgbaPixels) {
324
+ const device = getVideoGpuDevice();
325
+ const uploadRgba = texture.kind === "rgba";
326
+ if (uploadRgba || uploadAndroidRgba) {
327
+ const extentLimit = device.limits.maxTextureDimension2D;
328
+ if (
329
+ !Number.isSafeInteger(extentLimit) ||
330
+ extentLimit <= 0 ||
331
+ frame.width > extentLimit ||
332
+ frame.height > extentLimit
333
+ )
334
+ throw new Error(
335
+ "Video frame exceeds the WebGPU texture extent limit",
336
+ );
337
+ }
338
+ const format =
339
+ uploadRgba || uploadAndroidRgba ? "rgba8unorm" : "bgra8unorm";
340
+ reservation = reserveVideoMemory(bytes, "owned video image");
341
+ if (
342
+ !cache.uploadTexture ||
343
+ cache.width !== frame.width ||
344
+ cache.height !== frame.height ||
345
+ cache.format !== format
346
+ ) {
347
+ clearStagingSurface(cache);
348
+ cache.uploadReservation = reserveVideoMemory(
349
+ bytes,
350
+ uploadRgba
351
+ ? "RGBA frame WebGPU upload texture"
352
+ : "native frame WebGPU upload texture",
353
+ );
354
+ try {
355
+ // One scratch texture per runtime. COPY_SRC lets Graphite snapshot
356
+ // it with a GPU blit rather than draw into another staging target.
357
+ cache.uploadTexture = device.createTexture({
358
+ size: { width: frame.width, height: frame.height },
359
+ format,
360
+ // Android AHB and CPU frames share the RGBA scratch. Include
361
+ // COPY_DST even for a first AHB frame, so a later CPU fallback
362
+ // cannot reuse a texture that forbids writeTexture.
363
+ usage:
364
+ uploadRgba || uploadAndroidRgba
365
+ ? rgbaVideoTextureUsage
366
+ : nativeVideoTextureUsage,
367
+ label: uploadRgba
368
+ ? "video RGBA frame upload"
369
+ : "video native frame upload",
370
+ });
371
+ cache.width = frame.width;
372
+ cache.height = frame.height;
373
+ cache.format = format;
374
+ } catch (error) {
375
+ try {
376
+ clearStagingSurface(cache);
377
+ } catch {
378
+ /* unfinished cleanup/accounting stays reachable */
379
+ }
380
+ throw error;
381
+ }
382
+ }
383
+ if (copyRgbaPixels && texture.kind === "rgba") {
384
+ if (
385
+ !cache.cpuPixels ||
386
+ cache.cpuPixels.byteLength !== requiredRgbaBytes ||
387
+ cache.cpuBytesPerRow !== texture.bytesPerRow
388
+ ) {
389
+ clearCpuPixels(cache);
390
+ cache.cpuReservation = reserveVideoMemory(
391
+ requiredRgbaBytes,
392
+ "RGBA frame CPU upload buffer",
393
+ );
394
+ try {
395
+ cache.cpuPixels = new Uint8Array(
396
+ new ArrayBuffer(requiredRgbaBytes),
397
+ );
398
+ cache.cpuBytesPerRow = texture.bytesPerRow;
399
+ } catch (error) {
400
+ try {
401
+ clearCpuPixels(cache);
402
+ } catch {
403
+ /* unfinished accounting stays reachable */
404
+ }
405
+ throw error;
406
+ }
407
+ }
408
+ // The host function finishes memcpy synchronously. The private buffer
409
+ // is never exposed as texture.data and is rewritten only after the
410
+ // preceding import's checked fence. Saved raw aliases remain separate.
411
+ texture.copyPixelsTo!(cache.cpuPixels.buffer);
412
+ rgbaPixels = cache.cpuPixels;
413
+ } else {
414
+ // Native AHB frames and raw compatibility frames need no private CPU
415
+ // scratch. This also closes CPU scratch on same-format RGBA -> AHB.
416
+ clearCpuPixels(cache);
417
+ }
418
+ clearSurfaceView(cache);
419
+ // WebGPU writes do not invalidate SkSurface's cached snapshot. A fresh
420
+ // surface wrapper for every frame prevents stale pixels without another
421
+ // GPU allocation or a write through Skia that would overwrite the input.
422
+ cache.stage = Skia.Surface.MakeFromGPUTexture(cache.uploadTexture!);
423
+ if (!cache.stage) throw new Error("Cannot wrap video upload texture");
424
+ ownsSurfaceView = true;
425
+ cache.canvas = getVideoCanvas(cache.stage);
426
+ if (rgbaPixels && texture.kind === "rgba") {
427
+ // Dawn consumes this typed-array view synchronously. Keep the source
428
+ // alive through the checked fence, including a throw after enqueue.
429
+ copyPending = true;
430
+ device.queue.writeTexture(
431
+ { texture: cache.uploadTexture! },
432
+ rgbaPixels,
433
+ {
434
+ offset: 0,
435
+ bytesPerRow: texture.bytesPerRow,
436
+ rowsPerImage: frame.height,
437
+ },
438
+ { width: frame.width, height: frame.height, depthOrArrayLayers: 1 },
439
+ );
440
+ } else if (texture.kind === "native-buffer") {
441
+ nativeSource = createNativeVideoGpuFrame(texture.nativeBuffer);
442
+ const region = texture.region;
443
+ const rotation = texture.rotation ?? 0;
444
+ let origin = { x: 0, y: 0 };
445
+ if (region) {
446
+ // 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);
452
+ } else if (
453
+ // RN WebGPU labels a single-plane Android RGBA AHB "bgra8". Dawn
454
+ // reads the actual AHB format; the string distinguishes RGB from
455
+ // NV12 and must not be read as Android's physical byte order.
456
+ nativeSource.width !== frame.width ||
457
+ nativeSource.height !== frame.height ||
458
+ nativeSource.pixelFormat !== "bgra8"
459
+ ) {
460
+ throw new Error(
461
+ "Native video buffer dimensions or BGRA layout mismatch",
462
+ );
463
+ }
464
+ // The native call can throw after submitting; retain leases until a
465
+ // checked queue drain even when it does not return normally.
466
+ copyPending = true;
467
+ if (region) {
468
+ // copyExternalImageToTexture would also convert the video's
469
+ // transfer function and primaries to sRGB (see yuvBlit.ts).
470
+ blitYuvFrame(
471
+ device,
472
+ cache.yuvImports,
473
+ key,
474
+ nativeSource,
475
+ rotation,
476
+ origin,
477
+ frame.width,
478
+ frame.height,
479
+ cache.uploadTexture!,
480
+ );
481
+ } else {
482
+ // Keep decoded orientation: drawVideoFrame applies rotation once.
483
+ device.queue.copyExternalImageToTexture(
484
+ {
485
+ source: nativeSource,
486
+ rotation: 0,
487
+ mirrored: false,
488
+ flipY: false,
489
+ },
490
+ { texture: cache.uploadTexture!, premultipliedAlpha: false },
491
+ { width: frame.width, height: frame.height },
492
+ );
493
+ }
494
+ }
495
+ // Snapshot records an independent texture copy and submits it after
496
+ // the external blit on the shared queue. The wrapper never draws.
497
+ image = cache.stage.makeImageSnapshot();
498
+ cache.stage.flush(true);
499
+ sourceDrained = true;
500
+ }
501
+ if (!image) throw new Error("Cannot create a video frame image");
502
+ // Complete the native handoff before replacing published pixels. A failed
503
+ // source close must not evict a paused image or leave an unpublished cache
504
+ // entry behind when the caller receives an import error.
505
+ if (ownsSurfaceView) {
506
+ clearSurfaceView(cache);
507
+ ownsSurfaceView = false;
508
+ }
509
+ nativeSource?.release();
510
+ nativeSource = null;
511
+ frame.dispose?.();
512
+ releaseSource = false;
513
+ clearEntry(cache, key);
514
+ cache.images.set(key, {
515
+ id: frame.id ?? -1, image, reservation,
516
+ releasedSources: new WeakSet([frame]),
517
+ });
518
+ return image;
519
+ } catch (error) {
520
+ importFailed = true;
521
+ if (copyPending && !sourceDrained) {
522
+ try {
523
+ drainNativeCopies(cache);
524
+ } catch {
525
+ cache.pendingSources.push({
526
+ nativeSource,
527
+ owned: image,
528
+ reservation,
529
+ frame,
530
+ needsDrain: true,
531
+ sourceReleased: false,
532
+ });
533
+ nativeSource = null;
534
+ image = null;
535
+ reservation = 0;
536
+ releaseSource = false;
537
+ }
538
+ }
539
+ try {
540
+ // Failed drains retain the view along with its texture and source.
541
+ if (releaseSource && ownsSurfaceView) {
542
+ clearSurfaceView(cache);
543
+ ownsSurfaceView = false;
544
+ }
545
+ image?.dispose();
546
+ image = null;
547
+ releaseVideoMemory(reservation);
548
+ } catch {
549
+ cache.pendingSources.push({
550
+ nativeSource,
551
+ owned: image,
552
+ reservation,
553
+ frame,
554
+ needsDrain: false,
555
+ sourceReleased: false,
556
+ });
557
+ nativeSource = null;
558
+ releaseSource = false;
559
+ }
560
+ throw error;
561
+ } finally {
562
+ if (releaseSource) {
563
+ try {
564
+ nativeSource?.release();
565
+ nativeSource = null;
566
+ frame.dispose?.();
567
+ } catch (error) {
568
+ cache.pendingSources.push({
569
+ nativeSource,
570
+ owned: null,
571
+ reservation: 0,
572
+ frame,
573
+ needsDrain: false,
574
+ sourceReleased: false,
575
+ });
576
+ if (!importFailed) throw error;
577
+ }
578
+ }
579
+ }
580
+ };
581
+
582
+ /** Stage all issued frames, including items a callback currently hides.
583
+ * This releases decoder leases promptly instead of waiting for JS GC. */
584
+ export const ownVideoFrames = (
585
+ frames: Record<string, VideoFrame>,
586
+ ): Record<string, VideoFrame> => {
587
+ "worklet";
588
+ const owned: Record<string, VideoFrame> = {};
589
+ const visited = new Set<VideoFrame>();
590
+ const failed = { value: false, error: undefined as unknown };
591
+ try {
592
+ // Two different pictures from one producer would invalidate the first
593
+ // owned image while importing the second. Reject before allocating, and
594
+ // permit aliases of the same stable native frame.
595
+ const producers = new Map<number, VideoFrame>();
596
+ for (const frame of Object.values(frames)) {
597
+ if ((frame.texture as FrameTexture | null)?.kind === "skia-image")
598
+ continue;
599
+ const producer = frame.producerId ?? frame.id ?? -1;
600
+ const previous = producers.get(producer);
601
+ if (
602
+ previous &&
603
+ previous !== frame &&
604
+ (frame.id === undefined ||
605
+ previous.id === undefined ||
606
+ previous.id !== frame.id)
607
+ ) {
608
+ throw new Error(
609
+ "Composition yielded different frames for one producer; stable native frame identities are required",
610
+ );
611
+ }
612
+ producers.set(producer, frame);
613
+ }
614
+ for (const [key, frame] of Object.entries(frames)) {
615
+ visited.add(frame);
616
+ const image = imageFromVideoFrame(frame);
617
+ if (image)
618
+ owned[key] = {
619
+ width: frame.width,
620
+ height: frame.height,
621
+ rotation: frame.rotation,
622
+ crop: frame.crop,
623
+ // Synthetic processor images belong to their GPU scope. Raw anonymous
624
+ // input uses the fallback cache, which still needs a releasable key.
625
+ id:
626
+ frame.id ??
627
+ ((frame.texture as FrameTexture | null)?.kind === "skia-image"
628
+ ? undefined
629
+ : -1),
630
+ producerId:
631
+ frame.producerId ??
632
+ frame.id ??
633
+ ((frame.texture as FrameTexture | null)?.kind === "skia-image"
634
+ ? undefined
635
+ : -1),
636
+ texture: { kind: "skia-image", image },
637
+ };
638
+ }
639
+ return owned;
640
+ } catch (error) {
641
+ failed.value = true;
642
+ failed.error = error;
643
+ for (const frame of Object.values(owned)) {
644
+ try {
645
+ releaseVideoFrameImage(frame);
646
+ } catch {
647
+ /* retained/accounted by cache */
648
+ }
649
+ }
650
+ throw error;
651
+ } finally {
652
+ // Idempotent native dispose covers untouched frames after an import fails.
653
+ const pending = getCache().pendingSources;
654
+ for (const frame of Object.values(frames)) {
655
+ if (visited.has(frame)) continue;
656
+ // Do not undo the failed-drain retention established by the importer.
657
+ try {
658
+ if (
659
+ !pending.some(
660
+ (source) =>
661
+ source.frame === frame ||
662
+ (frame.id !== undefined &&
663
+ source.frame.id === frame.id &&
664
+ source.frame.producerId === frame.producerId),
665
+ )
666
+ )
667
+ frame.dispose?.();
668
+ } catch (error) {
669
+ // Keep an unsuccessful native close reachable for retry, but continue
670
+ // releasing every independent untouched item and preserve import error.
671
+ pending.push({
672
+ owned: null,
673
+ reservation: 0,
674
+ frame,
675
+ needsDrain: false,
676
+ sourceReleased: false,
677
+ });
678
+ if (!failed.value) failed.error = error;
679
+ failed.value = true;
680
+ }
681
+ }
682
+ if (failed.value) throw failed.error;
683
+ }
684
+ };