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.
- package/LICENSE +20 -0
- package/README.md +259 -0
- package/android/build.gradle +129 -0
- package/android/src/main/AndroidManifest.xml +66 -0
- package/android/src/main/cpp/CMakeLists.txt +73 -0
- package/android/src/main/java/com/facefusion/BitmapDecode.kt +112 -0
- package/android/src/main/java/com/facefusion/ContentGate.kt +86 -0
- package/android/src/main/java/com/facefusion/DeviceProbe.kt +158 -0
- package/android/src/main/java/com/facefusion/FaceCrop.kt +57 -0
- package/android/src/main/java/com/facefusion/FacefusionModule.kt +396 -0
- package/android/src/main/java/com/facefusion/FacefusionPackage.kt +37 -0
- package/android/src/main/java/com/facefusion/FacefusionPreviewView.kt +30 -0
- package/android/src/main/java/com/facefusion/FacefusionPreviewViewManager.kt +25 -0
- package/android/src/main/java/com/facefusion/GallerySave.kt +82 -0
- package/android/src/main/java/com/facefusion/ModelDownload.kt +351 -0
- package/android/src/main/java/com/facefusion/ModelPaths.kt +120 -0
- package/android/src/main/java/com/facefusion/PhotoSwap.kt +125 -0
- package/android/src/main/java/com/facefusion/PipeGuard.kt +74 -0
- package/android/src/main/java/com/facefusion/PreviewSurfaceHolder.kt +104 -0
- package/android/src/main/java/com/facefusion/SourceFaces.kt +125 -0
- package/android/src/main/java/com/facefusion/SwapConfig.kt +31 -0
- package/android/src/main/java/com/facefusion/TargetFaces.kt +88 -0
- package/android/src/main/java/com/facefusion/VideoSwap.kt +501 -0
- package/android/src/main/java/com/facefusion/VideoSwapService.kt +85 -0
- package/android/src/main/java/com/facefusion/mobile/NativePipe.kt +192 -0
- package/lib/module/FacefusionPreview.js +6 -0
- package/lib/module/FacefusionPreview.js.map +1 -0
- package/lib/module/FacefusionPreview.native.js +15 -0
- package/lib/module/FacefusionPreview.native.js.map +1 -0
- package/lib/module/NativeFacefusion.js +53 -0
- package/lib/module/NativeFacefusion.js.map +1 -0
- package/lib/module/detectSourceFaces.js +6 -0
- package/lib/module/detectSourceFaces.js.map +1 -0
- package/lib/module/detectSourceFaces.native.js +15 -0
- package/lib/module/detectSourceFaces.native.js.map +1 -0
- package/lib/module/detectTargetFaces.js +6 -0
- package/lib/module/detectTargetFaces.js.map +1 -0
- package/lib/module/detectTargetFaces.native.js +13 -0
- package/lib/module/detectTargetFaces.native.js.map +1 -0
- package/lib/module/index.js +11 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/models.js +16 -0
- package/lib/module/models.js.map +1 -0
- package/lib/module/models.native.js +43 -0
- package/lib/module/models.native.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/probeDevice.js +6 -0
- package/lib/module/probeDevice.js.map +1 -0
- package/lib/module/probeDevice.native.js +14 -0
- package/lib/module/probeDevice.native.js.map +1 -0
- package/lib/module/saveToGallery.js +6 -0
- package/lib/module/saveToGallery.js.map +1 -0
- package/lib/module/saveToGallery.native.js +15 -0
- package/lib/module/saveToGallery.native.js.map +1 -0
- package/lib/module/swapPhoto.js +6 -0
- package/lib/module/swapPhoto.js.map +1 -0
- package/lib/module/swapPhoto.native.js +16 -0
- package/lib/module/swapPhoto.native.js.map +1 -0
- package/lib/module/swapVideo.js +12 -0
- package/lib/module/swapVideo.js.map +1 -0
- package/lib/module/swapVideo.native.js +29 -0
- package/lib/module/swapVideo.native.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/FacefusionPreview.d.ts +4 -0
- package/lib/typescript/src/FacefusionPreview.d.ts.map +1 -0
- package/lib/typescript/src/FacefusionPreview.native.d.ts +142 -0
- package/lib/typescript/src/FacefusionPreview.native.d.ts.map +1 -0
- package/lib/typescript/src/NativeFacefusion.d.ts +291 -0
- package/lib/typescript/src/NativeFacefusion.d.ts.map +1 -0
- package/lib/typescript/src/detectSourceFaces.d.ts +3 -0
- package/lib/typescript/src/detectSourceFaces.d.ts.map +1 -0
- package/lib/typescript/src/detectSourceFaces.native.d.ts +11 -0
- package/lib/typescript/src/detectSourceFaces.native.d.ts.map +1 -0
- package/lib/typescript/src/detectTargetFaces.d.ts +3 -0
- package/lib/typescript/src/detectTargetFaces.d.ts.map +1 -0
- package/lib/typescript/src/detectTargetFaces.native.d.ts +9 -0
- package/lib/typescript/src/detectTargetFaces.native.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +11 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/models.d.ts +7 -0
- package/lib/typescript/src/models.d.ts.map +1 -0
- package/lib/typescript/src/models.native.d.ts +31 -0
- package/lib/typescript/src/models.native.d.ts.map +1 -0
- package/lib/typescript/src/probeDevice.d.ts +3 -0
- package/lib/typescript/src/probeDevice.d.ts.map +1 -0
- package/lib/typescript/src/probeDevice.native.d.ts +10 -0
- package/lib/typescript/src/probeDevice.native.d.ts.map +1 -0
- package/lib/typescript/src/saveToGallery.d.ts +2 -0
- package/lib/typescript/src/saveToGallery.d.ts.map +1 -0
- package/lib/typescript/src/saveToGallery.native.d.ts +9 -0
- package/lib/typescript/src/saveToGallery.native.d.ts.map +1 -0
- package/lib/typescript/src/swapPhoto.d.ts +3 -0
- package/lib/typescript/src/swapPhoto.d.ts.map +1 -0
- package/lib/typescript/src/swapPhoto.native.d.ts +12 -0
- package/lib/typescript/src/swapPhoto.native.d.ts.map +1 -0
- package/lib/typescript/src/swapVideo.d.ts +6 -0
- package/lib/typescript/src/swapVideo.d.ts.map +1 -0
- package/lib/typescript/src/swapVideo.native.d.ts +20 -0
- package/lib/typescript/src/swapVideo.native.d.ts.map +1 -0
- package/package.json +132 -0
- package/patches/ffjni-analyse-faces.patch +63 -0
- package/scripts/check-tarball.sh +68 -0
- package/scripts/fetch-upstream.sh +91 -0
- package/src/FacefusionPreview.native.tsx +18 -0
- package/src/FacefusionPreview.tsx +9 -0
- package/src/NativeFacefusion.ts +324 -0
- package/src/detectSourceFaces.native.tsx +17 -0
- package/src/detectSourceFaces.tsx +10 -0
- package/src/detectTargetFaces.native.tsx +15 -0
- package/src/detectTargetFaces.tsx +10 -0
- package/src/index.tsx +25 -0
- package/src/models.native.tsx +45 -0
- package/src/models.tsx +23 -0
- package/src/probeDevice.native.tsx +13 -0
- package/src/probeDevice.tsx +7 -0
- package/src/saveToGallery.native.tsx +16 -0
- package/src/saveToGallery.tsx +9 -0
- package/src/swapPhoto.native.tsx +20 -0
- package/src/swapPhoto.tsx +12 -0
- package/src/swapVideo.native.tsx +40 -0
- package/src/swapVideo.tsx +31 -0
- 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
|
+
}
|