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