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
package/src/canvas.ts CHANGED
@@ -12,11 +12,7 @@ export const getVideoCanvas = (surface: SkSurface): SkCanvas => {
12
12
  );
13
13
  }
14
14
  checked.enableCheckedSubmissions();
15
- const canvas = surface.getCanvas();
16
- if (typeof canvas.dispose !== "function") {
17
- throw new Error(
18
- "Skia canvas lifecycle patch is missing from the native binary. Apply the patch and rebuild the native app.",
19
- );
20
- }
21
- return canvas;
15
+ // Owned by the surface: its dispose() detaches the canvas, and drawing on
16
+ // it throws from then on.
17
+ return surface.getCanvas();
22
18
  };
@@ -18,13 +18,18 @@ import {
18
18
  reserveVideoMemory,
19
19
  } from "./memory";
20
20
  import type { VideoFrame } from "./types";
21
- import { blitYuvFrame, releaseYuvImports, yuvFrameOrigin } from "./yuvBlit";
21
+ import {
22
+ blitNv12Frame,
23
+ blitYuvFrame,
24
+ releaseYuvImports,
25
+ yuvFrameRegion,
26
+ } from "./yuvBlit";
22
27
  import type { YuvImport } from "./yuvBlit";
23
28
 
24
- /** A decoded frame sampled in place, without an owned snapshot: on iOS its
25
- * IOSurface, on Android its producer's RGBA texture, which the decoder
26
- * buffer was just converted into. The decoder lease stays open while the
27
- * image is in use. */
29
+ /** A decoded frame sampled in place, without an owned snapshot: a BGRA
30
+ * frame's IOSurface on iOS, or for a YUV frame (Android, iOS NV12) its
31
+ * producer's RGBA texture, which the decoder buffer was just converted into.
32
+ * The decoder lease stays open while the image is in use. */
28
33
  export type ExportFrameImport = {
29
34
  id: number;
30
35
  frame: VideoFrame;
@@ -37,7 +42,7 @@ export type ExportFrameImport = {
37
42
  aliases?: WeakSet<VideoFrame>;
38
43
  };
39
44
 
40
- /** Android: the texture a producer's decoded frames are converted into. */
45
+ /** The texture a producer's YUV frames are converted into. */
41
46
  type ProducerTexture = { texture: GPUTexture; reservation: number };
42
47
 
43
48
  /** Per export session, keyed by producer. `enabled` turns off after a first
@@ -48,7 +53,7 @@ export type ExportFrameImports = {
48
53
  producers: Set<number>;
49
54
  enabled: boolean;
50
55
  imported: boolean;
51
- /** Android: one RGBA texture per producer, rewritten by each frame. */
56
+ /** One RGBA texture per producer of YUV frames, rewritten by each frame. */
52
57
  textures: Map<number, ProducerTexture>;
53
58
  /** Android: decoder buffers imported once (see yuvBlit.ts). */
54
59
  yuv: Map<number, YuvImport[]>;
@@ -65,6 +70,8 @@ type NativeBufferTexture = {
65
70
  nativeBuffer: bigint;
66
71
  region?: { x: number; y: number; width: number; height: number };
67
72
  rotation?: number;
73
+ /** iOS: the decoder's NV12 buffer (`framePixelFormat: 'yuv'`), or BGRA. */
74
+ format?: "nv12" | "bgra8";
68
75
  };
69
76
 
70
77
  // Captured on the RN runtime. Android samples its ImageReader YUV frames
@@ -117,8 +124,8 @@ export const releaseExportFrame = (entry: ExportFrameImport): void => {
117
124
  entry.frame.dispose?.();
118
125
  };
119
126
 
120
- /** Android: releases a producer's texture and decoder buffer imports. Call
121
- * only once its last frame's GPU work has completed. */
127
+ /** Releases a producer's texture and decoder buffer imports. Call only once
128
+ * its last frame's GPU work has completed. */
122
129
  const releaseProducerTexture = (
123
130
  imports: ExportFrameImports,
124
131
  producer: number,
@@ -133,10 +140,11 @@ const releaseProducerTexture = (
133
140
  }
134
141
  };
135
142
 
136
- /** Android: converts a decoded ImageReader frame into its producer's RGBA
137
- * texture, wrapped as a Skia image. The conversion is submitted ahead of the
138
- * frame's drawing, on the same queue; the export's checked flush waits for
139
- * both before the lease or the texture is reused. */
143
+ /** Converts a decoded YUV frame (an Android ImageReader buffer, or an iOS
144
+ * NV12 one) into its producer's RGBA texture, wrapped as a Skia image. The
145
+ * conversion is submitted ahead of the frame's drawing, on the same queue;
146
+ * the export's checked flush waits for both before the lease or the texture
147
+ * is reused. */
140
148
  const importYuvExportFrame = (
141
149
  imports: ExportFrameImports,
142
150
  frame: VideoFrame,
@@ -158,12 +166,17 @@ const importYuvExportFrame = (
158
166
  try {
159
167
  entry.nativeFrame = createNativeVideoGpuFrame(source.nativeBuffer);
160
168
  const rotation = source.rotation ?? 0;
161
- const origin = yuvFrameOrigin(
162
- source.region!,
163
- rotation,
164
- entry.nativeFrame,
165
- frame,
166
- );
169
+ // iOS NV12: the whole buffer, in decoded orientation.
170
+ const nv12 = source.format === "nv12";
171
+ if (
172
+ nv12 &&
173
+ (entry.nativeFrame.width !== frame.width ||
174
+ entry.nativeFrame.height !== frame.height)
175
+ )
176
+ throw new Error("Native video buffer dimensions mismatch");
177
+ const region = nv12
178
+ ? null
179
+ : yuvFrameRegion(source.region!, rotation, entry.nativeFrame);
167
180
  const device = getVideoGpuDevice();
168
181
  target = imports.textures.get(producer);
169
182
  if (
@@ -199,17 +212,17 @@ const importYuvExportFrame = (
199
212
  }
200
213
  // Can throw after submitting: drain below before giving the lease back.
201
214
  submitted = true;
202
- blitYuvFrame(
203
- device,
204
- imports.yuv,
205
- producer,
206
- entry.nativeFrame,
207
- rotation,
208
- origin,
209
- frame.width,
210
- frame.height,
211
- target.texture,
212
- );
215
+ if (nv12) blitNv12Frame(device, entry.nativeFrame, target.texture);
216
+ else
217
+ blitYuvFrame(
218
+ device,
219
+ imports.yuv,
220
+ producer,
221
+ entry.nativeFrame,
222
+ rotation,
223
+ region!,
224
+ target.texture,
225
+ );
213
226
  entry.image = Skia.Image.MakeImageFromGPUTexture(target.texture);
214
227
  if (!entry.image) throw new Error("Cannot wrap the decoded frame");
215
228
  return entry;
@@ -233,15 +246,18 @@ const importYuvExportFrame = (
233
246
  };
234
247
 
235
248
  /** Wraps a decoded BGRA CVPixelBuffer's IOSurface as a Skia image, read-only.
236
- * The decoder already finished writing it; no producer fence is needed. On
237
- * Android, converts the decoded YUV frame instead (see importYuvExportFrame). */
249
+ * The decoder already finished writing it; no producer fence is needed. A
250
+ * YUV frame (Android, iOS NV12) is converted instead (see importYuvExportFrame). */
238
251
  export const importExportFrame = (
239
252
  frame: VideoFrame,
240
253
  imports?: ExportFrameImports,
241
254
  ): ExportFrameImport => {
242
255
  "worklet";
243
- if (convertsYuv) {
244
- if (!imports) throw new Error("Android in-place frames need their imports");
256
+ if (
257
+ convertsYuv ||
258
+ (frame.texture as NativeBufferTexture).format === "nv12"
259
+ ) {
260
+ if (!imports) throw new Error("YUV in-place frames need their imports");
245
261
  return importYuvExportFrame(imports, frame);
246
262
  }
247
263
  const entry: ExportFrameImport = {
@@ -311,8 +327,9 @@ export const releaseExportFrames = (imports: ExportFrameImports): void => {
311
327
 
312
328
  /** The export counterpart of ownVideoFrames on iOS: decoded frames are drawn
313
329
  * in place, and a producer's previous frame is released once its next one is
314
- * imported, since every exported frame ends with a checked GPU wait. Other
315
- * frames, or every frame after a first failed import, use `fallback`. */
330
+ * imported, since the export waits for the GPU to complete a frame before it
331
+ * imports the next one. Other frames, or every frame after a first failed
332
+ * import, use `fallback`. */
316
333
  export const ownExportFrames = (
317
334
  imports: ExportFrameImports,
318
335
  decoded: Record<string, VideoFrame>,
@@ -1,21 +1,33 @@
1
1
  /// <reference types="@webgpu/types" />
2
- import { Platform } from "react-native";
3
2
  import { BlendMode, Skia } from "react-native-skia";
4
3
  import type { SkCanvas, SkSurface } from "react-native-skia";
5
4
  import type {
6
5
  GPUSharedTextureMemory,
7
6
  GPUSharedTextureMemoryDescriptor,
8
7
  NativeVideoFrame,
8
+ RNCanvasContext,
9
9
  } from "react-native-webgpu";
10
10
  import { getVideoCanvas } from "./canvas";
11
- import { createNativeVideoGpuFrame, getVideoGpuDevice } from "./gpuDevice";
11
+ import {
12
+ createNativeVideoGpuFrame,
13
+ createNativeWindowGpuContext,
14
+ encoderWindowTextureUsage,
15
+ getVideoGpuDevice,
16
+ nativeVideoTextureUsage,
17
+ } from "./gpuDevice";
18
+ import {
19
+ frameByteSize,
20
+ releaseVideoMemory,
21
+ reserveVideoMemory,
22
+ } from "./memory";
12
23
  import type { VideoEncoder } from "./types";
13
24
 
14
- /** An encoder pool buffer that Skia draws into through WebGPU. */
25
+ /** A texture Skia draws an exported frame into through WebGPU: a lent
26
+ * encoder pool buffer on iOS, one of the encoder window's own textures on
27
+ * Android. */
15
28
  export type EncoderTarget = {
16
- pointer: bigint;
17
- /** Android: sync-fds ending the GPU access, handed over to the encoder. */
18
- fences: bigint[];
29
+ /** iOS: the lent CVPixelBuffer. */
30
+ pointer: bigint | null;
19
31
  frame: NativeVideoFrame | null;
20
32
  memory: GPUSharedTextureMemory | null;
21
33
  texture: GPUTexture | null;
@@ -24,9 +36,25 @@ export type EncoderTarget = {
24
36
  canvas: SkCanvas | null;
25
37
  };
26
38
 
27
- // Captured on the RN runtime. iOS lends CVPixelBuffers, imported through
28
- // their IOSurface; Android lends the AHardwareBuffer itself.
29
- const lendsPixelBuffers = Platform.OS === "ios";
39
+ /** Android: a canvas context presenting to the codec's input Surface. Skia
40
+ * draws each frame into one of two textures of our own, which a GPU copy then
41
+ * puts into the window's texture. The window's textures are the codec's
42
+ * input buffers: the copy waits on the GPU for the codec to hand one back,
43
+ * while the export goes on with the next frame, rather than the frame's
44
+ * checked wait. */
45
+ export type EncoderWindow = {
46
+ context: RNCanvasContext;
47
+ device: GPUDevice;
48
+ configured: boolean;
49
+ /** Drawn into in turn, each copied into the window after its frame. */
50
+ slots: EncoderTarget[];
51
+ next: number;
52
+ reservations: number[];
53
+ /** The window texture the probe acquired, the first frame's copy target. */
54
+ pending: GPUTexture | null;
55
+ };
56
+
57
+ const windowSlots = 2;
30
58
 
31
59
  type SharedMemoryDevice = GPUDevice & {
32
60
  importSharedTextureMemory?: (
@@ -43,38 +71,19 @@ export const supportsEncoderTargets = (encoder: VideoEncoder): boolean => {
43
71
  };
44
72
 
45
73
  // Declared before its callers: worklets capture their closure on creation.
46
- /** Ends GPU access; the lent buffer stays with the caller, to encode or
47
- * release. Call only after a checked flush(true) of its surface. With
48
- * `encoding`, Android keeps the access's sync-fds in `target.fences` for the
49
- * encoder, which owns and closes them: ending an AHB access submits a
50
- * release that flush(true) has not waited for. */
51
- export const closeEncoderTarget = (
52
- target: EncoderTarget,
53
- encoding = false,
54
- ): void => {
74
+ /** iOS: ends GPU access; the lent buffer stays with the caller, to encode or
75
+ * release. Call only after a checked flush(true) of its surface. */
76
+ export const closeEncoderTarget = (target: EncoderTarget): void => {
55
77
  "worklet";
56
- target.canvas?.dispose();
78
+ // Disposing the surface detaches its canvas.
57
79
  target.canvas = null;
58
80
  target.surface?.dispose();
59
81
  target.surface = null;
60
82
  if (target.accessing && target.memory && target.texture) {
61
83
  const ended = target.memory.endAccess(target.texture);
62
84
  target.accessing = false;
63
- for (const state of ended?.fences ?? []) {
64
- try {
65
- if (encoding && !lendsPixelBuffers) {
66
- const exported = state.fence.export();
67
- // Unreachable when negotiated: AHB interop requires the device's
68
- // only shared-fence feature to be sync-fd (see gpuDevice.ts).
69
- if (exported.type !== "sync-fd")
70
- throw new Error("GPU export needs sync-fd fences");
71
- target.fences.push(exported.handle);
72
- }
73
- } finally {
74
- // iOS: already waited for. The wrapper is released either way.
75
- state.fence.destroy();
76
- }
77
- }
85
+ // Already waited for. The wrappers are released either way.
86
+ for (const state of ended?.fences ?? []) state.fence.destroy();
78
87
  }
79
88
  target.accessing = false;
80
89
  target.texture?.destroy();
@@ -88,7 +97,7 @@ export const closeEncoderTarget = (
88
97
  target.frame = null;
89
98
  };
90
99
 
91
- /** Lends the next pool buffer and wraps it as a Skia surface. */
100
+ /** iOS: lends the next pool buffer and wraps it as a Skia surface. */
92
101
  export const acquireEncoderTarget = (
93
102
  encoder: VideoEncoder,
94
103
  width: number,
@@ -97,7 +106,6 @@ export const acquireEncoderTarget = (
97
106
  "worklet";
98
107
  const target: EncoderTarget = {
99
108
  pointer: encoder.acquireFrameBuffer!(),
100
- fences: [],
101
109
  frame: null,
102
110
  memory: null,
103
111
  texture: null,
@@ -106,33 +114,23 @@ export const acquireEncoderTarget = (
106
114
  canvas: null,
107
115
  };
108
116
  try {
109
- if (lendsPixelBuffers) {
110
- target.frame = createNativeVideoGpuFrame(target.pointer);
111
- if (
112
- target.frame.width !== width ||
113
- target.frame.height !== height ||
114
- target.frame.pixelFormat !== "bgra8"
115
- ) {
116
- throw new Error("Encoder buffer dimensions or BGRA layout mismatch");
117
- }
117
+ target.frame = createNativeVideoGpuFrame(target.pointer!);
118
+ if (
119
+ target.frame.width !== width ||
120
+ target.frame.height !== height ||
121
+ target.frame.pixelFormat !== "bgra8"
122
+ ) {
123
+ throw new Error("Encoder buffer dimensions or BGRA layout mismatch");
118
124
  }
119
125
  const device = getVideoGpuDevice() as SharedMemoryDevice;
120
126
  if (typeof device.importSharedTextureMemory !== "function") {
121
127
  throw new Error("WebGPU shared texture memory is unavailable");
122
128
  }
123
129
  target.memory = device.importSharedTextureMemory({
124
- handle: target.frame ? target.frame.handle : target.pointer,
130
+ handle: target.frame.handle,
125
131
  label: "video export frame",
126
132
  });
127
133
  target.texture = target.memory.createTexture();
128
- if (
129
- !lendsPixelBuffers &&
130
- (target.texture.width !== width ||
131
- target.texture.height !== height ||
132
- target.texture.format !== "rgba8unorm")
133
- ) {
134
- throw new Error("Encoder buffer dimensions or RGBA layout mismatch");
135
- }
136
134
  // Uninitialized: every frame starts with a full clear.
137
135
  target.memory.beginAccess(target.texture, false);
138
136
  target.accessing = true;
@@ -151,8 +149,9 @@ export const acquireEncoderTarget = (
151
149
  }
152
150
  };
153
151
 
154
- /** Clears one lent buffer on the GPU, then returns it unencoded. Returns the
155
- * failure, or null when this device can export through encoder targets. */
152
+ /** iOS: clears one lent buffer on the GPU, then returns it unencoded.
153
+ * Returns the failure, or null when this device can export through encoder
154
+ * targets. */
156
155
  export const probeEncoderTarget = (
157
156
  encoder: VideoEncoder,
158
157
  width: number,
@@ -177,3 +176,165 @@ export const probeEncoderTarget = (
177
176
  encoder.releaseFrameBuffer!();
178
177
  return failure;
179
178
  };
179
+
180
+ /** Android: the window's next texture, checked against the export size. */
181
+ const windowTexture = (
182
+ window: EncoderWindow,
183
+ width: number,
184
+ height: number,
185
+ ): GPUTexture => {
186
+ "worklet";
187
+ const texture = window.pending ?? window.context.getCurrentTexture();
188
+ window.pending = null;
189
+ if (
190
+ texture.width !== width ||
191
+ texture.height !== height ||
192
+ texture.format !== "rgba8unorm"
193
+ ) {
194
+ throw new Error("Encoder window dimensions or RGBA layout mismatch");
195
+ }
196
+ return texture;
197
+ };
198
+
199
+ /** Android: the slot the next frame draws into. Its previous copy completed:
200
+ * the checked wait the export makes before each frame's import covers the
201
+ * queue before it. */
202
+ export const acquireWindowTarget = (window: EncoderWindow): EncoderTarget => {
203
+ "worklet";
204
+ return window.slots[window.next]!;
205
+ };
206
+
207
+ /** Android: copies the slot Skia drew into to the window's next texture and
208
+ * hands it to the encoder, with its time. Call once the slot's surface was
209
+ * flushed: the copy follows the drawing on the same queue. */
210
+ export const presentWindowTarget = (
211
+ encoder: VideoEncoder,
212
+ window: EncoderWindow,
213
+ target: EncoderTarget,
214
+ time: number,
215
+ ): void => {
216
+ "worklet";
217
+ const source = target.texture!;
218
+ const destination = windowTexture(window, source.width, source.height);
219
+ const commands = window.device.createCommandEncoder({
220
+ label: "video export window copy",
221
+ });
222
+ commands.copyTextureToTexture(
223
+ { texture: source },
224
+ { texture: destination },
225
+ { width: source.width, height: source.height },
226
+ );
227
+ window.device.queue.submit([commands.finish()]);
228
+ window.next = (window.next + 1) % window.slots.length;
229
+ // The encoder sets the frame's time on the window, then presents.
230
+ encoder.encodeFrame(
231
+ { kind: "window", present: () => window.context.present() },
232
+ time,
233
+ );
234
+ };
235
+
236
+ /** Android: the swapchain gives the codec its buffers back at once, rather
237
+ * than once the context is collected. Call before disposing the encoder. */
238
+ export const closeEncoderWindow = (window: EncoderWindow): void => {
239
+ "worklet";
240
+ window.pending = null;
241
+ if (!window.configured) return;
242
+ window.context.unconfigure();
243
+ window.configured = false;
244
+ };
245
+
246
+ /** Android: frees the window's own textures. Call once their last recording
247
+ * was submitted: a copy still in flight keeps its source alive. */
248
+ export const releaseEncoderWindow = (window: EncoderWindow): void => {
249
+ "worklet";
250
+ while (window.slots.length) {
251
+ const slot = window.slots[window.slots.length - 1]!;
252
+ // Disposing the surface detaches its canvas.
253
+ slot.canvas = null;
254
+ slot.surface?.dispose();
255
+ slot.surface = null;
256
+ slot.texture?.destroy();
257
+ slot.texture = null;
258
+ window.slots.pop();
259
+ }
260
+ while (window.reservations.length) {
261
+ releaseVideoMemory(window.reservations[window.reservations.length - 1]!);
262
+ window.reservations.pop();
263
+ }
264
+ };
265
+
266
+ /** Android: opens a canvas context on the encoder's input window with its
267
+ * slots, acquires the window's first texture and draws a clear into the
268
+ * first slot: nothing reaches the encoder. Returns the window, or the
269
+ * failure, after which the window is closed and the export can fall back to
270
+ * readback. */
271
+ export const probeEncoderWindow = (
272
+ encoder: VideoEncoder,
273
+ width: number,
274
+ height: number,
275
+ ): { window: EncoderWindow | null; failure: unknown } => {
276
+ "worklet";
277
+ let window: EncoderWindow | null = null;
278
+ let failure: unknown = null;
279
+ try {
280
+ if (typeof encoder.acquireInputWindow !== "function")
281
+ throw new Error("Encoder cannot present through its input window");
282
+ const device = getVideoGpuDevice();
283
+ window = {
284
+ context: createNativeWindowGpuContext(
285
+ encoder.acquireInputWindow(),
286
+ width,
287
+ height,
288
+ ),
289
+ device,
290
+ configured: false,
291
+ slots: [],
292
+ next: 0,
293
+ reservations: [],
294
+ pending: null,
295
+ };
296
+ // Presented in FIFO order, opaque: every frame reaches the codec.
297
+ window.context.configure({
298
+ device,
299
+ format: "rgba8unorm",
300
+ usage: encoderWindowTextureUsage,
301
+ });
302
+ window.configured = true;
303
+ for (let i = 0; i < windowSlots; i++) {
304
+ window.reservations.push(
305
+ reserveVideoMemory(frameByteSize(width, height), "export window slot"),
306
+ );
307
+ const slot: EncoderTarget = {
308
+ pointer: null,
309
+ frame: null,
310
+ memory: null,
311
+ texture: device.createTexture({
312
+ label: "video export window slot",
313
+ size: { width, height },
314
+ format: "rgba8unorm",
315
+ usage: nativeVideoTextureUsage,
316
+ }),
317
+ accessing: false,
318
+ surface: null,
319
+ canvas: null,
320
+ };
321
+ window.slots.push(slot);
322
+ slot.surface = Skia.Surface.MakeFromGPUTexture(slot.texture!);
323
+ if (!slot.surface) throw new Error("Cannot wrap the export frame");
324
+ slot.canvas = getVideoCanvas(slot.surface);
325
+ }
326
+ window.pending = windowTexture(window, width, height);
327
+ const target = acquireWindowTarget(window);
328
+ target.canvas!.drawColor(Skia.Color("#00000000"), BlendMode.Clear);
329
+ target.surface!.flush(true);
330
+ } catch (error) {
331
+ failure = error ?? new Error("GPU export probe failed");
332
+ }
333
+ if (failure === null) return { window, failure };
334
+ if (window) {
335
+ // Not caught: a window still drawn by WebGPU cannot fall back to EGL.
336
+ closeEncoderWindow(window);
337
+ releaseEncoderWindow(window);
338
+ }
339
+ return { window: null, failure };
340
+ };