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
@@ -1,49 +1,36 @@
1
1
  #include "RNSVCheckedSizes.h"
2
2
  #include "VideoEncoderHostObject.h"
3
- #include "NativeHardwareBuffer.h"
4
- #include <EGL/egl.h>
3
+ #include <android/data_space.h>
4
+ #include <android/native_window_jni.h>
5
+ #include <dlfcn.h>
5
6
  #include <fbjni/ByteBuffer.h>
6
7
  #include <cmath>
7
8
  #include <limits>
8
- #include <unistd.h>
9
9
 
10
10
  namespace RNSkiaVideo {
11
11
  using namespace facebook::jni;
12
12
 
13
13
  namespace {
14
- /** Makes the encoder's EGL context current; restores the caller's (or none). */
15
- class ScopedEncoderContext {
16
- public:
17
- explicit ScopedEncoderContext(const global_ref<VideoEncoder>& encoder)
18
- : display(eglGetCurrentDisplay()), context(eglGetCurrentContext()),
19
- draw(eglGetCurrentSurface(EGL_DRAW)), read(eglGetCurrentSurface(EGL_READ)) {
20
- encoder->makeGLContextCurrent();
21
- }
22
- ~ScopedEncoderContext() {
23
- if (context != EGL_NO_CONTEXT) {
24
- eglMakeCurrent(display, draw, read, context);
25
- } else if (auto current = eglGetCurrentDisplay(); current != EGL_NO_DISPLAY) {
26
- eglMakeCurrent(current, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
27
- }
28
- }
29
- ScopedEncoderContext(const ScopedEncoderContext&) = delete;
30
- ScopedEncoderContext& operator=(const ScopedEncoderContext&) = delete;
31
-
32
- private:
33
- EGLDisplay display;
34
- EGLContext context;
35
- EGLSurface draw;
36
- EGLSurface read;
14
+ struct JSurface : JavaClass<JSurface> {
15
+ static constexpr auto kJavaDescriptor = "Landroid/view/Surface;";
37
16
  };
38
17
 
39
- /** Sync-fds handed over by JS; each is closed exactly once. */
40
- struct OwnedFences {
41
- std::vector<int> fds;
42
- ~OwnedFences() {
43
- for (int fd : fds)
44
- if (fd >= 0) close(fd);
45
- }
46
- };
18
+ /**
19
+ * Sets the presentation time of the next buffer queued to a window, as
20
+ * eglPresentationTimeANDROID does: the codec reads it as the frame's time.
21
+ * libnativewindow exports it on Android 10 and later, though the NDK does not
22
+ * declare it (it is in the LL-NDK); null where it is missing.
23
+ */
24
+ using SetBuffersTimestamp = int32_t (*)(ANativeWindow*, int64_t);
25
+ SetBuffersTimestamp setBuffersTimestamp() {
26
+ static const auto function = []() -> SetBuffersTimestamp {
27
+ void* library = dlopen("libnativewindow.so", RTLD_NOW);
28
+ return library == nullptr ? nullptr
29
+ : reinterpret_cast<SetBuffersTimestamp>(dlsym(
30
+ library, "ANativeWindow_setBuffersTimestamp"));
31
+ }();
32
+ return function;
33
+ }
47
34
  } // namespace
48
35
 
49
36
  local_ref<VideoEncoder>
@@ -79,12 +66,6 @@ void VideoEncoder::prepare() const {
79
66
  prepareMethod(self());
80
67
  }
81
68
 
82
- void VideoEncoder::makeGLContextCurrent() const {
83
- static const auto makeGLContextCurrentMethod =
84
- getClass()->getMethod<void()>("makeGLContextCurrent");
85
- makeGLContextCurrentMethod(self());
86
- }
87
-
88
69
  void VideoEncoder::encodeFrameRgba(alias_ref<JByteBuffer> pixels, jint width,
89
70
  jint height, jint bytesPerRow, jdouble time) const {
90
71
  static const auto method =
@@ -92,9 +73,21 @@ void VideoEncoder::encodeFrameRgba(alias_ref<JByteBuffer> pixels, jint width,
92
73
  method(self(), pixels, width, height, bytesPerRow, time);
93
74
  }
94
75
 
95
- void VideoEncoder::encodeTexture(jint texture, jdouble time) const {
96
- static const auto method = getClass()->getMethod<void(jint, jdouble)>("encodeTexture");
97
- method(self(), texture, time);
76
+ local_ref<jobject> VideoEncoder::getInputSurface() const {
77
+ static const auto method =
78
+ getClass()->getMethod<JSurface::javaobject()>("getInputSurface");
79
+ auto surface = method(self());
80
+ return make_local(static_cast<jobject>(surface.get()));
81
+ }
82
+
83
+ jlong VideoEncoder::beginWindowFrame(jdouble time) const {
84
+ static const auto method = getClass()->getMethod<jlong(jdouble)>("beginWindowFrame");
85
+ return method(self(), time);
86
+ }
87
+
88
+ void VideoEncoder::endWindowFrame(jlong timeUs) const {
89
+ static const auto method = getClass()->getMethod<void(jlong)>("endWindowFrame");
90
+ method(self(), timeUs);
98
91
  }
99
92
 
100
93
  void VideoEncoder::release() const {
@@ -112,11 +105,11 @@ VideoEncoderHostObject::VideoEncoderHostObject(
112
105
  std::string& outPath, int width, int height, int frameRate, int bitRate,
113
106
  std::optional<std::string> encoderName, std::optional<std::string> codec,
114
107
  alias_ref<VideoComposition> composition, int audioSampleRate,
115
- int audioChannelCount, int audioBitRate)
116
- : width(width), height(height) {
108
+ int audioChannelCount, int audioBitRate) {
117
109
  framesExtractor = make_global(VideoEncoder::create(
118
110
  outPath, width, height, frameRate, bitRate, encoderName, codec,
119
111
  composition, audioSampleRate, audioChannelCount, audioBitRate));
112
+ hints = std::make_unique<ExportHintSession>(frameRate);
120
113
  }
121
114
 
122
115
  VideoEncoderHostObject::~VideoEncoderHostObject() {
@@ -128,8 +121,7 @@ VideoEncoderHostObject::getPropertyNames(jsi::Runtime& rt) {
128
121
  std::vector<jsi::PropNameID> result;
129
122
  result.push_back(jsi::PropNameID::forUtf8(rt, std::string("prepare")));
130
123
  result.push_back(jsi::PropNameID::forUtf8(rt, std::string("encodeFrame")));
131
- result.push_back(jsi::PropNameID::forUtf8(rt, std::string("acquireFrameBuffer")));
132
- result.push_back(jsi::PropNameID::forUtf8(rt, std::string("releaseFrameBuffer")));
124
+ result.push_back(jsi::PropNameID::forUtf8(rt, std::string("acquireInputWindow")));
133
125
  result.push_back(jsi::PropNameID::forUtf8(rt, std::string("finishWriting")));
134
126
  result.push_back(jsi::PropNameID::forUtf8(rt, std::string("dispose")));
135
127
  return result;
@@ -155,39 +147,31 @@ jsi::Value VideoEncoderHostObject::get(jsi::Runtime& runtime,
155
147
  auto frame = arguments[0].asObject(runtime);
156
148
  const auto kindValue = frame.getProperty(runtime, "kind");
157
149
  const auto kind = kindValue.isString() ? kindValue.asString(runtime).utf8(runtime) : "";
158
- if (kind == "native-buffer") {
159
- // A lent buffer Skia rendered into; its sync-fds are ours to close.
160
- OwnedFences fences;
161
- const auto fenceValues = frame.getProperty(runtime, "fences");
162
- if (fenceValues.isObject()) {
163
- auto array = fenceValues.asObject(runtime).asArray(runtime);
164
- for (size_t i = 0; i < array.size(runtime); ++i) {
165
- auto value = array.getValueAtIndex(runtime, i);
166
- if (!value.isBigInt()) throw jsi::JSError(runtime, "Encoder fences must be sync-fd BigInts");
167
- // WebGPU reports an already signaled fence as -1, as a uint32.
168
- fences.fds.push_back(static_cast<int>(
169
- static_cast<uint32_t>(value.asBigInt(runtime).getUint64(runtime))));
170
- }
171
- }
172
- const auto pointerValue = frame.getProperty(runtime, "nativeBuffer");
173
- if (!pointerValue.isBigInt() || !targets)
174
- throw jsi::JSError(runtime, "Encoder frame buffer is not the one lent");
175
- const auto* buffer = reinterpret_cast<const AHardwareBuffer*>(
176
- static_cast<uintptr_t>(pointerValue.asBigInt(runtime).getUint64(runtime)));
150
+ if (kind == "window") {
151
+ // The GPU export rendered the frame into the canvas context of the
152
+ // input window: `present` queues it, with the time set here.
153
+ if (window == nullptr)
154
+ throw jsi::JSError(runtime, "Encoder input window was not acquired");
155
+ auto present = frame.getProperty(runtime, "present");
156
+ if (!present.isObject() || !present.asObject(runtime).isFunction(runtime))
157
+ throw jsi::JSError(runtime, "A window frame needs its present function");
177
158
  const auto time = checkedMediaSeconds(arguments[1].asNumber());
178
- ScopedEncoderContext scope(framesExtractor);
179
- const auto texture = targets->beginEncode(buffer, fences.fds);
180
- try {
181
- framesExtractor->encodeTexture(static_cast<jint>(texture), time);
182
- } catch (...) {
183
- targets->finishEncode();
184
- throw;
185
- }
186
- targets->finishEncode();
159
+ const auto timeUs = framesExtractor->beginWindowFrame(time);
160
+ // The swapchain tags its buffers sRGB, full range, and the codec
161
+ // would encode and label the file so. Unknown, as EGL leaves it,
162
+ // lets the codec use its default: Rec.709, limited range. Set for
163
+ // every frame, as a recreated swapchain tags the window again.
164
+ if (ANativeWindow_setBuffersDataSpace(window, ADATASPACE_UNKNOWN) != 0)
165
+ throw jsi::JSError(runtime, "Cannot set the encoder frame dataspace");
166
+ if (setBuffersTimestamp()(window, static_cast<int64_t>(timeUs) * 1000) != 0)
167
+ throw jsi::JSError(runtime, "Cannot set the encoder frame time");
168
+ present.asObject(runtime).asFunction(runtime).call(runtime);
169
+ framesExtractor->endWindowFrame(timeUs);
170
+ hints->frameDone();
187
171
  return jsi::Value::undefined();
188
172
  }
189
173
  if (kind != "rgba") {
190
- throw jsi::JSError(runtime, "Android encoder accepts RGBA pixels or a lent hardware buffer");
174
+ throw jsi::JSError(runtime, "Android encoder accepts RGBA pixels or a window frame");
191
175
  }
192
176
  const auto checkedInteger = [&](const char* name) -> int {
193
177
  const auto value = frame.getProperty(runtime, name).asNumber();
@@ -234,7 +218,7 @@ jsi::Value VideoEncoderHostObject::get(jsi::Runtime& runtime,
234
218
  framesExtractor->encodeFrameRgba(javaPixels, width, height, stride, checkedMediaSeconds(arguments[1].asNumber()));
235
219
  return jsi::Value::undefined();
236
220
  });
237
- } else if (propName == "acquireFrameBuffer") {
221
+ } else if (propName == "acquireInputWindow") {
238
222
  return getFunction(
239
223
  runtime, propName, 0,
240
224
  [this](jsi::Runtime& runtime, const jsi::Value& thisValue,
@@ -243,22 +227,17 @@ jsi::Value VideoEncoderHostObject::get(jsi::Runtime& runtime,
243
227
  if (released.test() || !prepared || finished) {
244
228
  throw jsi::JSError(runtime, "Encoder not prepared");
245
229
  }
246
- // The buffers are imported by the same Dawn device as decoded AHBs.
247
- if (!NativeHardwareBuffer::isEnabled()) {
248
- throw jsi::JSError(runtime, "Android GPU export requires AHardwareBuffer interop");
230
+ // Without it, every frame would carry the time it was queued at.
231
+ if (setBuffersTimestamp() == nullptr) {
232
+ throw jsi::JSError(runtime, "Encoder frame times need Android 10 or later");
249
233
  }
250
- if (!targets) targets = std::make_unique<EncoderTargets>(width, height);
251
- auto* buffer = targets->lend();
252
- return jsi::BigInt::fromUint64(runtime, reinterpret_cast<uintptr_t>(buffer));
253
- });
254
- } else if (propName == "releaseFrameBuffer") {
255
- return getFunction(
256
- runtime, propName, 0,
257
- [this](jsi::Runtime& runtime, const jsi::Value& thisValue,
258
- const jsi::Value* arguments, size_t count) -> jsi::Value {
259
- std::lock_guard<std::recursive_mutex> lock(encoderMutex);
260
- if (targets) targets->giveBack();
261
- return jsi::Value::undefined();
234
+ if (window == nullptr) {
235
+ auto surface = framesExtractor->getInputSurface();
236
+ window = ANativeWindow_fromSurface(Environment::current(), surface.get());
237
+ if (window == nullptr)
238
+ throw jsi::JSError(runtime, "Cannot get the encoder input window");
239
+ }
240
+ return jsi::BigInt::fromUint64(runtime, reinterpret_cast<uintptr_t>(window));
262
241
  });
263
242
  } else if (propName == "prepare") {
264
243
  return getFunction(
@@ -300,16 +279,12 @@ jsi::Value VideoEncoderHostObject::get(jsi::Runtime& runtime,
300
279
  void VideoEncoderHostObject::release() {
301
280
  std::lock_guard<std::recursive_mutex> lock(encoderMutex);
302
281
  if (!released.test_and_set()) {
303
- if (targets && !targets->empty()) {
304
- // GL names belong to the encoder context; free them before it goes.
305
- // Otherwise the destructor still releases buffers and reservations.
306
- try {
307
- ScopedEncoderContext scope(framesExtractor);
308
- targets->clear();
309
- } catch (...) {
310
- }
282
+ // A canvas context presenting to the window holds its own reference.
283
+ if (window != nullptr) {
284
+ ANativeWindow_release(window);
285
+ window = nullptr;
311
286
  }
312
- targets.reset();
287
+ hints.reset();
313
288
  framesExtractor->release();
314
289
  framesExtractor = nullptr;
315
290
  }
@@ -1,8 +1,9 @@
1
1
  #pragma once
2
2
 
3
- #include "EncoderTargets.h"
3
+ #include "ExportHintSession.h"
4
4
  #include "RNSVHostObject.h"
5
5
  #include "VideoComposition.h"
6
+ #include <android/native_window.h>
6
7
  #include <memory>
7
8
  #include <mutex>
8
9
  #include <fbjni/ByteBuffer.h>
@@ -34,13 +35,17 @@ public:
34
35
 
35
36
  void prepare() const;
36
37
 
37
- void makeGLContextCurrent() const;
38
-
39
38
  void encodeFrameRgba(alias_ref<JByteBuffer> pixels, jint width, jint height,
40
39
  jint bytesPerRow, jdouble time) const;
41
40
 
42
- /** A GL_TEXTURE_2D of the encoder's context, e.g. an imported buffer. */
43
- void encodeTexture(jint texture, jdouble time) const;
41
+ /** The codec's input Surface (an android.view.Surface). */
42
+ local_ref<jobject> getInputSurface() const;
43
+
44
+ /** Before a frame is presented to the input Surface: its time in µs. */
45
+ jlong beginWindowFrame(jdouble time) const;
46
+
47
+ /** Once the frame of beginWindowFrame was presented. */
48
+ void endWindowFrame(jlong timeUs) const;
44
49
 
45
50
  void finishWriting() const;
46
51
 
@@ -62,10 +67,10 @@ public:
62
67
 
63
68
  private:
64
69
  global_ref<VideoEncoder> framesExtractor;
65
- // GPU export: buffers Skia renders into, sampled by the encoder's context.
66
- std::unique_ptr<EncoderTargets> targets;
67
- int width;
68
- int height;
70
+ // GPU export: the codec's input Surface, which WebGPU presents to.
71
+ ANativeWindow* window = nullptr;
72
+ // GPU export: the system's hints, per presented frame.
73
+ std::unique_ptr<ExportHintSession> hints;
69
74
  std::recursive_mutex encoderMutex;
70
75
  bool prepared = false;
71
76
  bool finished = false;
@@ -138,13 +138,11 @@ jsi::Value VideoFrame::toJSInternal(jsi::Runtime& runtime) {
138
138
  if (isHardwareBuffer()) {
139
139
  auto owner = acquireHardwareFrame(getNativeHardwareBufferHandle());
140
140
  // An ImageReader frame is the codec's YUV buffer: its picture is the crop
141
- // rectangle, turned for display when WebGPU samples it.
141
+ // rectangle, turned for display and scaled to the frame's size when
142
+ // WebGPU converts it.
142
143
  const bool decodedImage = owner->image != nullptr;
143
- const bool swapped = owner->rotation == 90 || owner->rotation == 270;
144
144
  const bool valid = decodedImage
145
- ? owner->description.format != AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM &&
146
- (swapped ? owner->cropHeight : owner->cropWidth) == width &&
147
- (swapped ? owner->cropWidth : owner->cropHeight) == height
145
+ ? owner->description.format != AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM
148
146
  : owner->description.format == AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM &&
149
147
  owner->description.width == static_cast<uint32_t>(width) &&
150
148
  owner->description.height == static_cast<uint32_t>(height);
@@ -38,12 +38,16 @@ void install(jsi::Runtime& jsiRuntime) {
38
38
  const bool hardware = NativeHardwareBuffer::isEnabled();
39
39
  info.setProperty(runtime, "decodeTransport", jsi::String::createFromAscii(runtime,
40
40
  hardware ? "ahardwarebuffer-rgba-egl" : "cpu-rgba-readback"));
41
- // Export renders into encoder-lent AHardwareBuffers, sampled by the
42
- // encoder's EGL context; CPU readback stays the fallback.
41
+ // Export renders straight into the codec's input Surface through a
42
+ // WebGPU canvas context; the JS export probes it at setup, and CPU
43
+ // readback stays the fallback. Offered with AHardwareBuffer interop,
44
+ // which also samples decoded frames in place: turning interop off
45
+ // selects the readback export, the reference of the pixel checks.
43
46
  info.setProperty(runtime, "encodeTransport", jsi::String::createFromAscii(runtime,
44
- hardware ? "ahardwarebuffer-egl-sample" : "cpu-rgba-upload"));
47
+ hardware ? "webgpu-native-window" : "cpu-rgba-upload"));
45
48
  info.setProperty(runtime, "exportFallback", jsi::String::createFromAscii(runtime, "cpu-rgba-upload"));
46
49
  info.setProperty(runtime, "gpuDirectExport", hardware);
50
+ info.setProperty(runtime, "zeroCopyEncode", hardware);
47
51
  info.setProperty(runtime, "decodeCpuCopiesBeforeSkia", hardware ? 0 : 1);
48
52
  info.setProperty(runtime, "cpuReadbackDecode", !hardware);
49
53
  info.setProperty(runtime, "ownedFrameTransport", jsi::String::createFromAscii(runtime,
@@ -51,7 +55,6 @@ void install(jsi::Runtime& jsiRuntime) {
51
55
  info.setProperty(runtime, "skiaImportTransport", jsi::String::createFromAscii(runtime,
52
56
  hardware ? "native-buffer-webgpu-blit-snapshot" : "cpu-rgba-scoped-copy-webgpu-upload-snapshot"));
53
57
  info.setProperty(runtime, "zeroCopyDecode", false);
54
- info.setProperty(runtime, "zeroCopyEncode", false);
55
58
  return info;
56
59
  }));
57
60
  auto createVideoPlayer = jsi::Function::createFromHostFunction(
@@ -126,8 +126,8 @@ public class GLFrameExtractor implements SurfaceTexture.OnFrameAvailableListener
126
126
  /**
127
127
  * The Surface the codec renders into. With {@code nativeImages}, on API 29+
128
128
  * with hardware buffers, it is an ImageReader's: frames then keep the codec's
129
- * YUV buffer, cropped and turned by {@code rotation} when WebGPU samples it,
130
- * so they must be wanted at the encoded size.
129
+ * YUV buffer, cropped, turned by {@code rotation} and scaled to the frame's
130
+ * size when WebGPU converts it.
131
131
  */
132
132
  @android.annotation.TargetApi(Build.VERSION_CODES.Q)
133
133
  public Surface surfaceFor(int videoWidth, int videoHeight, int rotation, boolean nativeImages) {
@@ -163,13 +163,7 @@ public class GLFrameExtractor implements SurfaceTexture.OnFrameAvailableListener
163
163
  try {
164
164
  if (Build.VERSION.SDK_INT >= 33) awaitProducer(image);
165
165
  Rect crop = image.getCropRect();
166
- boolean swapped = imageRotation == 90 || imageRotation == 270;
167
- int pictureWidth = swapped ? crop.height() : crop.width();
168
- int pictureHeight = swapped ? crop.width() : crop.height();
169
- if (pictureWidth != width || pictureHeight != height) {
170
- throw new IllegalStateException("Decoded image " + pictureWidth + "x" + pictureHeight
171
- + " does not match the requested " + width + "x" + height);
172
- }
166
+ if (crop.isEmpty()) throw new IllegalStateException("Decoded image has an empty crop");
173
167
  HardwareBuffer buffer = image.getHardwareBuffer();
174
168
  if (buffer == null) throw new IllegalStateException("Decoded image has no hardware buffer");
175
169
  long lease;
@@ -0,0 +1,104 @@
1
+ package com.azzapp.rnskv;
2
+
3
+ import android.app.Activity;
4
+ import android.os.Build;
5
+ import android.os.SystemClock;
6
+ import android.util.Log;
7
+ import android.view.Display;
8
+ import android.view.View;
9
+ import android.view.ViewGroup;
10
+
11
+ import com.facebook.react.bridge.ReactApplicationContext;
12
+
13
+ import java.util.HashSet;
14
+ import java.util.Set;
15
+
16
+ /**
17
+ * Asks the display for its highest refresh rate while a preview plays, through
18
+ * the React Native Skia canvases of the activity (Android 15+). Main thread.
19
+ *
20
+ * Android picks the refresh rate from what the app presents. A preview that
21
+ * presents at 30 fps for a while (30 fps frames drawn as they come) brings a
22
+ * 60 Hz display down to 30 Hz, and it stays there: the next clip, at 60 or
23
+ * 24 fps, can only present at 30 fps and never shows the display it needs
24
+ * more. On a Pixel 8a, one 60 fps frame in two was never shown, and 24 fps
25
+ * came out irregular on a fifth of its frames. A vote tells the display.
26
+ *
27
+ * Only views that draw cast their vote: one on the window's root view is never
28
+ * counted. The player does not know which canvas shows it, so all of the Skia
29
+ * TextureView canvases vote; those that do not draw cast nothing. SurfaceView
30
+ * canvases vote for themselves, and the vote asks what theirs asks: the highest
31
+ * rate a seamless switch reaches, 120 Hz where the user allows it, so that
32
+ * what drawFrame animates over the video is not held to 60 Hz. Not the clips'
33
+ * rate: a 24 fps vote brought the display to 30 Hz, where 24 fps judders.
34
+ */
35
+ final class PreviewFrameRateVote {
36
+ private static final String TAG = "PreviewFrameRateVote";
37
+
38
+ private static final String SKIA_TEXTURE_VIEW = "com.reactnative.skia.SkiaTextureView";
39
+
40
+ // Canvases mounted while playing get the vote at the next refresh.
41
+ private static final long REFRESH_MILLIS = 1000;
42
+
43
+ private static final Set<PreviewFrameRateVote> voting = new HashSet<>();
44
+
45
+ private static long lastAppliedMillis = 0;
46
+
47
+ /** Whether the player is playing, at each vsync it collects frames on. */
48
+ void update(boolean playing) {
49
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
50
+ return;
51
+ }
52
+ if (playing ? voting.add(this) : voting.remove(this)) {
53
+ apply();
54
+ } else if (playing && SystemClock.uptimeMillis() - lastAppliedMillis >= REFRESH_MILLIS) {
55
+ apply();
56
+ }
57
+ }
58
+
59
+ /** The player is released. */
60
+ void withdraw() {
61
+ update(false);
62
+ }
63
+
64
+ private static void apply() {
65
+ lastAppliedMillis = SystemClock.uptimeMillis();
66
+ ReactApplicationContext context = ReactNativeSkiaVideoModule.currentReactApplicationContext();
67
+ Activity activity = context != null ? context.getCurrentActivity() : null;
68
+ if (activity == null || activity.getWindow() == null) {
69
+ return;
70
+ }
71
+ float rate = voting.isEmpty() ? View.REQUESTED_FRAME_RATE_CATEGORY_DEFAULT
72
+ : maxRefreshRate(activity.getWindow().getDecorView().getDisplay());
73
+ try {
74
+ vote(activity.getWindow().getDecorView(), rate);
75
+ } catch (RuntimeException e) {
76
+ Log.w(TAG, "Could not vote the refresh rate, leaving it to the display", e);
77
+ }
78
+ }
79
+
80
+ // The highest rate a seamless switch from the current mode reaches.
81
+ private static float maxRefreshRate(Display display) {
82
+ if (display == null) {
83
+ return View.REQUESTED_FRAME_RATE_CATEGORY_HIGH;
84
+ }
85
+ Display.Mode mode = display.getMode();
86
+ float rate = mode.getRefreshRate();
87
+ for (float alternative : mode.getAlternativeRefreshRates()) {
88
+ rate = Math.max(rate, alternative);
89
+ }
90
+ return rate;
91
+ }
92
+
93
+ private static void vote(View view, float rate) {
94
+ if (SKIA_TEXTURE_VIEW.equals(view.getClass().getName())) {
95
+ view.setRequestedFrameRate(rate);
96
+ }
97
+ if (view instanceof ViewGroup) {
98
+ ViewGroup group = (ViewGroup) view;
99
+ for (int i = 0; i < group.getChildCount(); i++) {
100
+ vote(group.getChildAt(i), rate);
101
+ }
102
+ }
103
+ }
104
+ }
@@ -14,6 +14,10 @@ public class VideoComposition {
14
14
  private boolean lazyDecoders = false;
15
15
  private boolean hardwareBufferEnabled = false;
16
16
  public boolean isHardwareBufferEnabled() { return hardwareBufferEnabled; }
17
+ // framePixelFormat: 'rgba': every frame through the GL path, none through
18
+ // ImageReader's YUV buffers. Set from the C++ side.
19
+ private boolean rgbaFrames = false;
20
+ public boolean isRgbaFrames() { return rgbaFrames; }
17
21
 
18
22
  public VideoComposition(
19
23
  double duration,
@@ -676,7 +676,7 @@ public class VideoCompositionDecoder {
676
676
  try {
677
677
  decoder.prepare();
678
678
  decoder.setSurface(slot.extractor.surfaceFor(decoder.getVideoWidth(),
679
- decoder.getVideoHeight(), decoder.getRotation(), wantsNativeImages(item, decoder)));
679
+ decoder.getVideoHeight(), decoder.getRotation(), wantsNativeImages(decoder)));
680
680
  } catch (IOException | RuntimeException e) {
681
681
  decoder.release();
682
682
  throw e;
@@ -685,15 +685,14 @@ public class VideoCompositionDecoder {
685
685
  }
686
686
 
687
687
  /**
688
- * Hardware frames at the encoded size from an 8-bit SDR track skip the GL
689
- * render pass: the codec writes into ImageReader buffers WebGPU samples.
690
- * A capped or explicit size keeps the GL path, which scales while drawing.
688
+ * Hardware frames from an 8-bit SDR track skip the GL render pass: the
689
+ * codec writes into ImageReader buffers, which WebGPU converts at the
690
+ * frame's size, a capped or explicit one included. `framePixelFormat:
691
+ * 'rgba'` keeps the GL path.
691
692
  */
692
- private boolean wantsNativeImages(VideoComposition.Item item, VideoCompositionItemDecoder decoder) {
693
- if (!composition.isHardwareBufferEnabled() || !decoder.isEightBitSdr()) return false;
694
- int[] size = FrameSize.of(item.getWidth(), item.getHeight(), item.getMaxLongSide(),
695
- decoder.getVideoWidth(), decoder.getVideoHeight(), 0);
696
- return size[0] == decoder.getVideoWidth() && size[1] == decoder.getVideoHeight();
693
+ private boolean wantsNativeImages(VideoCompositionItemDecoder decoder) {
694
+ return !composition.isRgbaFrames() && composition.isHardwareBufferEnabled()
695
+ && decoder.isEightBitSdr();
697
696
  }
698
697
 
699
698
  private void reportOpenError(Exception e) {
@@ -58,6 +58,8 @@ public class VideoCompositionFramesExtractor {
58
58
  private volatile boolean isPlaying = false;
59
59
 
60
60
  private volatile long startTime = 0;
61
+ // The position playback last started or was sought to while playing.
62
+ private volatile long playStartPosition = 0;
61
63
  private volatile long pausePosition = 0;
62
64
  private boolean isEOS = false;
63
65
  private boolean completeDispatched = false;
@@ -72,6 +74,7 @@ public class VideoCompositionFramesExtractor {
72
74
  public void doFrame(long frameTimeNanos) {
73
75
  if (releasing) {
74
76
  unwatchDisplay();
77
+ frameRateVote.withdraw();
75
78
  return;
76
79
  }
77
80
  recordedVsyncNanos = frameTimeNanos;
@@ -80,8 +83,12 @@ public class VideoCompositionFramesExtractor {
80
83
  };
81
84
  // The display the preview is on; null until the first collection, or when unknown.
82
85
  private DisplayVsyncPeriod vsyncPeriod;
86
+ // The display's highest refresh rate while playing.
87
+ private final PreviewFrameRateVote frameRateVote = new PreviewFrameRateVote();
83
88
  // When that thread last released frames; the playback loop stands back meanwhile.
84
89
  private volatile long lastVsyncReleaseNanos = 0;
90
+ // The vsync the frames collected last are shown at; 0 until the first.
91
+ private volatile long collectedVsyncNanos = 0;
85
92
  // The release itself runs on the playback thread: the decoder's locks are held
86
93
  // there for whole codec flushes on a seek, and the UI thread must never
87
94
  // wait on them (measured: 65 ms frames when it did).
@@ -187,11 +194,13 @@ public class VideoCompositionFramesExtractor {
187
194
  watchDisplay();
188
195
  return;
189
196
  }
197
+ frameRateVote.update(isPlaying);
190
198
  long period = vsyncPeriod != null ? vsyncPeriod.getPeriodNanos() : 0;
191
199
  if (period == 0 || recordedVsyncNanos == 0) return;
192
200
  // This frame's vsync, whether the recorder has run in this frame yet or not.
193
201
  long since = Math.max(0, System.nanoTime() - recordedVsyncNanos);
194
202
  long vsyncNanos = recordedVsyncNanos + (since / period) * period;
203
+ collectedVsyncNanos = vsyncNanos;
195
204
  if (vsyncNanos == lastVsyncNanos) return;
196
205
  lastVsyncNanos = vsyncNanos;
197
206
  long vsyncPeriodNanos = period;
@@ -243,6 +252,27 @@ public class VideoCompositionFramesExtractor {
243
252
  return isPlaying ? microTime() - startTime : pausePosition;
244
253
  }
245
254
 
255
+ /**
256
+ * The position the frames collected in this vsync were released for: the
257
+ * clock at the vsync rather than at the call, a few milliseconds later.
258
+ * drawFrame gets it as its time, so that the time it draws at and the frames
259
+ * it draws agree: read at the call, the time ran up to a frame ahead of the
260
+ * frames, at 60 fps on most vsyncs. Without vsync release (no display, or
261
+ * a stalled collector), the clock at the call.
262
+ *
263
+ * @return the position in microseconds
264
+ */
265
+ public long getPresentedPosition() {
266
+ long vsyncNanos = collectedVsyncNanos;
267
+ if (!isPlaying || vsyncNanos == 0
268
+ || System.nanoTime() - lastVsyncReleaseNanos > VSYNC_RELEASE_TIMEOUT_NANOS) {
269
+ return getCurrentPosition();
270
+ }
271
+ // Never before the play or seek that started this run: the vsync can
272
+ // precede it by up to a period.
273
+ return Math.max(vsyncNanos / 1000 - startTime, playStartPosition);
274
+ }
275
+
246
276
  /**
247
277
  * @return the version of the frames returned by {@link #decodeCompositionFrames},
248
278
  * see {@link VideoCompositionDecoder#getFramesVersion()}
@@ -314,6 +344,7 @@ public class VideoCompositionFramesExtractor {
314
344
  pausePosition = 0;
315
345
  seekInternal(0);
316
346
  }
347
+ playStartPosition = pausePosition;
317
348
  startTime = microTime() - pausePosition;
318
349
  isPlaying = true;
319
350
  pausePosition = 0;
@@ -381,6 +412,7 @@ public class VideoCompositionFramesExtractor {
381
412
  audioPlayer.seekTo(position);
382
413
  }
383
414
  if (isPlaying) {
415
+ playStartPosition = position;
384
416
  startTime = microTime() - position;
385
417
  } else {
386
418
  pausePosition = position;