react-native-facefusion 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 (122) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +259 -0
  3. package/android/build.gradle +129 -0
  4. package/android/src/main/AndroidManifest.xml +66 -0
  5. package/android/src/main/cpp/CMakeLists.txt +73 -0
  6. package/android/src/main/java/com/facefusion/BitmapDecode.kt +112 -0
  7. package/android/src/main/java/com/facefusion/ContentGate.kt +86 -0
  8. package/android/src/main/java/com/facefusion/DeviceProbe.kt +158 -0
  9. package/android/src/main/java/com/facefusion/FaceCrop.kt +57 -0
  10. package/android/src/main/java/com/facefusion/FacefusionModule.kt +396 -0
  11. package/android/src/main/java/com/facefusion/FacefusionPackage.kt +37 -0
  12. package/android/src/main/java/com/facefusion/FacefusionPreviewView.kt +30 -0
  13. package/android/src/main/java/com/facefusion/FacefusionPreviewViewManager.kt +25 -0
  14. package/android/src/main/java/com/facefusion/GallerySave.kt +82 -0
  15. package/android/src/main/java/com/facefusion/ModelDownload.kt +351 -0
  16. package/android/src/main/java/com/facefusion/ModelPaths.kt +120 -0
  17. package/android/src/main/java/com/facefusion/PhotoSwap.kt +125 -0
  18. package/android/src/main/java/com/facefusion/PipeGuard.kt +74 -0
  19. package/android/src/main/java/com/facefusion/PreviewSurfaceHolder.kt +104 -0
  20. package/android/src/main/java/com/facefusion/SourceFaces.kt +125 -0
  21. package/android/src/main/java/com/facefusion/SwapConfig.kt +31 -0
  22. package/android/src/main/java/com/facefusion/TargetFaces.kt +88 -0
  23. package/android/src/main/java/com/facefusion/VideoSwap.kt +501 -0
  24. package/android/src/main/java/com/facefusion/VideoSwapService.kt +85 -0
  25. package/android/src/main/java/com/facefusion/mobile/NativePipe.kt +192 -0
  26. package/lib/module/FacefusionPreview.js +6 -0
  27. package/lib/module/FacefusionPreview.js.map +1 -0
  28. package/lib/module/FacefusionPreview.native.js +15 -0
  29. package/lib/module/FacefusionPreview.native.js.map +1 -0
  30. package/lib/module/NativeFacefusion.js +53 -0
  31. package/lib/module/NativeFacefusion.js.map +1 -0
  32. package/lib/module/detectSourceFaces.js +6 -0
  33. package/lib/module/detectSourceFaces.js.map +1 -0
  34. package/lib/module/detectSourceFaces.native.js +15 -0
  35. package/lib/module/detectSourceFaces.native.js.map +1 -0
  36. package/lib/module/detectTargetFaces.js +6 -0
  37. package/lib/module/detectTargetFaces.js.map +1 -0
  38. package/lib/module/detectTargetFaces.native.js +13 -0
  39. package/lib/module/detectTargetFaces.native.js.map +1 -0
  40. package/lib/module/index.js +11 -0
  41. package/lib/module/index.js.map +1 -0
  42. package/lib/module/models.js +16 -0
  43. package/lib/module/models.js.map +1 -0
  44. package/lib/module/models.native.js +43 -0
  45. package/lib/module/models.native.js.map +1 -0
  46. package/lib/module/package.json +1 -0
  47. package/lib/module/probeDevice.js +6 -0
  48. package/lib/module/probeDevice.js.map +1 -0
  49. package/lib/module/probeDevice.native.js +14 -0
  50. package/lib/module/probeDevice.native.js.map +1 -0
  51. package/lib/module/saveToGallery.js +6 -0
  52. package/lib/module/saveToGallery.js.map +1 -0
  53. package/lib/module/saveToGallery.native.js +15 -0
  54. package/lib/module/saveToGallery.native.js.map +1 -0
  55. package/lib/module/swapPhoto.js +6 -0
  56. package/lib/module/swapPhoto.js.map +1 -0
  57. package/lib/module/swapPhoto.native.js +16 -0
  58. package/lib/module/swapPhoto.native.js.map +1 -0
  59. package/lib/module/swapVideo.js +12 -0
  60. package/lib/module/swapVideo.js.map +1 -0
  61. package/lib/module/swapVideo.native.js +29 -0
  62. package/lib/module/swapVideo.native.js.map +1 -0
  63. package/lib/typescript/package.json +1 -0
  64. package/lib/typescript/src/FacefusionPreview.d.ts +4 -0
  65. package/lib/typescript/src/FacefusionPreview.d.ts.map +1 -0
  66. package/lib/typescript/src/FacefusionPreview.native.d.ts +142 -0
  67. package/lib/typescript/src/FacefusionPreview.native.d.ts.map +1 -0
  68. package/lib/typescript/src/NativeFacefusion.d.ts +291 -0
  69. package/lib/typescript/src/NativeFacefusion.d.ts.map +1 -0
  70. package/lib/typescript/src/detectSourceFaces.d.ts +3 -0
  71. package/lib/typescript/src/detectSourceFaces.d.ts.map +1 -0
  72. package/lib/typescript/src/detectSourceFaces.native.d.ts +11 -0
  73. package/lib/typescript/src/detectSourceFaces.native.d.ts.map +1 -0
  74. package/lib/typescript/src/detectTargetFaces.d.ts +3 -0
  75. package/lib/typescript/src/detectTargetFaces.d.ts.map +1 -0
  76. package/lib/typescript/src/detectTargetFaces.native.d.ts +9 -0
  77. package/lib/typescript/src/detectTargetFaces.native.d.ts.map +1 -0
  78. package/lib/typescript/src/index.d.ts +11 -0
  79. package/lib/typescript/src/index.d.ts.map +1 -0
  80. package/lib/typescript/src/models.d.ts +7 -0
  81. package/lib/typescript/src/models.d.ts.map +1 -0
  82. package/lib/typescript/src/models.native.d.ts +31 -0
  83. package/lib/typescript/src/models.native.d.ts.map +1 -0
  84. package/lib/typescript/src/probeDevice.d.ts +3 -0
  85. package/lib/typescript/src/probeDevice.d.ts.map +1 -0
  86. package/lib/typescript/src/probeDevice.native.d.ts +10 -0
  87. package/lib/typescript/src/probeDevice.native.d.ts.map +1 -0
  88. package/lib/typescript/src/saveToGallery.d.ts +2 -0
  89. package/lib/typescript/src/saveToGallery.d.ts.map +1 -0
  90. package/lib/typescript/src/saveToGallery.native.d.ts +9 -0
  91. package/lib/typescript/src/saveToGallery.native.d.ts.map +1 -0
  92. package/lib/typescript/src/swapPhoto.d.ts +3 -0
  93. package/lib/typescript/src/swapPhoto.d.ts.map +1 -0
  94. package/lib/typescript/src/swapPhoto.native.d.ts +12 -0
  95. package/lib/typescript/src/swapPhoto.native.d.ts.map +1 -0
  96. package/lib/typescript/src/swapVideo.d.ts +6 -0
  97. package/lib/typescript/src/swapVideo.d.ts.map +1 -0
  98. package/lib/typescript/src/swapVideo.native.d.ts +20 -0
  99. package/lib/typescript/src/swapVideo.native.d.ts.map +1 -0
  100. package/package.json +132 -0
  101. package/patches/ffjni-analyse-faces.patch +63 -0
  102. package/scripts/check-tarball.sh +68 -0
  103. package/scripts/fetch-upstream.sh +91 -0
  104. package/src/FacefusionPreview.native.tsx +18 -0
  105. package/src/FacefusionPreview.tsx +9 -0
  106. package/src/NativeFacefusion.ts +324 -0
  107. package/src/detectSourceFaces.native.tsx +17 -0
  108. package/src/detectSourceFaces.tsx +10 -0
  109. package/src/detectTargetFaces.native.tsx +15 -0
  110. package/src/detectTargetFaces.tsx +10 -0
  111. package/src/index.tsx +25 -0
  112. package/src/models.native.tsx +45 -0
  113. package/src/models.tsx +23 -0
  114. package/src/probeDevice.native.tsx +13 -0
  115. package/src/probeDevice.tsx +7 -0
  116. package/src/saveToGallery.native.tsx +16 -0
  117. package/src/saveToGallery.tsx +9 -0
  118. package/src/swapPhoto.native.tsx +20 -0
  119. package/src/swapPhoto.tsx +12 -0
  120. package/src/swapVideo.native.tsx +40 -0
  121. package/src/swapVideo.tsx +31 -0
  122. package/third_party/facefusion-mobile/NOTICE +49 -0
@@ -0,0 +1,396 @@
1
+ package com.facefusion
2
+
3
+ import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.Promise
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.bridge.ReadableMap
7
+ import com.facebook.react.bridge.WritableMap
8
+ import com.facefusion.mobile.NativePipe
9
+ import java.io.File
10
+ import java.util.concurrent.Callable
11
+ import java.util.concurrent.ExecutorService
12
+ import java.util.concurrent.Executors
13
+ import java.util.concurrent.atomic.AtomicBoolean
14
+
15
+ class FacefusionModule(reactContext: ReactApplicationContext) :
16
+ NativeFacefusionSpec(reactContext) {
17
+
18
+ // One thread, not a pool. The native pipeline is a single C++ global (`g_pipe`) and is
19
+ // not reentrant, so serialising every native call through one thread is the behaviour
20
+ // we want anyway -- and it keeps the QNN backend's dlopen off the UI thread.
21
+ private val worker = named("facefusion-native")
22
+
23
+ // The downloader gets its OWN thread, and this is not an optimisation. A model download
24
+ // is minutes long; if it shared `worker`, a probeDevice() call made while it ran would
25
+ // queue behind it and look like a hang. It is safe to separate precisely because
26
+ // downloading touches no native state at all -- it is HTTP and disk. The one native
27
+ // answer it needs (the tier chain) is fetched back on `worker`, via [onWorker].
28
+ private val downloader = named("facefusion-download")
29
+
30
+ // A second concurrent download would fight the first over the same `.part` files. The
31
+ // check is here rather than in ModelDownload so the rejection is immediate and typed.
32
+ private val downloading = AtomicBoolean(false)
33
+
34
+ // A THIRD thread, for the same reason `downloader` is separate from `worker`: a video
35
+ // swap is minutes long, and if it shared `worker` a probeDevice()/getModelStatus() call
36
+ // made mid-swap would silently queue behind it. It is not safe to fold into `downloader`
37
+ // either -- unlike a download, a video job DOES touch the native pipeline throughout, via
38
+ // PipeGuard, which is the actual cross-job mutual exclusion. This executor only keeps
39
+ // unrelated lightweight calls responsive while it runs.
40
+ private val videoWorker = named("facefusion-video")
41
+
42
+ // Checked synchronously in swapVideo() before dispatch. Without it, a second call would
43
+ // silently queue behind the first on `videoWorker` (a single-thread executor) and only
44
+ // get rejected by PipeGuard.Busy once it started running, minutes later -- this fails
45
+ // fast instead, the same shape as `downloading` above.
46
+ private val videoBusy = AtomicBoolean(false)
47
+
48
+ // Its own thread for the same reason `downloader` is separate from `worker`: a gallery
49
+ // save touches no native state at all, just MediaStore + a file copy, so it must not queue
50
+ // behind a slow swap or a model download that happen to be running on their own threads.
51
+ private val gallery = named("facefusion-gallery")
52
+
53
+ override fun probeDevice(promise: Promise) {
54
+ worker.execute {
55
+ try {
56
+ promise.resolve(toMap(DeviceProbe.probe(reactApplicationContext)))
57
+ } catch (e: Throwable) {
58
+ // Includes UnsatisfiedLinkError: a JNI symbol that did not bind is an Error, not
59
+ // an Exception, so `catch (e: Exception)` would let it kill the worker thread
60
+ // and leave the promise pending forever.
61
+ promise.reject("E_PROBE", e.message ?: e.toString(), e)
62
+ }
63
+ }
64
+ }
65
+
66
+ override fun getModelStatus(promise: Promise) {
67
+ // On `worker`: resolving the tier reads the cached chain, and filling that cache is a
68
+ // native call the first time.
69
+ worker.execute {
70
+ try {
71
+ promise.resolve(modelStatus())
72
+ } catch (e: Throwable) {
73
+ promise.reject("E_MODEL_STATUS", e.message ?: e.toString(), e)
74
+ }
75
+ }
76
+ }
77
+
78
+ override fun downloadModels(promise: Promise) {
79
+ if (!downloading.compareAndSet(false, true)) {
80
+ promise.reject("E_BUSY", "A model download is already running")
81
+ return
82
+ }
83
+ downloader.execute {
84
+ try {
85
+ val chain = onWorker { DeviceProbe.tierChain(reactApplicationContext) }
86
+ ModelDownload.run(ModelPaths.dir(reactApplicationContext), chain) { progress ->
87
+ emitOnModelDownloadProgress(toMap(progress))
88
+ }
89
+ // Resolve with the status rather than the tier: the caller's next question is
90
+ // always "can it run now", and answering it here saves a round trip.
91
+ promise.resolve(onWorker { modelStatus() })
92
+ } catch (e: DownloadCancelled) {
93
+ promise.reject("E_CANCELLED", e.message ?: "Cancelled", e)
94
+ } catch (e: Throwable) {
95
+ promise.reject("E_DOWNLOAD", e.message ?: e.toString(), e)
96
+ } finally {
97
+ downloading.set(false)
98
+ }
99
+ }
100
+ }
101
+
102
+ override fun swapPhoto(
103
+ sourcePath: String,
104
+ targetPath: String,
105
+ outputPath: String,
106
+ options: ReadableMap?,
107
+ promise: Promise,
108
+ ) {
109
+ worker.execute {
110
+ try {
111
+ val result = PhotoSwap.run(
112
+ reactApplicationContext, sourcePath, targetPath, outputPath, swapConfig(options),
113
+ sourceFaceBox(options), targetFaceBox(options),
114
+ )
115
+ promise.resolve(toMap(result))
116
+ } catch (e: PipeGuard.Busy) {
117
+ promise.reject("E_BUSY", e.message, e)
118
+ } catch (e: PhotoSwap.ModelsMissing) {
119
+ promise.reject("E_MODELS", e.message, e)
120
+ } catch (e: ContentGate.Refused) {
121
+ promise.reject("E_CONTENT", e.message, e)
122
+ } catch (e: Throwable) {
123
+ // Includes UnsatisfiedLinkError -- see the same note on probeDevice() above.
124
+ promise.reject("E_SWAP", e.message ?: e.toString(), e)
125
+ }
126
+ }
127
+ }
128
+
129
+ override fun detectSourceFaces(sourcePath: String, options: ReadableMap?, promise: Promise) {
130
+ // On `worker`, same as swapPhoto -- a single-image detect pass is fast enough that it
131
+ // doesn't need its own thread the way the minutes-long video job does.
132
+ worker.execute {
133
+ try {
134
+ val faces = SourceFaces.detect(reactApplicationContext, sourcePath, swapConfig(options))
135
+ promise.resolve(facesArray(faces))
136
+ } catch (e: PipeGuard.Busy) {
137
+ promise.reject("E_BUSY", e.message, e)
138
+ } catch (e: SourceFaces.ModelsMissing) {
139
+ promise.reject("E_MODELS", e.message, e)
140
+ } catch (e: Throwable) {
141
+ promise.reject("E_DETECT", e.message ?: e.toString(), e)
142
+ }
143
+ }
144
+ }
145
+
146
+ override fun detectTargetFaces(targetPath: String, options: ReadableMap?, promise: Promise) {
147
+ worker.execute {
148
+ try {
149
+ val faces = TargetFaces.detect(reactApplicationContext, targetPath, swapConfig(options))
150
+ promise.resolve(facesArray(faces))
151
+ } catch (e: PipeGuard.Busy) {
152
+ promise.reject("E_BUSY", e.message, e)
153
+ } catch (e: TargetFaces.ModelsMissing) {
154
+ promise.reject("E_MODELS", e.message, e)
155
+ } catch (e: Throwable) {
156
+ promise.reject("E_DETECT", e.message ?: e.toString(), e)
157
+ }
158
+ }
159
+ }
160
+
161
+ override fun swapVideo(
162
+ sourcePath: String,
163
+ targetPath: String,
164
+ outputPath: String,
165
+ options: ReadableMap?,
166
+ promise: Promise,
167
+ ) {
168
+ if (!videoBusy.compareAndSet(false, true)) {
169
+ promise.reject("E_BUSY", "A video swap is already running")
170
+ return
171
+ }
172
+ videoWorker.execute {
173
+ VideoSwapService.start(reactApplicationContext)
174
+ try {
175
+ val result = VideoSwap.run(
176
+ reactApplicationContext, sourcePath, targetPath, outputPath, swapConfig(options),
177
+ sourceFaceBox(options), targetFaceBox(options), targetFps(options),
178
+ ) { progress ->
179
+ VideoSwapService.updateProgress(
180
+ reactApplicationContext, "Swapping video… ${progress.frameIndex} frames"
181
+ )
182
+ emitOnVideoSwapProgress(toMap(progress))
183
+ }
184
+ promise.resolve(toMap(result))
185
+ } catch (e: PipeGuard.Busy) {
186
+ promise.reject("E_BUSY", e.message, e)
187
+ } catch (e: VideoSwap.ModelsMissing) {
188
+ promise.reject("E_MODELS", e.message, e)
189
+ } catch (e: VideoSwap.Cancelled) {
190
+ promise.reject("E_CANCELLED", e.message ?: "Cancelled", e)
191
+ } catch (e: ContentGate.Refused) {
192
+ promise.reject("E_CONTENT", e.message, e)
193
+ } catch (e: Throwable) {
194
+ // Includes UnsatisfiedLinkError -- see the same note on probeDevice() above.
195
+ promise.reject("E_SWAP", e.message ?: e.toString(), e)
196
+ } finally {
197
+ videoBusy.set(false)
198
+ VideoSwapService.stop(reactApplicationContext)
199
+ }
200
+ }
201
+ }
202
+
203
+ override fun saveToGallery(
204
+ path: String,
205
+ mimeType: String,
206
+ displayName: String?,
207
+ promise: Promise,
208
+ ) {
209
+ gallery.execute {
210
+ try {
211
+ val name = displayName ?: File(path).name
212
+ val uri = GallerySave.run(reactApplicationContext, path, mimeType, name)
213
+ promise.resolve(uri)
214
+ } catch (e: GallerySave.UnsupportedMimeType) {
215
+ promise.reject("E_MIME", e.message, e)
216
+ } catch (e: Throwable) {
217
+ promise.reject("E_SAVE", e.message ?: e.toString(), e)
218
+ }
219
+ }
220
+ }
221
+
222
+ override fun cancelVideoSwap() {
223
+ // Deliberately not a promise -- see cancelModelDownload() below for why.
224
+ VideoSwap.cancel()
225
+ }
226
+
227
+ override fun cancelModelDownload() {
228
+ // Deliberately not a promise. Cancelling is a flag the download loop reads; the answer
229
+ // the caller wants -- that it stopped -- arrives as the E_CANCELLED rejection of
230
+ // downloadModels(), not from here.
231
+ ModelDownload.cancel()
232
+ }
233
+
234
+ override fun invalidate() {
235
+ ModelDownload.cancel()
236
+ VideoSwap.cancel()
237
+ NativePipe.release()
238
+ worker.shutdown()
239
+ downloader.shutdown()
240
+ videoWorker.shutdown()
241
+ gallery.shutdown()
242
+ super.invalidate()
243
+ }
244
+
245
+ /** Runs [block] on the native worker and waits. Safe: `worker` never waits on `downloader`. */
246
+ private fun <T> onWorker(block: () -> T): T = worker.submit(Callable(block)).get()
247
+
248
+ private fun modelStatus(): WritableMap {
249
+ val context = reactApplicationContext
250
+ val tier = ModelPaths.tier(context)
251
+ val missing = ModelPaths.missing(context, tier)
252
+
253
+ return Arguments.createMap().apply {
254
+ putString("tier", tier)
255
+ putArray("tierChain", stringsOf(DeviceProbe.tierChain(context)))
256
+ putString("dir", ModelPaths.dir(context).absolutePath)
257
+ putBoolean("ready", missing.isEmpty())
258
+ putArray("missing", stringsOf(missing))
259
+ putBoolean("hasEnhancer", ModelPaths.hasEnhancer(context, tier))
260
+ putBoolean("metered", ModelDownload.isMetered(context))
261
+ }
262
+ }
263
+
264
+ /** `options` from JS, defaulted field by field against [SwapConfig]'s own defaults. */
265
+ private fun swapConfig(options: ReadableMap?): SwapConfig {
266
+ val defaults = SwapConfig()
267
+ if (options == null) return defaults
268
+ return SwapConfig(
269
+ swapperWeight = optDouble(options, "swapperWeight")?.toFloat() ?: defaults.swapperWeight,
270
+ maskBlur = optDouble(options, "maskBlur")?.toFloat() ?: defaults.maskBlur,
271
+ maskPadding = if (options.hasKey("maskPadding") && !options.isNull("maskPadding")) {
272
+ val arr = options.getArray("maskPadding")
273
+ (0 until (arr?.size() ?: 0)).map { arr!!.getInt(it) }
274
+ } else defaults.maskPadding,
275
+ detectorScore = optDouble(options, "detectorScore")?.toFloat() ?: defaults.detectorScore,
276
+ landmarkerScore = optDouble(options, "landmarkerScore")?.toFloat() ?: defaults.landmarkerScore,
277
+ pixelBoost = if (options.hasKey("pixelBoost") && !options.isNull("pixelBoost")) {
278
+ options.getInt("pixelBoost")
279
+ } else defaults.pixelBoost,
280
+ largestFaceOnly = if (options.hasKey("largestFaceOnly") && !options.isNull("largestFaceOnly")) {
281
+ options.getBoolean("largestFaceOnly")
282
+ } else defaults.largestFaceOnly,
283
+ faceEnhance = if (options.hasKey("faceEnhance") && !options.isNull("faceEnhance")) {
284
+ options.getBoolean("faceEnhance")
285
+ } else defaults.faceEnhance,
286
+ faceEnhancerBlend = optDouble(options, "faceEnhancerBlend")?.toFloat() ?: defaults.faceEnhancerBlend,
287
+ )
288
+ }
289
+
290
+ private fun optDouble(options: ReadableMap, key: String): Double? =
291
+ if (options.hasKey(key) && !options.isNull(key)) options.getDouble(key) else null
292
+
293
+ /** `sourceFaceBox` from `options` -- not part of [swapConfig]/[SwapConfig] because it
294
+ * never reaches `initEx`; it only decides what [PhotoSwap]/[VideoSwap] crop before
295
+ * calling the unchanged `setSource`. See [SourceFaces]. */
296
+ private fun sourceFaceBox(options: ReadableMap?): FloatArray? {
297
+ if (options == null || !options.hasKey("sourceFaceBox") || options.isNull("sourceFaceBox")) {
298
+ return null
299
+ }
300
+ val arr = options.getArray("sourceFaceBox") ?: return null
301
+ require(arr.size() == 4) { "sourceFaceBox must be [left, top, right, bottom]" }
302
+ return FloatArray(4) { i -> arr.getDouble(i).toFloat() }
303
+ }
304
+
305
+ /** `targetFaceBox` from `options` -- mirrors [sourceFaceBox] above; see [FaceCrop]. */
306
+ private fun targetFaceBox(options: ReadableMap?): FloatArray? {
307
+ if (options == null || !options.hasKey("targetFaceBox") || options.isNull("targetFaceBox")) {
308
+ return null
309
+ }
310
+ val arr = options.getArray("targetFaceBox") ?: return null
311
+ require(arr.size() == 4) { "targetFaceBox must be [left, top, right, bottom]" }
312
+ return FloatArray(4) { i -> arr.getDouble(i).toFloat() }
313
+ }
314
+
315
+ /** `targetFps` from `options` -- not part of [swapConfig]/[SwapConfig] because it never
316
+ * reaches `initEx`; it only decides which decoded frames [VideoSwap] bothers swapping. */
317
+ private fun targetFps(options: ReadableMap?): Int? {
318
+ if (options == null || !options.hasKey("targetFps") || options.isNull("targetFps")) {
319
+ return null
320
+ }
321
+ return options.getDouble("targetFps").toInt()
322
+ }
323
+
324
+ private fun facesArray(faces: List<DetectedFace>) = Arguments.createArray().apply {
325
+ faces.forEach { f ->
326
+ pushMap(
327
+ Arguments.createMap().apply {
328
+ putDouble("left", f.left.toDouble())
329
+ putDouble("top", f.top.toDouble())
330
+ putDouble("right", f.right.toDouble())
331
+ putDouble("bottom", f.bottom.toDouble())
332
+ putDouble("score", f.score.toDouble())
333
+ // The box's coordinate space. Not derivable by the caller: the analysed
334
+ // bitmap is capped for photos and uncapped for video frames -- see DetectedFace.
335
+ putInt("imageWidth", f.imageWidth)
336
+ putInt("imageHeight", f.imageHeight)
337
+ }
338
+ )
339
+ }
340
+ }
341
+
342
+ private fun toMap(result: PhotoSwapResult): WritableMap = Arguments.createMap().apply {
343
+ putString("outputPath", result.outputPath)
344
+ putInt("faceCount", result.faceCount)
345
+ putString("tier", result.tier)
346
+ }
347
+
348
+ private fun toMap(result: VideoSwapResult): WritableMap = Arguments.createMap().apply {
349
+ putString("outputPath", result.outputPath)
350
+ putInt("frameCount", result.frameCount)
351
+ putInt("faceFrameCount", result.faceFrameCount)
352
+ putString("tier", result.tier)
353
+ putDouble("fps", result.fps)
354
+ putBoolean("hasAudio", result.hasAudio)
355
+ }
356
+
357
+ private fun toMap(progress: VideoSwapProgress): WritableMap = Arguments.createMap().apply {
358
+ putInt("frameIndex", progress.frameIndex)
359
+ putInt("estimatedFrameCount", progress.estimatedFrameCount)
360
+ putDouble("fps", progress.fps)
361
+ }
362
+
363
+ private fun toMap(progress: DownloadProgress): WritableMap = Arguments.createMap().apply {
364
+ putString("tier", progress.tier)
365
+ putInt("fileIndex", progress.fileIndex)
366
+ putInt("fileCount", progress.fileCount)
367
+ putString("name", progress.name)
368
+ // Double, not Int: these run to ~317 million and Arguments has no Long. A double holds
369
+ // every integer up to 2^53 exactly, so the byte counts are precise.
370
+ putDouble("doneBytes", progress.doneBytes.toDouble())
371
+ putDouble("totalBytes", progress.totalBytes.toDouble())
372
+ }
373
+
374
+ private fun toMap(probe: DeviceProbe): WritableMap = Arguments.createMap().apply {
375
+ putBoolean("ok", probe.ok)
376
+ putString("tier", probe.tier)
377
+ putArray("tierChain", stringsOf(probe.tierChain))
378
+ putInt("arch", probe.arch)
379
+ putInt("vtcmMb", probe.vtcmMb)
380
+ putInt("socModel", probe.socModel)
381
+ putBoolean("signedPd", probe.signedPd)
382
+ putBoolean("dlbc", probe.dlbc)
383
+ putString("error", probe.error)
384
+ }
385
+
386
+ private fun stringsOf(values: List<String>) = Arguments.createArray().apply {
387
+ values.forEach { pushString(it) }
388
+ }
389
+
390
+ companion object {
391
+ const val NAME = NativeFacefusionSpec.NAME
392
+
393
+ private fun named(name: String): ExecutorService =
394
+ Executors.newSingleThreadExecutor { r -> Thread(r, name).apply { isDaemon = true } }
395
+ }
396
+ }
@@ -0,0 +1,37 @@
1
+ package com.facefusion
2
+
3
+ import com.facebook.react.BaseReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.module.model.ReactModuleInfo
7
+ import com.facebook.react.module.model.ReactModuleInfoProvider
8
+ import com.facebook.react.uimanager.ViewManager
9
+ import java.util.HashMap
10
+
11
+ class FacefusionPackage : BaseReactPackage() {
12
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
13
+ return if (name == FacefusionModule.NAME) {
14
+ FacefusionModule(reactContext)
15
+ } else {
16
+ null
17
+ }
18
+ }
19
+
20
+ // The live-preview surface -- see FacefusionPreviewViewManager's class doc for why this is
21
+ // a plain ViewManager rather than a codegen'd Fabric component.
22
+ override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
23
+ listOf(FacefusionPreviewViewManager())
24
+
25
+ override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
26
+ mapOf(
27
+ FacefusionModule.NAME to ReactModuleInfo(
28
+ name = FacefusionModule.NAME,
29
+ className = FacefusionModule.NAME,
30
+ canOverrideExistingModule = false,
31
+ needsEagerInit = false,
32
+ isCxxModule = false,
33
+ isTurboModule = true
34
+ )
35
+ )
36
+ }
37
+ }
@@ -0,0 +1,30 @@
1
+ package com.facefusion
2
+
3
+ import android.content.Context
4
+ import android.view.SurfaceHolder
5
+ import android.view.SurfaceView
6
+
7
+ /**
8
+ * The RN-mounted view behind `<FacefusionPreview />`. Holds no swap logic of its own — it
9
+ * only owns a [SurfaceView] and forwards its [Surface][android.view.Surface] lifecycle to
10
+ * [PreviewSurfaceHolder], which is what [PhotoSwap] and [VideoSwap] actually draw into.
11
+ */
12
+ class FacefusionPreviewView(context: Context) : SurfaceView(context), SurfaceHolder.Callback {
13
+
14
+ init {
15
+ holder.addCallback(this)
16
+ }
17
+
18
+ override fun surfaceCreated(holder: SurfaceHolder) {
19
+ PreviewSurfaceHolder.attach(holder.surface)
20
+ }
21
+
22
+ override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
23
+ // No cached size to update -- PreviewSurfaceHolder.draw() reads the Canvas's own
24
+ // width/height fresh from lockCanvas() every frame.
25
+ }
26
+
27
+ override fun surfaceDestroyed(holder: SurfaceHolder) {
28
+ PreviewSurfaceHolder.detach(holder.surface)
29
+ }
30
+ }
@@ -0,0 +1,25 @@
1
+ package com.facefusion
2
+
3
+ import com.facebook.react.uimanager.SimpleViewManager
4
+ import com.facebook.react.uimanager.ThemedReactContext
5
+
6
+ /**
7
+ * Registers `<FacefusionPreview />` (`src/FacefusionPreview.tsx`) as `"FacefusionPreviewView"`.
8
+ *
9
+ * A plain [SimpleViewManager], not a codegen'd Fabric component -- there are no props to
10
+ * bridge (the view has none; all it does is exist and own a Surface), so codegen would add a
11
+ * `codegenConfig` project-wide setting and a generated interface for zero benefit. RN's
12
+ * Fabric interop layer renders a legacy `ViewManager` like this one under the New
13
+ * Architecture without changes here, the same way `example/`'s (currently unwired)
14
+ * `NativeVideoView` was scaffolded to.
15
+ */
16
+ class FacefusionPreviewViewManager : SimpleViewManager<FacefusionPreviewView>() {
17
+ override fun getName() = NAME
18
+
19
+ override fun createViewInstance(reactContext: ThemedReactContext): FacefusionPreviewView =
20
+ FacefusionPreviewView(reactContext)
21
+
22
+ companion object {
23
+ const val NAME = "FacefusionPreviewView"
24
+ }
25
+ }
@@ -0,0 +1,82 @@
1
+ package com.facefusion
2
+
3
+ import android.content.ContentValues
4
+ import android.content.Context
5
+ import android.os.Environment
6
+ import android.provider.MediaStore
7
+ import java.io.File
8
+
9
+ /**
10
+ * Copies a swap's output into the system's Photos/Gallery app, so it survives beyond this
11
+ * app's own private storage (where [PhotoSwap]/[VideoSwap] actually write) and shows up
12
+ * somewhere other apps and the user's own Files/Photos app can see it.
13
+ *
14
+ * This is deliberately a separate, stateless call rather than something `swapPhoto`/
15
+ * `swapVideo` do automatically — same reasoning as `sourceFaceBox`/`targetFaceBox` staying
16
+ * out of [SwapConfig] (ADR-0012/0014): "write the output" and "publish it to the gallery"
17
+ * are two different decisions, and a caller who wants the first without the second
18
+ * shouldn't have to work around the second happening anyway.
19
+ */
20
+ object GallerySave {
21
+
22
+ /** [mimeType] was neither an image nor a video mime type — nothing else is a supported
23
+ * gallery collection on Android. */
24
+ class UnsupportedMimeType(message: String) : Exception(message)
25
+
26
+ /**
27
+ * Copies the file at [sourcePath] into `MediaStore.Images` or `MediaStore.Video`,
28
+ * depending on [mimeType], under a `Facefusion` album inside Pictures or Movies. Returns
29
+ * the resulting `content://` URI as a string.
30
+ *
31
+ * No storage permission is needed here, on `minSdk 31` or any API level since scoped
32
+ * storage arrived (29): inserting *new* media an app itself created into MediaStore has
33
+ * never required one — only reading or modifying media the app didn't create does. That
34
+ * is also why this can't just be a plain file copy to `/sdcard/Pictures` — an app has no
35
+ * direct filesystem access to another app's (or the Gallery's) view of shared storage,
36
+ * `MediaStore` is the only door in.
37
+ */
38
+ fun run(
39
+ context: Context,
40
+ sourcePath: String,
41
+ mimeType: String,
42
+ displayName: String,
43
+ ): String {
44
+ val source = File(sourcePath)
45
+ require(source.exists()) { "No file at $sourcePath" }
46
+
47
+ val (collection, relativeDir) = when {
48
+ mimeType.startsWith("image/") ->
49
+ MediaStore.Images.Media.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_PICTURES
50
+ mimeType.startsWith("video/") ->
51
+ MediaStore.Video.Media.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_MOVIES
52
+ else -> throw UnsupportedMimeType(
53
+ "saveToGallery supports image/* and video/* only, got '$mimeType'"
54
+ )
55
+ }
56
+
57
+ val resolver = context.contentResolver
58
+
59
+ // IS_PENDING hides the row from other apps (the Gallery included) until the bytes are
60
+ // actually written -- without it, something could show a zero-byte or half-written file
61
+ // for the brief window between insert() and the copy finishing.
62
+ val values = ContentValues().apply {
63
+ put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
64
+ put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
65
+ put(MediaStore.MediaColumns.RELATIVE_PATH, "$relativeDir/Facefusion")
66
+ put(MediaStore.MediaColumns.IS_PENDING, 1)
67
+ }
68
+
69
+ val uri = resolver.insert(collection, values)
70
+ ?: throw IllegalStateException("MediaStore refused to insert $displayName")
71
+
72
+ val opened = resolver.openOutputStream(uri)
73
+ ?: throw IllegalStateException("Could not open an output stream for $uri")
74
+ opened.use { out -> source.inputStream().use { it.copyTo(out) } }
75
+
76
+ values.clear()
77
+ values.put(MediaStore.MediaColumns.IS_PENDING, 0)
78
+ resolver.update(uri, values, null, null)
79
+
80
+ return uri.toString()
81
+ }
82
+ }