@pictor-sdk/video-android 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/android/CMakeLists.txt +30 -0
  4. package/android/build.gradle +119 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
  7. package/android/src/main/cpp/pictor_publish.cpp +87 -0
  8. package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
  9. package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
  10. package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
  11. package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
  12. package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
  13. package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
  14. package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
  15. package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
  16. package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
  17. package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
  18. package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
  19. package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
  20. package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
  21. package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
  22. package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
  23. package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
  24. package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
  25. package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
  26. package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
  27. package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
  28. package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
  29. package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
  30. package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
  31. package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
  32. package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
  33. package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
  34. package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
  35. package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
  36. package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
  37. package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
  38. package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
  39. package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
  40. package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
  41. package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
  42. package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
  43. package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
  44. package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
  45. package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
  46. package/app.plugin.js +43 -0
  47. package/dist/index.d.mts +3622 -0
  48. package/dist/index.d.ts +3622 -0
  49. package/dist/index.js +3737 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/index.mjs +3625 -0
  52. package/dist/index.mjs.map +1 -0
  53. package/package.json +82 -0
  54. package/plugin/gradleProperties.d.ts +19 -0
  55. package/plugin/gradleProperties.js +64 -0
  56. package/react-native.config.js +13 -0
  57. package/src/NativePictorVideo.ts +574 -0
  58. package/src/audioProbe.ts +72 -0
  59. package/src/compositionSpike.ts +305 -0
  60. package/src/convergence.ts +112 -0
  61. package/src/encoderGate.ts +66 -0
  62. package/src/exportDestination.ts +149 -0
  63. package/src/exportJob.ts +232 -0
  64. package/src/exportProgress.ts +391 -0
  65. package/src/exportProgressPump.ts +190 -0
  66. package/src/exporter.ts +655 -0
  67. package/src/index.ts +276 -0
  68. package/src/managedSources.ts +252 -0
  69. package/src/media3.ts +412 -0
  70. package/src/mediaStore.ts +177 -0
  71. package/src/memoryGuard.ts +118 -0
  72. package/src/mixSources.ts +82 -0
  73. package/src/overlayStaging.ts +216 -0
  74. package/src/playbackLifecycle.ts +137 -0
  75. package/src/player.ts +39 -0
  76. package/src/playlistSession.ts +125 -0
  77. package/src/previewAudioDiagnostics.ts +67 -0
  78. package/src/previewAudioSession.ts +168 -0
  79. package/src/previewCadence.ts +950 -0
  80. package/src/previewMix.ts +212 -0
  81. package/src/previewTrace.ts +348 -0
  82. package/src/spikeExcursions.ts +405 -0
  83. package/src/submissionScoring.ts +680 -0
  84. package/src/surfaceOwner.ts +264 -0
  85. package/src/surfaceRecovery.ts +121 -0
  86. package/src/syncTrace.ts +189 -0
  87. package/src/thumbnailProvider.ts +163 -0
  88. package/src/tracePhases.ts +217 -0
  89. package/src/useCompositionPlayback.ts +327 -0
  90. package/src/useMedia3Playback.ts +492 -0
  91. package/src/usePreviewAudio.ts +305 -0
  92. package/src/useVideoSurface.ts +282 -0
@@ -0,0 +1,735 @@
1
+ /**
2
+ * The persistent presentation surface: one external texture, one SurfaceTexture.
3
+ *
4
+ * Everything that touches GL runs on the UI runtime, because that is where
5
+ * Skia's context is current — established by the earlier context probe, which
6
+ * found the same EGL context handle before and after a Skia GPU call on that
7
+ * thread. A `SurfaceTexture` binds to the calling thread's current context, so
8
+ * it is built from here rather than from Kotlin on some other thread.
9
+ *
10
+ * The host object holds a global ref to the Java objects and nothing else. Its
11
+ * destructor is trivial: teardown is an explicit call, in order, on this
12
+ * thread. A finalizer running on a GC thread would issue `glDeleteTextures`
13
+ * against whatever context happened to be current there, which is how this
14
+ * project has crashed before.
15
+ */
16
+ #include <EGL/egl.h>
17
+ #include <GLES2/gl2.h>
18
+ #include <GLES2/gl2ext.h>
19
+ #include <atomic>
20
+ #include <jni.h>
21
+ #include <mutex>
22
+ #include <jsi/jsi.h>
23
+
24
+ #include <ctime>
25
+ #include <memory>
26
+
27
+ namespace jsi = facebook::jsi;
28
+
29
+ namespace pictor {
30
+ namespace {
31
+
32
+ /**
33
+ * How many host objects have been constructed, ever.
34
+ *
35
+ * A façade is cheap and cloning one is harmless, but *constructing* one is a new
36
+ * backing state unless the state lives elsewhere. This counter is what
37
+ * distinguishes the two, and it is reported by the probe rather than inferred.
38
+ */
39
+ std::atomic<int> g_factoryCount{0};
40
+
41
+ /** Monotonic stamp for the last mutation, so two readers can be ordered. */
42
+ std::atomic<long> g_mutationSeq{0};
43
+ /** Thread id of that mutation, as a small stable number. */
44
+ std::atomic<long> g_mutationThread{0};
45
+
46
+ long threadTag() {
47
+ static std::atomic<long> next{1};
48
+ thread_local long tag = next.fetch_add(1);
49
+ return tag;
50
+ }
51
+
52
+ JavaVM *g_vm = nullptr;
53
+
54
+ /** The UI thread is a Java thread already; this only fetches its env. */
55
+ JNIEnv *env() {
56
+ if (g_vm == nullptr) return nullptr;
57
+ JNIEnv *e = nullptr;
58
+ if (g_vm->GetEnv(reinterpret_cast<void **>(&e), JNI_VERSION_1_6) != JNI_OK) {
59
+ return nullptr;
60
+ }
61
+ return e;
62
+ }
63
+
64
+ class SurfaceHostObject : public jsi::HostObject {
65
+ public:
66
+ ~SurfaceHostObject() override = default; // trivial, deliberately
67
+
68
+ jsi::Value get(jsi::Runtime &rt, const jsi::PropNameID &name) override {
69
+ const auto prop = name.utf8(rt);
70
+ auto self = this;
71
+
72
+ if (prop == "create") {
73
+ return jsi::Function::createFromHostFunction(
74
+ rt, name, 2,
75
+ [self](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args,
76
+ size_t count) -> jsi::Value {
77
+ const int width = count > 0 ? (int)args[0].asNumber() : 256;
78
+ const int height = count > 1 ? (int)args[1].asNumber() : 256;
79
+ jsi::Object out(rt);
80
+ out.setProperty(rt, "textureId", 0.0);
81
+ out.setProperty(rt, "ok", false);
82
+
83
+ if (eglGetCurrentContext() == EGL_NO_CONTEXT) {
84
+ out.setProperty(rt, "error",
85
+ jsi::String::createFromUtf8(
86
+ rt, "no EGL context current on this thread"));
87
+ return jsi::Value(std::move(out));
88
+ }
89
+ JNIEnv *e = env();
90
+ if (e == nullptr) {
91
+ out.setProperty(
92
+ rt, "error",
93
+ jsi::String::createFromUtf8(rt, "no JNIEnv on this thread"));
94
+ return jsi::Value(std::move(out));
95
+ }
96
+
97
+ /*
98
+ Retire whatever this slot already holds, before making more.
99
+
100
+ `create` used to overwrite `texture_`, `surfaceTexture_`,
101
+ `surface_`, `flag_` and `cls_` in place. Every field it
102
+ overwrote was the only reference to a live object: a GL external
103
+ texture, a SurfaceTexture and its buffer, a Surface, a listener
104
+ still watching the old texture, and four JNI global refs. None of
105
+ it was reachable afterwards and none of it was freed.
106
+
107
+ Measured on a Pixel 7 across eight mount cycles of the production
108
+ stack — no CompositionPlayer, no ring — graphics memory rose from
109
+ 77.3 MB to 141.4 MB in steps of exactly 8,312 KB. A constant
110
+ increment is one object per cycle, not bloat, and 8,312 KB is a
111
+ 1080x1920 buffer.
112
+
113
+ Explicit, on this thread, which has both an EGL context and an
114
+ attached JNIEnv. Not a destructor: the JSI object outlives the GL
115
+ thread and a GC-time free would run where neither is available.
116
+ */
117
+ retire(self, e, true);
118
+ self->creates_++;
119
+
120
+ while (glGetError() != GL_NO_ERROR) {
121
+ }
122
+ glGenTextures(1, &self->texture_);
123
+ glBindTexture(GL_TEXTURE_EXTERNAL_OES, self->texture_);
124
+ glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MIN_FILTER,
125
+ GL_LINEAR);
126
+ glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MAG_FILTER,
127
+ GL_LINEAR);
128
+ glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_WRAP_S,
129
+ GL_CLAMP_TO_EDGE);
130
+ glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_WRAP_T,
131
+ GL_CLAMP_TO_EDGE);
132
+ const GLenum error = glGetError();
133
+
134
+ jclass cls = e->FindClass("dev/pictor/video/VideoSurface");
135
+ jmethodID create = e->GetStaticMethodID(
136
+ cls, "create", "(III)Landroid/graphics/SurfaceTexture;");
137
+ jobject texture = e->CallStaticObjectMethod(
138
+ cls, create, (jint)self->texture_, (jint)width, (jint)height);
139
+ jmethodID surfaceOf = e->GetStaticMethodID(
140
+ cls, "surfaceOf",
141
+ "(Landroid/graphics/SurfaceTexture;)Landroid/view/Surface;");
142
+ jobject surface = e->CallStaticObjectMethod(cls, surfaceOf, texture);
143
+
144
+ self->surfaceTexture_ = e->NewGlobalRef(texture);
145
+ self->surface_ = e->NewGlobalRef(surface);
146
+ self->cls_ = (jclass)e->NewGlobalRef(cls);
147
+
148
+ // Published so a player can render into it, and watched so frames
149
+ // raise a flag instead of doing work on the producer's thread.
150
+ // Returns the generation this surface was registered under. Kept
151
+ // so teardown can prove it is retiring its own surface: a screen's
152
+ // cleanup routinely runs after the next screen's setup.
153
+ jmethodID reg = e->GetStaticMethodID(cls, "register",
154
+ "(Landroid/view/Surface;)I");
155
+ self->generation_.store(e->CallStaticIntMethod(cls, reg, surface));
156
+ g_mutationSeq.fetch_add(1);
157
+ g_mutationThread.store(threadTag());
158
+ jclass boolCls = e->FindClass("java/util/concurrent/atomic/AtomicBoolean");
159
+ jobject flag = e->NewObject(
160
+ boolCls, e->GetMethodID(boolCls, "<init>", "(Z)V"), JNI_FALSE);
161
+ self->flag_ = e->NewGlobalRef(flag);
162
+ jmethodID watch = e->GetStaticMethodID(
163
+ cls, "watch",
164
+ "(Landroid/graphics/SurfaceTexture;Ljava/util/concurrent/atomic/AtomicBoolean;)V");
165
+ e->CallStaticVoidMethod(cls, watch, texture, flag);
166
+
167
+ out.setProperty(rt, "textureId", (double)self->texture_);
168
+ out.setProperty(rt, "glError", (double)error);
169
+ out.setProperty(rt, "ok", self->surfaceTexture_ != nullptr);
170
+ // Reported so readiness can be generation-aware. A caller that only
171
+ // knows "a surface exists" cannot tell the one it prepared against
172
+ // from the one that replaced it while the picker was open.
173
+ out.setProperty(rt, "generation", (double)self->generation_.load());
174
+ out.setProperty(rt, "error", jsi::String::createFromUtf8(rt, ""));
175
+ return jsi::Value(std::move(out));
176
+ });
177
+ }
178
+
179
+ if (prop == "surfaceCounters") {
180
+ /*
181
+ Creates, retires, and the difference.
182
+
183
+ The regression this exists for: `create` used to overwrite a live
184
+ surface without retiring it, so `live` climbed by one per call and each
185
+ step was a 1080x1920 buffer nothing could reach. It is exposed rather
186
+ than inferred from graphics memory, which settles on its own schedule
187
+ and cannot say whose bytes it is holding.
188
+
189
+ `live` must never exceed one, and must return to zero after a release.
190
+ */
191
+ return jsi::Function::createFromHostFunction(
192
+ rt, name, 0,
193
+ [self](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *,
194
+ size_t) -> jsi::Value {
195
+ jsi::Object out(rt);
196
+ out.setProperty(rt, "creates", (double)self->creates_);
197
+ out.setProperty(rt, "retires", (double)self->retires_);
198
+ out.setProperty(rt, "live", (double)(self->creates_ - self->retires_));
199
+ out.setProperty(rt, "generation", (double)self->generation_.load());
200
+ return jsi::Value(std::move(out));
201
+ });
202
+ }
203
+
204
+ if (prop == "pending") {
205
+ // Whether a frame is waiting, asked of the *same* `FramePending` instance
206
+ // the listener updates. Deliberately a call across JNI on every tick
207
+ // rather than a value mirrored into JS: a mirror is a copy, a copy is
208
+ // stale between updates, and the whole failure being fixed here was a
209
+ // consumer acting on its own idea of availability rather than the
210
+ // producer's.
211
+ return jsi::Function::createFromHostFunction(
212
+ rt, name, 0,
213
+ [self](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *,
214
+ size_t) -> jsi::Value {
215
+ JNIEnv *e = env();
216
+ if (e == nullptr || self->cls_ == nullptr) return jsi::Value(false);
217
+ jmethodID has = e->GetStaticMethodID(self->cls_, "hasPendingFrame",
218
+ "(I)Z");
219
+ return jsi::Value(e->CallStaticBooleanMethod(
220
+ self->cls_, has, self->generation_.load()) == JNI_TRUE);
221
+ });
222
+ }
223
+
224
+ if (prop == "identity") {
225
+ // Who this object is, and who it is talking to.
226
+ //
227
+ // `self` is the host object the consumer captured; `controllerId` and
228
+ // `pendingId` are the producer's. A consumer holding a stale object is
229
+ // invisible in the values — a captured `undefined` surface and a live one
230
+ // both report "nothing pending" — so identity is the only thing that can
231
+ // tell them apart, and it is recorded from inside the worklet rather than
232
+ // sampled from JS, which would only prove what JS can see.
233
+ return jsi::Function::createFromHostFunction(
234
+ rt, name, 0,
235
+ [self](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *,
236
+ size_t) -> jsi::Value {
237
+ jsi::Object out(rt);
238
+ // The façade: this host object. May legitimately differ between
239
+ // runtimes — a clone is harmless.
240
+ out.setProperty(
241
+ rt, "facadeId",
242
+ (double)(reinterpret_cast<uintptr_t>(self) & 0x7fffffff));
243
+ // The backing state the façade resolves through. **Must** match
244
+ // across runtimes: if it does not, two independent states exist and
245
+ // every value read from them is separately plausible and jointly
246
+ // wrong.
247
+ out.setProperty(
248
+ rt, "backingId",
249
+ (double)(reinterpret_cast<uintptr_t>(&self->generation_) & 0x7fffffff));
250
+ out.setProperty(rt, "factoryCount",
251
+ (double)g_factoryCount.load());
252
+ out.setProperty(rt, "mutationSeq", (double)g_mutationSeq.load());
253
+ out.setProperty(rt, "mutationThread",
254
+ (double)g_mutationThread.load());
255
+ out.setProperty(rt, "readThread", (double)threadTag());
256
+ out.setProperty(
257
+ rt, "surfaceId",
258
+ (double)(reinterpret_cast<uintptr_t>(self) & 0x7fffffff));
259
+ out.setProperty(rt, "controllerId", 0.0);
260
+ out.setProperty(rt, "pendingId", 0.0);
261
+ out.setProperty(rt, "generation", (double)self->generation_.load());
262
+ JNIEnv *e = env();
263
+ if (e == nullptr || self->cls_ == nullptr) {
264
+ return jsi::Value(std::move(out));
265
+ }
266
+ jmethodID controller =
267
+ e->GetStaticMethodID(self->cls_, "getControllerId", "()I");
268
+ jmethodID pending =
269
+ e->GetStaticMethodID(self->cls_, "getPendingId", "()I");
270
+ if (controller != nullptr) {
271
+ out.setProperty(rt, "controllerId",
272
+ (double)e->CallStaticIntMethod(self->cls_, controller));
273
+ }
274
+ if (pending != nullptr) {
275
+ out.setProperty(rt, "pendingId",
276
+ (double)e->CallStaticIntMethod(self->cls_, pending));
277
+ }
278
+ e->ExceptionClear();
279
+ return jsi::Value(std::move(out));
280
+ });
281
+ }
282
+
283
+ if (prop == "pendingSnapshot") {
284
+ // One reading of three facts. Asking separately means asking at three
285
+ // different instants, and a frame arriving between two of them makes the
286
+ // consumer look like it disagrees with the producer when both answers were
287
+ // true when given.
288
+ return jsi::Function::createFromHostFunction(
289
+ rt, name, 0,
290
+ [self](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *,
291
+ size_t) -> jsi::Value {
292
+ JNIEnv *e = env();
293
+ if (e == nullptr || self->cls_ == nullptr) return jsi::Value(0.0);
294
+ jmethodID snap =
295
+ e->GetStaticMethodID(self->cls_, "pendingSnapshot", "(I)J");
296
+ if (snap == nullptr) {
297
+ e->ExceptionClear();
298
+ return jsi::Value(0.0);
299
+ }
300
+ return jsi::Value(static_cast<double>(e->CallStaticLongMethod(
301
+ self->cls_, snap, self->generation_.load())));
302
+ });
303
+ }
304
+
305
+ if (prop == "pendingDueNs") {
306
+ // The time the pending frame was asked to be presented at, so the
307
+ // consumer can honour it rather than latching the instant a buffer is
308
+ // queued. Measured, the latch ran about 34 ms ahead of it.
309
+ return jsi::Function::createFromHostFunction(
310
+ rt, name, 0,
311
+ [self](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *,
312
+ size_t) -> jsi::Value {
313
+ JNIEnv *e = env();
314
+ if (e == nullptr || self->cls_ == nullptr) return jsi::Value(0.0);
315
+ jmethodID due = e->GetStaticMethodID(self->cls_, "pendingDueNs", "()J");
316
+ if (due == nullptr) {
317
+ e->ExceptionClear();
318
+ return jsi::Value(0.0);
319
+ }
320
+ return jsi::Value(
321
+ static_cast<double>(e->CallStaticLongMethod(self->cls_, due)));
322
+ });
323
+ }
324
+
325
+ if (prop == "pendingDepth") {
326
+ // How many frames are outstanding, from the producer's own counters.
327
+ // Needed for the strengthened invariant: depth above zero for the
328
+ // consumer's generation must make the consumer's own query return true,
329
+ // and if it does not, the two are holding different objects.
330
+ return jsi::Function::createFromHostFunction(
331
+ rt, name, 0,
332
+ [self](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *,
333
+ size_t) -> jsi::Value {
334
+ JNIEnv *e = env();
335
+ if (e == nullptr || self->cls_ == nullptr) return jsi::Value(0.0);
336
+ jmethodID getter =
337
+ e->GetStaticMethodID(self->cls_, "getPendingDepth", "()J");
338
+ if (getter == nullptr) {
339
+ e->ExceptionClear();
340
+ return jsi::Value(0.0);
341
+ }
342
+ return jsi::Value(
343
+ static_cast<double>(e->CallStaticLongMethod(self->cls_, getter)));
344
+ });
345
+ }
346
+
347
+ if (prop == "pendingGeneration") {
348
+ // Which generation the counters are keeping for. Pending for 2 while the
349
+ // worklet asks about 3 is a different fault from nothing having arrived.
350
+ return jsi::Function::createFromHostFunction(
351
+ rt, name, 0,
352
+ [self](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *,
353
+ size_t) -> jsi::Value {
354
+ JNIEnv *e = env();
355
+ if (e == nullptr || self->cls_ == nullptr) return jsi::Value(0.0);
356
+ jmethodID getter = e->GetStaticMethodID(
357
+ self->cls_, "getPendingGeneration", "()I");
358
+ if (getter == nullptr) {
359
+ e->ExceptionClear();
360
+ return jsi::Value(0.0);
361
+ }
362
+ return jsi::Value(static_cast<double>(
363
+ e->CallStaticIntMethod(self->cls_, getter)));
364
+ });
365
+ }
366
+
367
+ if (prop == "generation") {
368
+ // The generation this instance registered under, or 0 before `create`.
369
+ // Read-only: nothing here decides anything, it only lets a caller say
370
+ // which surface it means.
371
+ return jsi::Function::createFromHostFunction(
372
+ rt, name, 0,
373
+ [self](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *,
374
+ size_t) -> jsi::Value {
375
+ return jsi::Value(static_cast<double>(self->generation_.load()));
376
+ });
377
+ }
378
+
379
+ if (prop == "alive") {
380
+ // Whether this instance still holds a SurfaceTexture. False before
381
+ // `create` and after `release`, which is what lets a host reuse a live
382
+ // surface instead of building a second one on every foreground.
383
+ return jsi::Function::createFromHostFunction(
384
+ rt, name, 0,
385
+ [self](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *,
386
+ size_t) -> jsi::Value {
387
+ return jsi::Value(self->surfaceTexture_ != nullptr);
388
+ });
389
+ }
390
+
391
+ if (prop == "textureId") {
392
+ return jsi::Function::createFromHostFunction(
393
+ rt, name, 0,
394
+ [self](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *,
395
+ size_t) -> jsi::Value {
396
+ return jsi::Value(static_cast<double>(self->texture_));
397
+ });
398
+ }
399
+
400
+ if (prop == "update") {
401
+ // The whole per-frame path. Returns the frame's presentation time in
402
+ // microseconds, or -1 when nothing new has arrived — the caller then
403
+ // holds what is already shown rather than rewrapping for no reason.
404
+ return jsi::Function::createFromHostFunction(
405
+ rt, name, 0,
406
+ [self](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *,
407
+ size_t) -> jsi::Value {
408
+ JNIEnv *e = env();
409
+ if (e == nullptr || self->surfaceTexture_ == nullptr) {
410
+ return jsi::Value(-1.0);
411
+ }
412
+ // Asked as a level, not read as an edge.
413
+ //
414
+ // The old path did `getAndSet(false)` on a Java AtomicBoolean: it
415
+ // *consumed* the signal to test it, so a tick that then failed to
416
+ // latch had to put it back by hand, and a consumer that was disarmed
417
+ // when the edge arrived never saw it at all. A frame that turned up
418
+ // while the app was in the system picker was therefore announced to
419
+ // nobody, and a paused player produces no second announcement — the
420
+ // preview stayed black with every producer counter healthy.
421
+ //
422
+ // `hasPendingFrame` compares two monotonic counters, so asking does
423
+ // not consume, and the answer is the same however long ago the frame
424
+ // arrived or who was listening. `VideoSurface.update` advances the
425
+ // consumed counter itself, and only on a latch that really happened.
426
+ jmethodID hasPending = e->GetStaticMethodID(self->cls_,
427
+ "hasPendingFrame", "(I)Z");
428
+ if (e->CallStaticBooleanMethod(self->cls_, hasPending,
429
+ self->generation_.load()) == JNI_FALSE) {
430
+ return jsi::Value(-1.0);
431
+ }
432
+
433
+ jmethodID update = e->GetStaticMethodID(
434
+ self->cls_, "update", "(Landroid/graphics/SurfaceTexture;I)Z");
435
+ const jboolean latched = e->CallStaticBooleanMethod(
436
+ self->cls_, update, self->surfaceTexture_, self->generation_.load());
437
+ if (e->ExceptionCheck()) {
438
+ e->ExceptionClear();
439
+ return jsi::Value(-1.0);
440
+ }
441
+ if (latched == JNI_FALSE) {
442
+ // Nothing to put back: a failed latch consumed nothing, so the
443
+ // level is still raised and the next tick retries by itself.
444
+ return jsi::Value(-1.0);
445
+ }
446
+
447
+ jmethodID transform = e->GetStaticMethodID(
448
+ self->cls_, "transform",
449
+ "(Landroid/graphics/SurfaceTexture;)[F");
450
+ auto array = (jfloatArray)e->CallStaticObjectMethod(
451
+ self->cls_, transform, self->surfaceTexture_);
452
+ jfloat values[16];
453
+ e->GetFloatArrayRegion(array, 0, 16, values);
454
+ self->sx_ = values[0];
455
+ self->sy_ = values[5];
456
+ self->tx_ = values[12];
457
+ self->ty_ = values[13];
458
+ // The whole matrix, not just the axis-aligned terms. A rotated
459
+ // source puts its rotation in [1] and [4] and leaves [0] and [5]
460
+ // at zero, so a transform reading only the diagonal cannot tell a
461
+ // rotation from an identity — and silently renders the wrong thing.
462
+ for (int i = 0; i < 16; i++) self->matrix_[i] = values[i];
463
+ e->DeleteLocalRef(array);
464
+
465
+ // One canonical sample, completed where the frame is consumed.
466
+ // Nothing here recomputes anything: the readout and the
467
+ // percentiles both read these fields, matched by sample id.
468
+ struct timespec now;
469
+ clock_gettime(CLOCK_MONOTONIC, &now);
470
+ const int64_t consumedNs =
471
+ static_cast<int64_t>(now.tv_sec) * 1000000000LL + now.tv_nsec;
472
+
473
+ /*
474
+ What the texture actually holds, read after the acquisition.
475
+
476
+ The metadata deque is coalesced to its newest entry on every
477
+ availability callback, so "the sample we kept" and "the buffer
478
+ `updateTexImage` took" are only assumed to be the same frame.
479
+ `getTimestamp` is the acquired buffer's own presentation time and
480
+ is the one thing that can check it.
481
+ */
482
+ jmethodID stamp = e->GetStaticMethodID(
483
+ self->cls_, "timestamp", "(Landroid/graphics/SurfaceTexture;)J");
484
+ const jlong texturePtsNs =
485
+ stamp == nullptr
486
+ ? 0
487
+ : e->CallStaticLongMethod(self->cls_, stamp, self->surfaceTexture_);
488
+ if (e->ExceptionCheck()) e->ExceptionClear();
489
+
490
+ jclass clock = e->FindClass("dev/pictor/video/FrameClock");
491
+ jmethodID take = e->GetStaticMethodID(clock, "take", "(JJ)[J");
492
+ auto paired = (jlongArray)e->CallStaticObjectMethod(
493
+ clock, take, (jlong)consumedNs, texturePtsNs);
494
+ if (paired == nullptr) {
495
+ self->ptsUs_ = -1;
496
+ return jsi::Value(-1.0);
497
+ }
498
+ e->GetLongArrayRegion(paired, 0, 17, self->sample_);
499
+ e->DeleteLocalRef(paired);
500
+ self->ptsUs_ = self->sample_[2];
501
+ return jsi::Value(static_cast<double>(self->ptsUs_));
502
+ });
503
+ }
504
+
505
+ // One accessor over the canonical sample. Field indices mirror
506
+ // FrameClock.Field; nothing is derived here, so a reader cannot disagree
507
+ // with the percentiles about what a frame's timing was.
508
+ if (prop == "field") {
509
+ return jsi::Function::createFromHostFunction(
510
+ rt, name, 1,
511
+ [self](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args,
512
+ size_t count) -> jsi::Value {
513
+ if (count == 0) return jsi::Value(0.0);
514
+ const int index = static_cast<int>(args[0].asNumber());
515
+ if (index < 0 || index >= 17) return jsi::Value(0.0);
516
+ return jsi::Value(static_cast<double>(self->sample_[index]));
517
+ });
518
+ }
519
+
520
+ if (prop == "matrix") {
521
+ // The full 4x4, for diagnosing orientation. Diagnostics only: the render
522
+ // path reads the individual terms and never allocates per frame.
523
+ return jsi::Function::createFromHostFunction(
524
+ rt, name, 0,
525
+ [self](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *,
526
+ size_t) -> jsi::Value {
527
+ jsi::Array out(rt, 16);
528
+ for (size_t i = 0; i < 16; i++) {
529
+ out.setValueAtIndex(rt, i, jsi::Value(static_cast<double>(self->matrix_[i])));
530
+ }
531
+ return out;
532
+ });
533
+ }
534
+
535
+ // `rx`/`ry` are the off-diagonal terms, m[1] and m[4]. A quarter turn puts
536
+ // the whole rotation there and leaves the diagonal at zero, so a renderer
537
+ // that reads only sx/sy/tx/ty cannot see it and silently draws unrotated.
538
+ if (prop == "sx" || prop == "sy" || prop == "tx" || prop == "ty" ||
539
+ prop == "rx" || prop == "ry") {
540
+ const char axis = prop[0];
541
+ const char which = prop[1];
542
+ return jsi::Function::createFromHostFunction(
543
+ rt, name, 0,
544
+ [self, axis, which](jsi::Runtime &rt, const jsi::Value &,
545
+ const jsi::Value *, size_t) -> jsi::Value {
546
+ const float value =
547
+ axis == 'r' ? (which == 'x' ? self->matrix_[1] : self->matrix_[4])
548
+ : axis == 's' ? (which == 'x' ? self->sx_ : self->sy_)
549
+ : (which == 'x' ? self->tx_ : self->ty_);
550
+ return jsi::Value(static_cast<double>(value));
551
+ });
552
+ }
553
+
554
+ if (prop == "releaseGeneration") {
555
+ // Release, but only if this instance still holds the generation named.
556
+ //
557
+ // A superseded create that succeeded has to free what it made, and by the
558
+ // time it can it may no longer be the current surface — releasing "the
559
+ // current one" would then tear down its replacement. Naming the
560
+ // generation makes that impossible: a late attempt either frees its own
561
+ // surface or does nothing.
562
+ return jsi::Function::createFromHostFunction(
563
+ rt, name, 1,
564
+ [self](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args,
565
+ size_t count) -> jsi::Value {
566
+ const int wanted = count > 0 ? (int)args[0].asNumber() : 0;
567
+ if (wanted == 0 || wanted != (int)self->generation_.load()) {
568
+ return jsi::Value(false);
569
+ }
570
+ JNIEnv *e = env();
571
+ if (e == nullptr || self->surfaceTexture_ == nullptr) {
572
+ return jsi::Value(false);
573
+ }
574
+ // The same teardown the other two paths run. Three copies of this
575
+ // is how `create` came to be missing it. The class ref stays: this
576
+ // host object is still in use after a superseded surface retires.
577
+ retire(self, e, false);
578
+ return jsi::Value(true);
579
+ });
580
+ }
581
+
582
+ if (prop == "release") {
583
+ // Order matters and is explicit: Surface, SurfaceTexture, then the GL
584
+ // texture, on the thread whose context owns it. The same teardown
585
+ // `create` runs before it allocates, so a replaced surface and a released
586
+ // one free exactly the same things.
587
+ return jsi::Function::createFromHostFunction(
588
+ rt, name, 0,
589
+ [self](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *,
590
+ size_t) -> jsi::Value {
591
+ JNIEnv *e = env();
592
+ const bool deleted = retire(self, e, true);
593
+ if (e != nullptr && self->clockCls_ != nullptr) {
594
+ e->DeleteGlobalRef(self->clockCls_);
595
+ self->clockCls_ = nullptr;
596
+ self->take_ = nullptr;
597
+ }
598
+ return jsi::Value(deleted);
599
+ });
600
+ }
601
+
602
+ return jsi::Value::undefined();
603
+ }
604
+
605
+ private:
606
+ /**
607
+ * Free everything one surface generation owns, in order.
608
+ *
609
+ * The Surface and SurfaceTexture first, then the GL texture, then the class
610
+ * ref that was needed to make the call — each on the thread that owns it,
611
+ * with an attached JNIEnv and a current EGL context. Shared by `release` and
612
+ * by `create`, so replacing a surface and releasing one free exactly the same
613
+ * set; they diverged once, and the half `create` skipped was all of it.
614
+ *
615
+ * Safe to call on an empty slot: every field is checked and cleared, so a
616
+ * second call frees nothing rather than freeing twice.
617
+ *
618
+ * Returns whether a GL texture was actually deleted.
619
+ */
620
+ static bool retire(SurfaceHostObject *self, JNIEnv *e, bool dropClass) {
621
+ if (e != nullptr && self->surfaceTexture_ != nullptr) {
622
+ jmethodID release = e->GetStaticMethodID(
623
+ self->cls_, "release",
624
+ "(Landroid/graphics/SurfaceTexture;Landroid/view/Surface;I)V");
625
+ e->CallStaticVoidMethod(self->cls_, release, self->surfaceTexture_,
626
+ self->surface_, self->generation_.load());
627
+ e->DeleteGlobalRef(self->surfaceTexture_);
628
+ e->DeleteGlobalRef(self->surface_);
629
+ self->surfaceTexture_ = nullptr;
630
+ self->surface_ = nullptr;
631
+ // Counted only when something was actually freed, so `live` is a true count
632
+ // rather than a count of calls. A no-op retire must not read as a free.
633
+ self->retires_++;
634
+ }
635
+ if (e != nullptr && self->flag_ != nullptr) {
636
+ // The listener watching the retired SurfaceTexture goes with it; leaving
637
+ // the ref alive kept a callback pointed at a texture nobody could read.
638
+ e->DeleteGlobalRef(self->flag_);
639
+ self->flag_ = nullptr;
640
+ }
641
+ bool deleted = false;
642
+ if (self->texture_ != 0 && eglGetCurrentContext() != EGL_NO_CONTEXT) {
643
+ glDeleteTextures(1, &self->texture_);
644
+ deleted = glGetError() == GL_NO_ERROR;
645
+ self->texture_ = 0;
646
+ }
647
+ /*
648
+ The class ref goes only when nothing will ask for it again.
649
+
650
+ `create` replaces it on the next line and `release` is the end of the
651
+ object, so both drop it. `releaseGeneration` retires a superseded surface
652
+ while the host object stays in use, and every other query on it guards on
653
+ a null class by returning a default — dropping it there would turn a
654
+ surface swap into a stretch of silent zeroes.
655
+ */
656
+ if (dropClass && e != nullptr && self->cls_ != nullptr) {
657
+ e->DeleteGlobalRef(self->cls_);
658
+ self->cls_ = nullptr;
659
+ }
660
+ self->generation_.store(0);
661
+ return deleted;
662
+ }
663
+
664
+ /**
665
+ * The slot's mutable target, guarded because two runtimes read it.
666
+ *
667
+ * This object is created once, at install, **before any surface exists** and
668
+ * before anything captures it. `create` fills the fields in place; retirement
669
+ * clears them, and only for its own generation. So a consumer captures this
670
+ * object once and every call resolves through whatever the slot is pointing at
671
+ * now — there is nothing replaceable to capture, which is the whole point.
672
+ *
673
+ * `generation_` is atomic because the JS thread reads it (`generation()`)
674
+ * while the UI runtime writes it (`create`, `releaseGeneration`). The JNI
675
+ * handles beside it are only ever touched on the UI runtime, where the GL
676
+ * context lives.
677
+ */
678
+ std::mutex slot_;
679
+ GLuint texture_ = 0;
680
+ /** Surfaces made, and surfaces actually freed. @see surfaceCounters */
681
+ long creates_ = 0;
682
+ long retires_ = 0;
683
+ /** Raised by the frame listener, cleared when the UI runtime consumes it. */
684
+ jobject flag_ = nullptr;
685
+ float sx_ = 1.f, sy_ = 1.f, tx_ = 0.f, ty_ = 0.f;
686
+ /** Clip time of the frame on screen, and how late it got there. */
687
+ int64_t ptsUs_ = -1;
688
+ /** The canonical sample for the frame on screen. See FrameClock.Field. */
689
+ jlong sample_[17] = {0};
690
+ jfloat matrix_[16] = {0};
691
+ /** The surface generation this instance registered under. */
692
+ std::atomic<jint> generation_{0};
693
+ jobject surfaceTexture_ = nullptr;
694
+ jobject surface_ = nullptr;
695
+ jclass cls_ = nullptr;
696
+ /** Held across frames so the per-frame path never calls `FindClass`. */
697
+ jclass clockCls_ = nullptr;
698
+ jmethodID take_ = nullptr;
699
+ };
700
+
701
+ } // namespace
702
+ } // namespace pictor
703
+
704
+ extern "C" JNIEXPORT jboolean JNICALL
705
+ Java_dev_pictor_video_PictorVideoModule_nativeInstallVideoSurface(
706
+ JNIEnv *e, jclass, jlong runtimePtr) {
707
+ auto *runtime = reinterpret_cast<jsi::Runtime *>(runtimePtr);
708
+ if (runtime == nullptr) return JNI_FALSE;
709
+ e->GetJavaVM(&pictor::g_vm);
710
+
711
+ // Idempotent, and that is the whole point.
712
+ //
713
+ // This used to construct a new `SurfaceHostObject` on every call and
714
+ // overwrite the global with it. A caller that installed once during render
715
+ // and again from an effect — which is exactly what the hook did — ended up
716
+ // with two objects: the consumer captured the first, `create` ran on the
717
+ // second, and the two reported different generations forever. Neither was
718
+ // wrong about itself. Reinstalling is not a way to obtain the slot; asking
719
+ // for it is.
720
+ auto existing =
721
+ runtime->global().getProperty(*runtime, "__pictorVideoSurface");
722
+ if (existing.isObject()) return JNI_TRUE;
723
+
724
+ pictor::g_factoryCount.fetch_add(1);
725
+ runtime->global().setProperty(
726
+ *runtime, "__pictorVideoSurface",
727
+ jsi::Object::createFromHostObject(
728
+ *runtime, std::make_shared<pictor::SurfaceHostObject>()));
729
+ // Installed once and never replaced. A consumer that captures this object at
730
+ // any point — including before a surface has ever been created — holds a
731
+ // stable slot, not a snapshot, so its calls resolve through whatever target
732
+ // the slot has *now*. Replacing this object on surface creation is what would
733
+ // put a consumer back in the position of holding something stale.
734
+ return JNI_TRUE;
735
+ }