@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pictor contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # @pictor-sdk/video-android
2
+
3
+ Optional Android video playback and export adapter for Pictor. Requires Android API 29+.
4
+
5
+ Part of [Pictor](https://github.com/zahidhasanaunto/pictor), an MIT-licensed, composable
6
+ photo and video editor SDK for React Native. Public APIs are pre-1.0 and experimental.
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ pnpm add @pictor-sdk/video-android@0.1.0
12
+ ```
13
+
14
+ Install the package's declared peer dependencies in your app. Keep React, React Native,
15
+ Skia, Reanimated and Worklets single-instance; never install a second renderer stack.
16
+
17
+ The reference host uses Expo SDK 54, React Native 0.81, React 19.1,
18
+ Skia 2.2.12, Reanimated 4.1 and Worklets 0.5.1 (New Architecture).
19
+ Device acceptance was performed on a Pixel 7; broader peer ranges are not a claim
20
+ of device verification on every supported version/platform.
21
+
22
+ ## Android setup
23
+
24
+ Android only, API 29+. Requires a native development/release build; Expo Go is not supported.
25
+ Add "@pictor-sdk/video-android" to the Expo app config's plugins array, then run Expo prebuild
26
+ and rebuild your app. Bare React Native hosts must set minSdkVersion to at least 29.
27
+ The package includes its autolinking config, TurboModule specs, Kotlin/C++ sources and CMake build.
28
+ Media3 is pinned to 1.9.4. iOS video playback/export is not provided.
29
+
30
+ Diagnostics are disabled in ordinary release builds. Do not set the pictorDiagnostics Gradle
31
+ property in a shipped build. The host owns source permissions, durable ingestion and export files.
32
+
33
+ ## Documentation and contributing
34
+
35
+ - [Repository and example app](https://github.com/zahidhasanaunto/pictor)
36
+ - [Public API inventory](https://github.com/zahidhasanaunto/pictor/blob/main/docs/API-SURFACE.md)
37
+ - [Release and contributor guide](https://github.com/zahidhasanaunto/pictor/blob/main/docs/RELEASING.md)
38
+
39
+ The original development scope was @pictor/*; public packages use @pictor-sdk/*.
40
+ Use the public scope in all imports and app configuration.
@@ -0,0 +1,30 @@
1
+ cmake_minimum_required(VERSION 3.13)
2
+ project(pictor_video)
3
+
4
+ set(CMAKE_CXX_STANDARD 17)
5
+
6
+ add_library(pictor_video SHARED
7
+ src/main/cpp/pictor_videosurface.cpp
8
+ src/main/cpp/pictor_publish.cpp
9
+ src/main/cpp/pictor_overlaystage.cpp
10
+ )
11
+
12
+ # JSI comes from React Native's prefab. Linking it is what lets this library
13
+ # expose a jsi::HostObject, which is the supported route into a worklet runtime.
14
+ find_package(ReactAndroid REQUIRED CONFIG)
15
+ find_library(log-lib log)
16
+ find_library(android-lib android)
17
+ # The presentation surface creates a GL texture in Skia's context.
18
+ find_library(egl-lib EGL)
19
+ find_library(gles-lib GLESv2)
20
+ # CRC32 for the overlay sidecars, the same checksum `OverlayResources.kt` reads.
21
+ find_library(z-lib z)
22
+
23
+ target_link_libraries(pictor_video
24
+ ReactAndroid::jsi
25
+ ${log-lib}
26
+ ${android-lib}
27
+ ${egl-lib}
28
+ ${gles-lib}
29
+ ${z-lib}
30
+ )
@@ -0,0 +1,119 @@
1
+ buildscript {
2
+ repositories { google(); mavenCentral() }
3
+ dependencies {
4
+ classpath "com.android.tools.build:gradle:8.6.0"
5
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21"
6
+ }
7
+ }
8
+
9
+ apply plugin: "com.android.library"
10
+ apply plugin: "org.jetbrains.kotlin.android"
11
+ apply plugin: "com.facebook.react"
12
+
13
+ android {
14
+ namespace "dev.pictor.video"
15
+ compileSdk 35
16
+
17
+ defaultConfig {
18
+ // 29 is now conservative, and deliberately so.
19
+ //
20
+ // It was forced by the retired custom backend: ImageReader.newInstance with
21
+ // USAGE_GPU_SAMPLED_IMAGE (29), Image.getHardwareBuffer() (28), HardwareBuffer
22
+ // itself (26). None of that code exists any more. What the Media3 + external-OES
23
+ // path actually needs is far lower — SurfaceTexture and GL_TEXTURE_EXTERNAL_OES
24
+ // have been present since API 11 and 8, Media3 1.9.4's own floor is 23, and
25
+ // React Native 0.76 requires 24 — so 24 is the real requirement.
26
+ //
27
+ // It stays at 29 because nothing below it has been *run*. The only device here
28
+ // is API 36 and the one installed system image is incomplete, so 24 would be a
29
+ // support claim resting on reading API levels rather than on a passing test.
30
+ // Lower it when an older device or a complete emulator image can prove it.
31
+ // Installing this package raises a host's minSdk; not installing it leaves
32
+ // image-only Pictor consumers exactly where they were.
33
+ minSdk 29
34
+ externalNativeBuild {
35
+ cmake {
36
+ cppFlags "-std=c++17"
37
+ // React Native's prefab libraries are built against the shared STL, and
38
+ // linking JSI against a static one fails configuration outright.
39
+ arguments "-DANDROID_STL=c++_shared"
40
+ }
41
+ }
42
+ }
43
+
44
+ externalNativeBuild {
45
+ cmake {
46
+ path "CMakeLists.txt"
47
+ version "3.22.1"
48
+ }
49
+ }
50
+
51
+ /*
52
+ The diagnostics boundary is compile-time, not a runtime flag.
53
+
54
+ Fault injection, artificial stalls, timing holds, provenance hashing and
55
+ synthetic heartbeats exist for conformance gates. A shipped build must not
56
+ be able to reach them by any sequence of calls, so whether they exist at all
57
+ is decided here and baked into `BuildConfig` — not stored in a mutable field
58
+ that arbitrary JavaScript could set.
59
+
60
+ Off unless a build explicitly asks:
61
+
62
+ ./gradlew :app:assembleRelease -PpictorDiagnostics=true
63
+
64
+ A debug build gets it for free, because a debug build is already a
65
+ development artefact. A release build without the property has the constant
66
+ false, every mutating gate method refuses synchronously, and every site that
67
+ would act on an injected fault is dead.
68
+ */
69
+ buildTypes {
70
+ debug {
71
+ buildConfigField "boolean", "PICTOR_DIAGNOSTICS", "true"
72
+ }
73
+ release {
74
+ buildConfigField "boolean", "PICTOR_DIAGNOSTICS",
75
+ project.hasProperty("pictorDiagnostics") ? "true" : "false"
76
+ }
77
+ }
78
+
79
+ buildFeatures {
80
+ buildConfig true
81
+ // Required to consume React Native's JSI headers and library.
82
+ prefab true
83
+ }
84
+ compileOptions {
85
+ sourceCompatibility JavaVersion.VERSION_17
86
+ targetCompatibility JavaVersion.VERSION_17
87
+ }
88
+ kotlinOptions { jvmTarget = "17" }
89
+ }
90
+
91
+ repositories { google(); mavenCentral() }
92
+
93
+ /**
94
+ * One Media3 version, everywhere.
95
+ *
96
+ * 1.9.4 is the last release that builds against compileSdk 35; 1.10.0 raised
97
+ * its floor to 36. Revisit when Pictor targets an Expo SDK that compiles
98
+ * against 36 — see docs/research/media3-mapping.md.
99
+ *
100
+ * `media3-transformer` and `media3-effect` arrive here at V3.3, for the
101
+ * Transformer–Skia GL interoperability gate. They are still weight an optional
102
+ * adapter's consumers pay for; they are here because the gate needs them, not
103
+ * in anticipation of a slice that has not started.
104
+ */
105
+ ext.media3Version = "1.9.4"
106
+
107
+ dependencies {
108
+ implementation "com.facebook.react:react-android"
109
+ implementation "org.jetbrains.kotlin:kotlin-stdlib"
110
+ implementation "androidx.media3:media3-exoplayer:${project.ext.media3Version}"
111
+ implementation "androidx.media3:media3-transformer:${project.ext.media3Version}"
112
+ implementation "androidx.media3:media3-effect:${project.ext.media3Version}"
113
+ implementation "androidx.media3:media3-common:${project.ext.media3Version}"
114
+
115
+ // FrameClock's pairing rules are Kotlin and have no Android dependencies, so
116
+ // they are tested on the JVM rather than reimplemented in TS to suit the
117
+ // Node harness. Run with `pnpm test:android`.
118
+ testImplementation "junit:junit:4.13.2"
119
+ }
@@ -0,0 +1 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,372 @@
1
+ /*
2
+ Staging overlay rasters, synchronously, without a filesystem package in JS.
3
+
4
+ ## Why this exists in the adapter
5
+
6
+ The export reader that consumes these sidecars is `OverlayResources.kt`, in
7
+ this package. The writer belongs beside it: a host that had to produce the
8
+ bytes *and* choose where they live would need a filesystem dependency and a
9
+ path convention, and the two ends of one format would then be maintained in
10
+ different repositories.
11
+
12
+ ## Why JSI rather than a TurboModule method
13
+
14
+ The payload is a raster — several megabytes at export scale. Codegen carries
15
+ numbers, strings and arrays; the only way to move raw bytes through it is
16
+ base64, which costs a 4/3 expansion, an encode on the JS thread and a decode
17
+ on the native side, for data that is already contiguous memory. A JSI host
18
+ function reads the `ArrayBuffer` in place.
19
+
20
+ ## Why JS cannot name the destination
21
+
22
+ `stage` takes bytes and dimensions and returns a token. The directory is
23
+ chosen here from the app's own cache path, and the file name is a counter and
24
+ a random suffix — never anything derived from a document string. An overlay id
25
+ is author-controlled text; letting it reach a path is how `../` arrives in a
26
+ filename.
27
+
28
+ ## Publication
29
+
30
+ Bytes go to `<dir>/<name>.part`, are flushed and fsynced, and only then
31
+ renamed onto the final name. A reader therefore never opens a file that is
32
+ still being written: the sidecar either does not exist or is complete, with no
33
+ state in between for a crash to land in.
34
+ */
35
+
36
+ #include <jni.h>
37
+ #include <jsi/jsi.h>
38
+
39
+ #include <errno.h>
40
+ #include <dirent.h>
41
+ #include <fcntl.h>
42
+ #include <string.h>
43
+ #include <sys/stat.h>
44
+ #include <unistd.h>
45
+ #include <zlib.h>
46
+
47
+ #include <memory>
48
+ #include <mutex>
49
+ #include <string>
50
+ #include <unordered_map>
51
+ #include <vector>
52
+
53
+ using namespace facebook;
54
+
55
+ namespace {
56
+
57
+ /** The subdirectory this module owns. Nothing outside it is ever swept. */
58
+ constexpr const char *kDirName = "pictor-overlays";
59
+ /** The suffix a published sidecar carries. */
60
+ constexpr const char *kSuffix = ".pictorraster";
61
+ /** The suffix an in-progress write carries. */
62
+ constexpr const char *kPartSuffix = ".part";
63
+
64
+ /**
65
+ * The largest raster that will be accepted, in bytes.
66
+ *
67
+ * 256 MB is far above any plausible text overlay — a 4096×4096 RGBA raster is
68
+ * 64 MB — and far below a size that would threaten the process. The point is
69
+ * not to guess the right maximum but to have one: without it a corrupted or
70
+ * hostile descriptor turns into an allocation the device cannot satisfy, and
71
+ * the failure is an abort rather than a refusal.
72
+ */
73
+ constexpr uint64_t kMaxBytes = 256ull * 1024ull * 1024ull;
74
+
75
+ /** Bytes per pixel for the one format this stages. */
76
+ constexpr uint64_t kBytesPerPixel = 4;
77
+
78
+ struct Staged {
79
+ std::string path;
80
+ uint64_t byteLength;
81
+ uint32_t checksum;
82
+ };
83
+
84
+ class Staging {
85
+ public:
86
+ explicit Staging(std::string dir) : dir_(std::move(dir)) {
87
+ ::mkdir(dir_.c_str(), 0700);
88
+ }
89
+
90
+ /**
91
+ * Write one raster and publish it.
92
+ *
93
+ * Every failure path removes whatever it created. A half-written `.part` left
94
+ * behind would be swept later, but "later" is the next cold start, and an
95
+ * export that failed is exactly the run nobody comes back to tidy.
96
+ */
97
+ bool stage(const uint8_t *bytes, uint64_t length, uint32_t *outChecksum,
98
+ std::string *outToken, std::string *outPath, std::string *outError) {
99
+ const uint64_t id = ++counter_;
100
+ // Never derived from a document string: an overlay id is author-controlled
101
+ // text, and a path built from one is how `../` arrives in a filename.
102
+ const std::string name = "ov" + std::to_string(id) + "-" + std::to_string(::getpid()) +
103
+ "-" + std::to_string(::rand());
104
+ const std::string finalPath = dir_ + "/" + name + kSuffix;
105
+ const std::string partPath = finalPath + kPartSuffix;
106
+
107
+ int fd = ::open(partPath.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0600);
108
+ if (fd < 0) {
109
+ *outError = "could not create a staging file";
110
+ return false;
111
+ }
112
+
113
+ uint64_t written = 0;
114
+ bool ok = true;
115
+ while (written < length) {
116
+ const ssize_t n = ::write(fd, bytes + written, static_cast<size_t>(length - written));
117
+ if (n <= 0) {
118
+ if (errno == EINTR) continue;
119
+ ok = false;
120
+ break;
121
+ }
122
+ written += static_cast<uint64_t>(n);
123
+ }
124
+ // Durable before the rename: publishing a name that points at unflushed
125
+ // bytes is the same hazard as publishing a partial file.
126
+ if (ok && ::fsync(fd) != 0) ok = false;
127
+ ::close(fd);
128
+
129
+ if (!ok || written != length) {
130
+ ::unlink(partPath.c_str());
131
+ *outError = "the raster could not be written in full";
132
+ return false;
133
+ }
134
+
135
+ if (::rename(partPath.c_str(), finalPath.c_str()) != 0) {
136
+ ::unlink(partPath.c_str());
137
+ *outError = "the raster could not be published";
138
+ return false;
139
+ }
140
+
141
+ uLong crc = ::crc32(0L, Z_NULL, 0);
142
+ crc = ::crc32(crc, bytes, static_cast<uInt>(length));
143
+
144
+ const std::string token = "stg" + std::to_string(id);
145
+ {
146
+ std::lock_guard<std::mutex> lock(mutex_);
147
+ live_.emplace(token, Staged{finalPath, length, static_cast<uint32_t>(crc)});
148
+ created_ += 1;
149
+ }
150
+ *outChecksum = static_cast<uint32_t>(crc);
151
+ *outToken = token;
152
+ *outPath = finalPath;
153
+ return true;
154
+ }
155
+
156
+ /** Exact and idempotent: releasing twice is not an error and frees once. */
157
+ bool release(const std::string &token) {
158
+ std::string path;
159
+ {
160
+ std::lock_guard<std::mutex> lock(mutex_);
161
+ auto it = live_.find(token);
162
+ if (it == live_.end()) return false;
163
+ path = it->second.path;
164
+ live_.erase(it);
165
+ released_ += 1;
166
+ }
167
+ ::unlink(path.c_str());
168
+ return true;
169
+ }
170
+
171
+ int releaseAll() {
172
+ std::vector<std::string> tokens;
173
+ {
174
+ std::lock_guard<std::mutex> lock(mutex_);
175
+ tokens.reserve(live_.size());
176
+ for (const auto &entry : live_) tokens.push_back(entry.first);
177
+ }
178
+ int n = 0;
179
+ for (const auto &t : tokens) {
180
+ if (release(t)) n += 1;
181
+ }
182
+ return n;
183
+ }
184
+
185
+ /**
186
+ * Remove orphans left by a previous process.
187
+ *
188
+ * Scoped to this module's own directory and to its own suffixes, so it can
189
+ * never take a cache file belonging to anything else. Anything currently live
190
+ * in *this* process is skipped, which is what makes it safe to call at any
191
+ * time rather than only before the first export.
192
+ */
193
+ int sweep() {
194
+ std::vector<std::string> livePaths;
195
+ {
196
+ std::lock_guard<std::mutex> lock(mutex_);
197
+ for (const auto &entry : live_) livePaths.push_back(entry.second.path);
198
+ }
199
+ DIR *d = ::opendir(dir_.c_str());
200
+ if (d == nullptr) return 0;
201
+ int removed = 0;
202
+ while (dirent *e = ::readdir(d)) {
203
+ const std::string name = e->d_name;
204
+ if (name == "." || name == "..") continue;
205
+ const bool ours = name.size() > 3 && name.compare(0, 2, "ov") == 0 &&
206
+ (name.find(kSuffix) != std::string::npos);
207
+ if (!ours) continue;
208
+ const std::string path = dir_ + "/" + name;
209
+ bool isLive = false;
210
+ for (const auto &p : livePaths) {
211
+ if (p == path) { isLive = true; break; }
212
+ }
213
+ if (isLive) continue;
214
+ if (::unlink(path.c_str()) == 0) removed += 1;
215
+ }
216
+ ::closedir(d);
217
+ return removed;
218
+ }
219
+
220
+ void ledger(uint64_t *created, uint64_t *released, uint64_t *live) {
221
+ std::lock_guard<std::mutex> lock(mutex_);
222
+ *created = created_;
223
+ *released = released_;
224
+ *live = live_.size();
225
+ }
226
+
227
+ private:
228
+ std::string dir_;
229
+ std::mutex mutex_;
230
+ std::unordered_map<std::string, Staged> live_;
231
+ uint64_t counter_ = 0;
232
+ uint64_t created_ = 0;
233
+ uint64_t released_ = 0;
234
+ };
235
+
236
+ std::shared_ptr<Staging> gStaging;
237
+
238
+ /** Is `v` a whole number that fits a positive 32-bit extent? */
239
+ bool wholeExtent(double v, uint64_t *out) {
240
+ if (!(v > 0) || v != v) return false; // zero, negative, or NaN
241
+ if (v > 65536.0) return false; // far past any raster
242
+ const uint64_t n = static_cast<uint64_t>(v);
243
+ if (static_cast<double>(n) != v) return false; // not an integer
244
+ *out = n;
245
+ return true;
246
+ }
247
+
248
+ class StagingHostObject : public jsi::HostObject {
249
+ public:
250
+ jsi::Value get(jsi::Runtime &rt, const jsi::PropNameID &name) override {
251
+ const std::string prop = name.utf8(rt);
252
+
253
+ if (prop == "stage") {
254
+ return jsi::Function::createFromHostFunction(
255
+ rt, name, 3,
256
+ [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args,
257
+ size_t count) -> jsi::Value {
258
+ if (gStaging == nullptr) throw jsi::JSError(rt, "overlay staging is not installed");
259
+ if (count < 3) throw jsi::JSError(rt, "stage(bytes, width, height)");
260
+
261
+ if (!args[0].isObject() || !args[0].asObject(rt).isArrayBuffer(rt)) {
262
+ throw jsi::JSError(rt, "stage expects an ArrayBuffer");
263
+ }
264
+ uint64_t w = 0, h = 0;
265
+ if (!wholeExtent(args[1].asNumber(), &w) || !wholeExtent(args[2].asNumber(), &h)) {
266
+ throw jsi::JSError(rt, "raster dimensions must be whole positive numbers");
267
+ }
268
+ /*
269
+ Overflow-safe before multiplying, not after.
270
+
271
+ `w * h * 4` in 64 bits cannot overflow for extents capped at
272
+ 65536, but the check is written as a division rather than relying
273
+ on that cap staying where it is.
274
+ */
275
+ if (w > kMaxBytes / kBytesPerPixel / h) {
276
+ throw jsi::JSError(rt, "raster is larger than staging accepts");
277
+ }
278
+ const uint64_t expected = w * h * kBytesPerPixel;
279
+ if (expected == 0 || expected > kMaxBytes) {
280
+ throw jsi::JSError(rt, "raster is larger than staging accepts");
281
+ }
282
+
283
+ auto buffer = args[0].asObject(rt).getArrayBuffer(rt);
284
+ const uint64_t length = static_cast<uint64_t>(buffer.size(rt));
285
+ if (length != expected) {
286
+ // The descriptor and the payload disagree; writing either would
287
+ // publish a sidecar the reader must then reject.
288
+ throw jsi::JSError(rt, "raster byte length does not match its dimensions");
289
+ }
290
+
291
+ uint32_t checksum = 0;
292
+ std::string token, path, error;
293
+ if (!gStaging->stage(buffer.data(rt), length, &checksum, &token, &path, &error)) {
294
+ // No path in the message: an app-private location is not
295
+ // something a user-facing error should carry.
296
+ throw jsi::JSError(rt, error);
297
+ }
298
+
299
+ jsi::Object out(rt);
300
+ out.setProperty(rt, "token", jsi::String::createFromUtf8(rt, token));
301
+ out.setProperty(rt, "file", jsi::String::createFromUtf8(rt, path));
302
+ out.setProperty(rt, "byteLength", jsi::Value(static_cast<double>(length)));
303
+ out.setProperty(rt, "checksum", jsi::Value(static_cast<double>(checksum)));
304
+ return out;
305
+ });
306
+ }
307
+
308
+ if (prop == "release") {
309
+ return jsi::Function::createFromHostFunction(
310
+ rt, name, 1,
311
+ [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args,
312
+ size_t count) -> jsi::Value {
313
+ if (gStaging == nullptr || count < 1 || !args[0].isString()) return jsi::Value(false);
314
+ return jsi::Value(gStaging->release(args[0].asString(rt).utf8(rt)));
315
+ });
316
+ }
317
+
318
+ if (prop == "releaseAll") {
319
+ return jsi::Function::createFromHostFunction(
320
+ rt, name, 0,
321
+ [](jsi::Runtime &, const jsi::Value &, const jsi::Value *, size_t) -> jsi::Value {
322
+ if (gStaging == nullptr) return jsi::Value(0);
323
+ return jsi::Value(gStaging->releaseAll());
324
+ });
325
+ }
326
+
327
+ if (prop == "sweep") {
328
+ return jsi::Function::createFromHostFunction(
329
+ rt, name, 0,
330
+ [](jsi::Runtime &, const jsi::Value &, const jsi::Value *, size_t) -> jsi::Value {
331
+ if (gStaging == nullptr) return jsi::Value(0);
332
+ return jsi::Value(gStaging->sweep());
333
+ });
334
+ }
335
+
336
+ if (prop == "ledger") {
337
+ return jsi::Function::createFromHostFunction(
338
+ rt, name, 0,
339
+ [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *, size_t) -> jsi::Value {
340
+ uint64_t created = 0, released = 0, live = 0;
341
+ if (gStaging != nullptr) gStaging->ledger(&created, &released, &live);
342
+ jsi::Object out(rt);
343
+ out.setProperty(rt, "created", jsi::Value(static_cast<double>(created)));
344
+ out.setProperty(rt, "released", jsi::Value(static_cast<double>(released)));
345
+ out.setProperty(rt, "live", jsi::Value(static_cast<double>(live)));
346
+ return out;
347
+ });
348
+ }
349
+
350
+ return jsi::Value::undefined();
351
+ }
352
+ };
353
+
354
+ } // namespace
355
+
356
+ extern "C" JNIEXPORT jboolean JNICALL
357
+ Java_dev_pictor_video_PictorVideoModule_nativeInstallOverlayStaging(
358
+ JNIEnv *env, jobject, jlong runtimePtr, jstring cacheDir) {
359
+ if (runtimePtr == 0 || cacheDir == nullptr) return JNI_FALSE;
360
+ auto *rt = reinterpret_cast<jsi::Runtime *>(runtimePtr);
361
+
362
+ const char *chars = env->GetStringUTFChars(cacheDir, nullptr);
363
+ if (chars == nullptr) return JNI_FALSE;
364
+ const std::string dir = std::string(chars) + "/" + kDirName;
365
+ env->ReleaseStringUTFChars(cacheDir, chars);
366
+
367
+ gStaging = std::make_shared<Staging>(dir);
368
+ rt->global().setProperty(
369
+ *rt, "__pictorOverlayStaging",
370
+ jsi::Object::createFromHostObject(*rt, std::make_shared<StagingHostObject>()));
371
+ return JNI_TRUE;
372
+ }
@@ -0,0 +1,87 @@
1
+ /*
2
+ One syscall, because publication has to survive a power cut.
3
+
4
+ `open(O_CREAT|O_EXCL)` then `rename` is race-safe — the kernel decides who
5
+ claims the name — but it is two operations, and a process that dies between
6
+ them leaves an empty file that looks claimed. Nothing afterwards can tell that
7
+ from a destination someone meant to create, so it blocks every later
8
+ `overwrite: false` export to the same path until a human deletes it.
9
+
10
+ `renameat2` with `RENAME_NOREPLACE` is the primitive that does not have that
11
+ shape: it either publishes the complete staging file or reports `EEXIST`, and
12
+ there is no state in between for a crash to land in.
13
+
14
+ It is reached through `syscall(2)` rather than the libc wrapper. Bionic only
15
+ exposes `renameat2` from API 30, this module supports older devices, and
16
+ whether the *kernel* has it is a different question from whether the *headers*
17
+ do — which is exactly the question that has to be answered on the device
18
+ rather than at build time.
19
+ */
20
+
21
+ #include <jni.h>
22
+
23
+ #include <errno.h>
24
+ #include <fcntl.h>
25
+ #include <sys/syscall.h>
26
+ #include <unistd.h>
27
+
28
+ #ifndef RENAME_NOREPLACE
29
+ #define RENAME_NOREPLACE (1 << 0)
30
+ #endif
31
+
32
+ /*
33
+ The syscall numbers, for toolchains whose headers predate the call.
34
+
35
+ Stable ABI: a syscall number is part of the kernel's contract with userspace
36
+ and cannot be renumbered, so hard-coding them is safe in a way that
37
+ hard-coding almost nothing else is.
38
+ */
39
+ #ifndef __NR_renameat2
40
+ #if defined(__aarch64__)
41
+ #define __NR_renameat2 276
42
+ #elif defined(__arm__)
43
+ #define __NR_renameat2 382
44
+ #elif defined(__i386__)
45
+ #define __NR_renameat2 353
46
+ #elif defined(__x86_64__)
47
+ #define __NR_renameat2 316
48
+ #endif
49
+ #endif
50
+
51
+ /**
52
+ * Publish `from` as `to`, refusing to replace anything.
53
+ *
54
+ * Returns 0 on success, or the errno. `ENOSYS` means the kernel has no such
55
+ * call; `EINVAL` means it has the call and this filesystem will not take the
56
+ * flag. Both are answers about the destination, not about the caller, and the
57
+ * Kotlin side reads them as "this filesystem cannot do an atomic no-replace".
58
+ */
59
+ extern "C" JNIEXPORT jint JNICALL
60
+ Java_dev_pictor_video_GateDestination_nativeRenameNoReplace(
61
+ JNIEnv *env, jobject /* self */, jstring from, jstring to) {
62
+ #ifndef __NR_renameat2
63
+ (void)env;
64
+ (void)from;
65
+ (void)to;
66
+ return ENOSYS;
67
+ #else
68
+ const char *fromPath = env->GetStringUTFChars(from, nullptr);
69
+ if (fromPath == nullptr) {
70
+ return EINVAL;
71
+ }
72
+ const char *toPath = env->GetStringUTFChars(to, nullptr);
73
+ if (toPath == nullptr) {
74
+ env->ReleaseStringUTFChars(from, fromPath);
75
+ return EINVAL;
76
+ }
77
+
78
+ errno = 0;
79
+ long result =
80
+ syscall(__NR_renameat2, AT_FDCWD, fromPath, AT_FDCWD, toPath, RENAME_NOREPLACE);
81
+ int failure = (result == 0) ? 0 : errno;
82
+
83
+ env->ReleaseStringUTFChars(from, fromPath);
84
+ env->ReleaseStringUTFChars(to, toPath);
85
+ return failure;
86
+ #endif
87
+ }