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,351 @@
|
|
|
1
|
+
package com.facefusion
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.ConnectivityManager
|
|
5
|
+
import android.net.NetworkCapabilities
|
|
6
|
+
import android.util.Log
|
|
7
|
+
import java.io.File
|
|
8
|
+
import java.io.FileOutputStream
|
|
9
|
+
import java.io.IOException
|
|
10
|
+
import java.net.HttpURLConnection
|
|
11
|
+
import java.net.URL
|
|
12
|
+
import java.security.MessageDigest
|
|
13
|
+
|
|
14
|
+
/** One file in the hosted manifest. */
|
|
15
|
+
data class ModelFile(val name: String, val bytes: Long, val sha256: String)
|
|
16
|
+
|
|
17
|
+
/** The best tier the manifest actually publishes for this chip, and its files. */
|
|
18
|
+
data class ModelManifest(val tier: String, val files: List<ModelFile>)
|
|
19
|
+
|
|
20
|
+
/** A snapshot of a download in flight. Bytes are across the whole set, not the current file. */
|
|
21
|
+
data class DownloadProgress(
|
|
22
|
+
val tier: String,
|
|
23
|
+
val fileIndex: Int,
|
|
24
|
+
val fileCount: Int,
|
|
25
|
+
val name: String,
|
|
26
|
+
val doneBytes: Long,
|
|
27
|
+
val totalBytes: Long,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
/** Thrown by [ModelDownload.run] when [ModelDownload.cancel] was called. The `.part` files stay. */
|
|
31
|
+
class DownloadCancelled : IOException("Cancelled")
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Fetching the NPU context binaries for this device's tier.
|
|
35
|
+
*
|
|
36
|
+
* The models are not in the APK and never will be: the set is ~317 MB, and several of the
|
|
37
|
+
* models carry licences that are not ours to sublicense (`docs/04-models.md`). They are
|
|
38
|
+
* hosted on Hugging Face and pulled on first run — the same thing FaceFusion itself does.
|
|
39
|
+
*
|
|
40
|
+
* Two properties this has to have, both of which upstream learned the hard way:
|
|
41
|
+
*
|
|
42
|
+
* * **Resumable.** A 206 MB file over a phone link is not an atomic operation. Bytes land
|
|
43
|
+
* in `<name>.part` and a retry continues from its length with an HTTP Range request.
|
|
44
|
+
* * **Verified.** A context binary that is short does not fail loudly — it fails at load,
|
|
45
|
+
* four layers away from the cause, inside Qualcomm's runtime. So every file is SHA-256'd
|
|
46
|
+
* against the manifest before it is allowed to take its real name.
|
|
47
|
+
*/
|
|
48
|
+
object ModelDownload {
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Versioned per *model* revision, not per app version — the `-0.1.0` is upstream's model
|
|
52
|
+
* revision and stays put while the app moves, so an old build keeps resolving the files
|
|
53
|
+
* it was tested against.
|
|
54
|
+
*/
|
|
55
|
+
const val REPO = "AbrahamPJ/facefusion-mobile-models-0.1.0"
|
|
56
|
+
|
|
57
|
+
private const val BASE = "https://huggingface.co/$REPO/resolve/main/"
|
|
58
|
+
private const val TAG = "ffmodels"
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What a manifest entry's `name` is allowed to be: a bare `.bin` filename, nothing else.
|
|
62
|
+
*
|
|
63
|
+
* The name arrives from a **remote** JSON and is then used both as a URL suffix and as
|
|
64
|
+
* `File(dir, name)`. Without this, a `name` of `../../shared_prefs/something` would write
|
|
65
|
+
* outside the models directory — and SHA-256 verification is no defence at all here,
|
|
66
|
+
* because the same manifest that supplies the path also supplies the expected hash.
|
|
67
|
+
*
|
|
68
|
+
* This is not a hypothetical about upstream's intentions; it is that this is a library
|
|
69
|
+
* other people ship, the host is a third party neither they nor we control, and the check
|
|
70
|
+
* costs one regex. A rejected entry throws rather than being skipped: a downloader that
|
|
71
|
+
* quietly ignores part of the set produces a half-tier that fails later inside Qualcomm's
|
|
72
|
+
* runtime, which is the exact failure mode this file's header says to avoid.
|
|
73
|
+
*/
|
|
74
|
+
private val SAFE_NAME = Regex("""^[A-Za-z0-9][A-Za-z0-9._-]*\.bin$""")
|
|
75
|
+
private const val CONNECT_TIMEOUT_MS = 30_000
|
|
76
|
+
private const val READ_TIMEOUT_MS = 60_000
|
|
77
|
+
private const val BUFFER = 1 shl 16
|
|
78
|
+
|
|
79
|
+
/** How often progress is reported. The UI cannot use more, and each tick crosses to JS. */
|
|
80
|
+
private const val TICK_MS = 250L
|
|
81
|
+
|
|
82
|
+
@Volatile
|
|
83
|
+
private var cancelled = false
|
|
84
|
+
|
|
85
|
+
/** Asks the run in flight to stop. Idempotent, and safe to call when nothing is running. */
|
|
86
|
+
fun cancel() {
|
|
87
|
+
cancelled = true
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True when the connection is metered, so a caller can warn before spending ~317 MB. */
|
|
91
|
+
fun isMetered(context: Context): Boolean {
|
|
92
|
+
val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false
|
|
93
|
+
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
|
|
94
|
+
return !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Which files to fetch, from the hosted manifest. Network call; never on the main thread.
|
|
99
|
+
*
|
|
100
|
+
* Takes the whole tier **chain** rather than one tier and returns the best entry the
|
|
101
|
+
* manifest actually publishes. The two differ whenever the app knows about an arch whose
|
|
102
|
+
* binaries are not hosted — which is the normal state for a while after a new chip lands,
|
|
103
|
+
* and is not hypothetical: v81 is in the chain of every 8 Elite Gen 5 and was withheld
|
|
104
|
+
* from the manifest on 2026-08-30 because the binaries had never been run on v81 silicon.
|
|
105
|
+
* Resolving against the manifest turns that from "no models published for tier v81" into
|
|
106
|
+
* a working v73 install. See ADR-0007.
|
|
107
|
+
*/
|
|
108
|
+
fun manifestFor(chain: List<String>): ModelManifest {
|
|
109
|
+
require(chain.isNotEmpty()) { "no tier requested" }
|
|
110
|
+
|
|
111
|
+
val tiers = org.json.JSONObject(get(BASE + "manifest.json")).getJSONObject("tiers")
|
|
112
|
+
|
|
113
|
+
// The best tier that is both published AND actually usable. "Published" is not enough:
|
|
114
|
+
// as of 2026-09-11 upstream publishes v81 whose only gate-shaped file is `nsfwq2`,
|
|
115
|
+
// which `Pipeline::init` never opens -- so an 8 Elite Gen 5 that downloaded v81 would
|
|
116
|
+
// fetch 305 MB and then fail init outright with "no content gate", which is a hard
|
|
117
|
+
// failure and not a recoverable one.
|
|
118
|
+
//
|
|
119
|
+
// Falling through to the next tier in the chain instead is exactly the machinery
|
|
120
|
+
// ADR-0007 exists for, keyed on "can the pipeline use this" rather than the weaker
|
|
121
|
+
// "does the manifest mention it". The native side then picks the same tier on its own,
|
|
122
|
+
// because it probes which tier's files are actually on disk.
|
|
123
|
+
val tier = chain.firstOrNull { tiers.has(it) && hasUsableGate(tiers, it) }
|
|
124
|
+
?: throw IOException(
|
|
125
|
+
"no usable models published for any of ${chain.joinToString(", ")} — a tier must " +
|
|
126
|
+
"ship ${ModelPaths.GATE.joinToString(" or ")} for the content gate to run"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
val files = tiers.getJSONObject(tier).getJSONArray("files")
|
|
130
|
+
return ModelManifest(
|
|
131
|
+
tier = tier,
|
|
132
|
+
files = (0 until files.length()).map {
|
|
133
|
+
val o = files.getJSONObject(it)
|
|
134
|
+
val name = o.getString("name")
|
|
135
|
+
// Validated here, at the parse boundary, so nothing downstream can ever hold an
|
|
136
|
+
// unsafe name -- see [SAFE_NAME].
|
|
137
|
+
if (!SAFE_NAME.matches(name)) {
|
|
138
|
+
throw IOException("manifest for $tier names an unacceptable file: '$name'")
|
|
139
|
+
}
|
|
140
|
+
ModelFile(name, o.getLong("bytes"), o.getString("sha256"))
|
|
141
|
+
}
|
|
142
|
+
// Only what `Pipeline::init` actually opens -- see [ModelPaths.OPENED_BY_PIPELINE].
|
|
143
|
+
// Filtered here, at the one place the manifest is read, so the byte totals, the
|
|
144
|
+
// progress events, `notPresent` and the log line below all agree about what this
|
|
145
|
+
// run is for. Filtering later would have the progress bar counting files nothing
|
|
146
|
+
// fetches.
|
|
147
|
+
.filter { isOpenedByPipeline(it.name, tier) },
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Whether [tier]'s published file list includes a content gate the native layer opens.
|
|
153
|
+
*
|
|
154
|
+
* A tier without one cannot start at all — see the call site in [manifestFor] — so this
|
|
155
|
+
* is a precondition for choosing a tier, not a detail of what to download from it.
|
|
156
|
+
*/
|
|
157
|
+
private fun hasUsableGate(tiers: org.json.JSONObject, tier: String): Boolean {
|
|
158
|
+
val files = tiers.getJSONObject(tier).optJSONArray("files") ?: return false
|
|
159
|
+
val names = (0 until files.length()).map { files.getJSONObject(it).optString("name") }
|
|
160
|
+
return ModelPaths.GATE.any { "${it}_$tier.bin" in names }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Whether `name` is one of the models the native pipeline opens, for this [tier].
|
|
165
|
+
*
|
|
166
|
+
* Manifest names are `<base>_<tier>.bin`, so the tier suffix is stripped and the base
|
|
167
|
+
* checked against [ModelPaths.OPENED_BY_PIPELINE]. A file for a *different* tier would
|
|
168
|
+
* not match and is skipped, which is correct — the manifest only lists one tier's files
|
|
169
|
+
* per tier object, but being explicit costs nothing and makes a malformed manifest
|
|
170
|
+
* under-fetch rather than fetch the wrong tier's 206 MB swapper.
|
|
171
|
+
*/
|
|
172
|
+
private fun isOpenedByPipeline(name: String, tier: String): Boolean {
|
|
173
|
+
val suffix = "_$tier.bin"
|
|
174
|
+
if (!name.endsWith(suffix)) return false
|
|
175
|
+
return name.removeSuffix(suffix) in ModelPaths.OPENED_BY_PIPELINE
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Which of [files] are not already present at their full length in [dir].
|
|
180
|
+
*
|
|
181
|
+
* Length only, not hash: re-reading 317 MB to decide whether to download it would cost
|
|
182
|
+
* seconds on every launch. The hash is checked once, when the file is committed, and a
|
|
183
|
+
* file that is the right length but wrong content would have failed that check and never
|
|
184
|
+
* been renamed.
|
|
185
|
+
*/
|
|
186
|
+
fun notPresent(dir: File, files: List<ModelFile>): List<ModelFile> =
|
|
187
|
+
files.filter { f ->
|
|
188
|
+
val onDisk = File(dir, f.name)
|
|
189
|
+
!onDisk.canRead() || onDisk.length() != f.bytes
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Download everything missing for the best published tier in [chain].
|
|
194
|
+
*
|
|
195
|
+
* Blocking — call it from a worker thread. Returns the tier that was fetched. Throws
|
|
196
|
+
* [DownloadCancelled] if cancelled, or an [IOException] naming the file that failed.
|
|
197
|
+
*/
|
|
198
|
+
fun run(dir: File, chain: List<String>, onProgress: (DownloadProgress) -> Unit): String {
|
|
199
|
+
cancelled = false
|
|
200
|
+
|
|
201
|
+
val manifest = manifestFor(chain)
|
|
202
|
+
val todo = notPresent(dir, manifest.files)
|
|
203
|
+
|
|
204
|
+
// Say what is about to be fetched and what was kept. A resumed download that re-fetches
|
|
205
|
+
// a 206 MB file it already had and one that fetches only what is missing look identical
|
|
206
|
+
// from outside -- a progress bar and a wait.
|
|
207
|
+
Log.i(
|
|
208
|
+
TAG,
|
|
209
|
+
"tier ${manifest.tier}: ${manifest.files.size} files, fetching ${todo.map { it.name }}, " +
|
|
210
|
+
"keeping ${manifest.files.filterNot { f -> todo.any { it.name == f.name } }.map { it.name }}",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
val totalBytes = todo.sumOf { it.bytes }
|
|
214
|
+
var doneBytes = 0L
|
|
215
|
+
|
|
216
|
+
for ((i, file) in todo.withIndex()) {
|
|
217
|
+
throwIfCancelled()
|
|
218
|
+
val startedAt = doneBytes
|
|
219
|
+
fetch(dir, file) { fileBytes ->
|
|
220
|
+
doneBytes = startedAt + fileBytes
|
|
221
|
+
onProgress(
|
|
222
|
+
DownloadProgress(manifest.tier, i + 1, todo.size, file.name, doneBytes, totalBytes)
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
doneBytes = startedAt + file.bytes
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// One final tick so a caller that only ever sees ticks lands on 100% rather than on
|
|
229
|
+
// whatever the last 250 ms boundary happened to be.
|
|
230
|
+
onProgress(
|
|
231
|
+
DownloadProgress(manifest.tier, todo.size, todo.size, "", totalBytes, totalBytes)
|
|
232
|
+
)
|
|
233
|
+
return manifest.tier
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* One file, resuming its `.part` if there is one.
|
|
238
|
+
*
|
|
239
|
+
* [onBytes] is called with the bytes transferred *for this file*, throttled; the caller
|
|
240
|
+
* adds the running total. The temp file only takes its real name after the hash matches,
|
|
241
|
+
* so a partial or corrupt download can never present itself to the loader as a model.
|
|
242
|
+
*/
|
|
243
|
+
private fun fetch(dir: File, file: ModelFile, onBytes: (Long) -> Unit) {
|
|
244
|
+
val part = File(dir, file.name + ".part")
|
|
245
|
+
val dest = File(dir, file.name)
|
|
246
|
+
|
|
247
|
+
// A .part longer than the target is left over from a different revision of the file;
|
|
248
|
+
// resuming from it would append good bytes onto wrong ones.
|
|
249
|
+
if (part.exists() && part.length() > file.bytes) part.delete()
|
|
250
|
+
|
|
251
|
+
var from = if (part.exists()) part.length() else 0L
|
|
252
|
+
if (from == file.bytes) {
|
|
253
|
+
// Fully transferred last time but the process died before it was verified.
|
|
254
|
+
verifyAndCommit(part, dest, file)
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
val conn = (URL(BASE + file.name).openConnection() as HttpURLConnection).apply {
|
|
259
|
+
connectTimeout = CONNECT_TIMEOUT_MS
|
|
260
|
+
readTimeout = READ_TIMEOUT_MS
|
|
261
|
+
if (from > 0) setRequestProperty("Range", "bytes=$from-")
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
val code = conn.responseCode
|
|
265
|
+
if (from > 0 && code != HttpURLConnection.HTTP_PARTIAL) {
|
|
266
|
+
// The server ignored the Range, so the bytes coming back start at zero. Appending
|
|
267
|
+
// them to the .part would corrupt it silently -- the length would look plausible.
|
|
268
|
+
part.delete()
|
|
269
|
+
from = 0
|
|
270
|
+
}
|
|
271
|
+
if (code != HttpURLConnection.HTTP_OK && code != HttpURLConnection.HTTP_PARTIAL) {
|
|
272
|
+
throw IOException("${file.name}: HTTP $code")
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
var written = from
|
|
276
|
+
onBytes(written)
|
|
277
|
+
conn.inputStream.use { input ->
|
|
278
|
+
FileOutputStream(part, from > 0).use { out ->
|
|
279
|
+
val buf = ByteArray(BUFFER)
|
|
280
|
+
var lastTick = 0L
|
|
281
|
+
while (true) {
|
|
282
|
+
throwIfCancelled()
|
|
283
|
+
val n = input.read(buf)
|
|
284
|
+
if (n < 0) break
|
|
285
|
+
out.write(buf, 0, n)
|
|
286
|
+
written += n
|
|
287
|
+
val now = System.currentTimeMillis()
|
|
288
|
+
if (now - lastTick > TICK_MS) {
|
|
289
|
+
lastTick = now
|
|
290
|
+
onBytes(written)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
} finally {
|
|
296
|
+
// The .part is deliberately kept on failure: it is exactly what a resume needs.
|
|
297
|
+
conn.disconnect()
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
throwIfCancelled()
|
|
301
|
+
verifyAndCommit(part, dest, file)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private fun verifyAndCommit(part: File, dest: File, file: ModelFile) {
|
|
305
|
+
if (part.length() != file.bytes) {
|
|
306
|
+
part.delete()
|
|
307
|
+
throw IOException("${file.name}: expected ${file.bytes} bytes, got ${part.length()}")
|
|
308
|
+
}
|
|
309
|
+
val actual = sha256(part)
|
|
310
|
+
if (!actual.equals(file.sha256, ignoreCase = true)) {
|
|
311
|
+
// Not resumable: a hash mismatch means the bytes are wrong, not that they are short.
|
|
312
|
+
part.delete()
|
|
313
|
+
throw IOException("${file.name}: checksum mismatch")
|
|
314
|
+
}
|
|
315
|
+
dest.delete()
|
|
316
|
+
if (!part.renameTo(dest)) throw IOException("${file.name}: could not be saved")
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private fun sha256(file: File): String {
|
|
320
|
+
val md = MessageDigest.getInstance("SHA-256")
|
|
321
|
+
file.inputStream().use { stream ->
|
|
322
|
+
val buf = ByteArray(1 shl 20)
|
|
323
|
+
while (true) {
|
|
324
|
+
val n = stream.read(buf)
|
|
325
|
+
if (n < 0) break
|
|
326
|
+
md.update(buf, 0, n)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return md.digest().joinToString("") { "%02x".format(it) }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** A small GET with timeouts. `URL.readText()` has none and can hang for minutes. */
|
|
333
|
+
private fun get(url: String): String {
|
|
334
|
+
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
|
335
|
+
connectTimeout = CONNECT_TIMEOUT_MS
|
|
336
|
+
readTimeout = READ_TIMEOUT_MS
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
if (conn.responseCode != HttpURLConnection.HTTP_OK) {
|
|
340
|
+
throw IOException("HTTP ${conn.responseCode} for $url")
|
|
341
|
+
}
|
|
342
|
+
return conn.inputStream.bufferedReader().readText()
|
|
343
|
+
} finally {
|
|
344
|
+
conn.disconnect()
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private fun throwIfCancelled() {
|
|
349
|
+
if (cancelled) throw DownloadCancelled()
|
|
350
|
+
}
|
|
351
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
package com.facefusion
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import java.io.File
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where the model files live, and which tier this install will actually load.
|
|
8
|
+
*
|
|
9
|
+
* There is exactly one rule for both answers because the native side has its own copy of
|
|
10
|
+
* it: `Pipeline::init` in `ffpipe.cpp` resolves the tier against disk the same way. If
|
|
11
|
+
* this file names a tier the pipeline will not open, "models missing" and "model loaded"
|
|
12
|
+
* disagree on the same device and the result reads like a download bug rather than a
|
|
13
|
+
* mismatch. Any change here is a change to `ffpipe.cpp:88-95` as well.
|
|
14
|
+
*/
|
|
15
|
+
object ModelPaths {
|
|
16
|
+
|
|
17
|
+
/** The swap model. Upstream's `Pipeline::init` takes this as a parameter; we ship one. */
|
|
18
|
+
const val SWAPPER = "hyperswap"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The models the pipeline refuses to start without, by base name.
|
|
22
|
+
*
|
|
23
|
+
* `gpen` (the face enhancer) is deliberately absent — `Pipeline::init` opens it without
|
|
24
|
+
* checking, so a missing enhancer is a feature that is off, not a broken install.
|
|
25
|
+
*/
|
|
26
|
+
private val REQUIRED = listOf("yoloface", "fan2d", "arcface", SWAPPER)
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The content gate, in the native layer's preference order: fp32 first, quantised second.
|
|
30
|
+
*
|
|
31
|
+
* These two names exactly — `Pipeline::init` tries `nsfw_<tier>` then `nsfwq_<tier>` and
|
|
32
|
+
* treats neither being present as a fatal init error, by design, because a gate that
|
|
33
|
+
* silently does not run is worse than no gate. Anything else the manifest may publish
|
|
34
|
+
* (`nsfwq2`) is not opened, so it does not count as a gate here no matter how much it
|
|
35
|
+
* looks like one. See [ModelDownload.manifestFor], which refuses a tier on this basis.
|
|
36
|
+
*/
|
|
37
|
+
val GATE = listOf("nsfw", "nsfwq")
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every model `Pipeline::init` actually opens — the download allowlist.
|
|
41
|
+
*
|
|
42
|
+
* **This exists because the manifest lists more than the pipeline uses.** As of
|
|
43
|
+
* 2026-09-11 upstream publishes `wav2lip` (43.73 MB) and `edtalk` (60.37 MB) for
|
|
44
|
+
* lip-sync work this port does not do, plus `nsfwq2` (~6.6 MB) on the lower tiers that
|
|
45
|
+
* `Pipeline::init` never reaches because it tries `nsfw_` then `nsfwq_` and stops. That
|
|
46
|
+
* was **104 MB of every user's first run spent on files nothing opens.**
|
|
47
|
+
*
|
|
48
|
+
* This reverses an earlier deliberate stance (`docs/04-models.md`: "we fetch exactly
|
|
49
|
+
* what the manifest lists and staying in lockstep with upstream is worth more than
|
|
50
|
+
* 6.6 MB"). That reasoning held at 6.6 MB. It does not hold at 104 MB on a mobile
|
|
51
|
+
* connection, which is a quarter of the download.
|
|
52
|
+
*
|
|
53
|
+
* `fan685` is here even though no tier currently publishes it: `Pipeline::init` opens it
|
|
54
|
+
* optionally and falls back to geometry when absent, so it should be fetched the day it
|
|
55
|
+
* appears rather than needing a code change to notice.
|
|
56
|
+
*
|
|
57
|
+
* The cost of an allowlist is that a genuinely new *required* model would be skipped
|
|
58
|
+
* until this list learns about it — which surfaces as `init` failing by name
|
|
59
|
+
* ("no content gate: neither ..."), loudly, not as a silent wrong answer.
|
|
60
|
+
*/
|
|
61
|
+
val OPENED_BY_PIPELINE: List<String> = REQUIRED + GATE + listOf("gpen", "fan685")
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The models directory, created by us.
|
|
65
|
+
*
|
|
66
|
+
* App-specific external storage: no permission is needed on any supported Android
|
|
67
|
+
* version, the OS deletes it with the app, and — unlike internal storage — it can be
|
|
68
|
+
* listed over adb while debugging. `getExternalFilesDir` returns null when external
|
|
69
|
+
* storage is not mounted, which is rare but real, so internal storage is the fallback.
|
|
70
|
+
*
|
|
71
|
+
* `mkdirs()` here matters more than it looks: a directory created by `adb push` is owned
|
|
72
|
+
* by the `shell` user and this app cannot traverse it, which surfaces as `open()` failing
|
|
73
|
+
* with a bare ENOENT on a file that is plainly there. Creating it ourselves first avoids
|
|
74
|
+
* inheriting someone else's ownership.
|
|
75
|
+
*/
|
|
76
|
+
fun dir(context: Context): File {
|
|
77
|
+
val base = context.getExternalFilesDir(null) ?: context.filesDir
|
|
78
|
+
return File(base, "models").apply { mkdirs() }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The tier this install will load: the best one whose files are on disk, else the best
|
|
83
|
+
* this chip could load.
|
|
84
|
+
*
|
|
85
|
+
* **Never cache this**, and never substitute the probe's `tier` for it. That one is
|
|
86
|
+
* `tierChain().first()` — what the *silicon* can load, with no reference to disk. The two
|
|
87
|
+
* differ on exactly the devices whose best arch is not published yet: an 8 Elite Gen 5
|
|
88
|
+
* resolves the chain `v81,v73,v68`, the manifest currently publishes no v81, the
|
|
89
|
+
* downloader correctly fetches **v73**, and anything that then names files `_v81` reports
|
|
90
|
+
* a complete download as missing. That shipped in upstream's 0.2.0. Recomputing is a
|
|
91
|
+
* handful of `canRead()` calls; the chain is what was expensive, and that is cached.
|
|
92
|
+
*/
|
|
93
|
+
fun tier(context: Context): String {
|
|
94
|
+
val chain = DeviceProbe.tierChain(context)
|
|
95
|
+
val dir = dir(context)
|
|
96
|
+
// The detector is the probe because it is mandatory and the smallest file in the set.
|
|
97
|
+
// A tier is never half present: the downloader writes `<name>.part` and renames only
|
|
98
|
+
// after the SHA-256 matches, so yoloface being there means the rest of that tier is.
|
|
99
|
+
return chain.firstOrNull { File(dir, "yoloface_$it.bin").canRead() } ?: chain.first()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Which required models are absent for [tier], by base name. Empty means ready to run.
|
|
104
|
+
*
|
|
105
|
+
* The content gate counts as required because it *blocks*: without it there is nothing to
|
|
106
|
+
* refuse with, and a run that cannot check is a run that must not happen. Either build
|
|
107
|
+
* satisfies it — fp32 `nsfw_`, or the quantised `nsfwq_` that every tier below v79
|
|
108
|
+
* carries because the fp32 GELU will not compile for them.
|
|
109
|
+
*/
|
|
110
|
+
fun missing(context: Context, tier: String): List<String> {
|
|
111
|
+
val dir = dir(context)
|
|
112
|
+
val absent = REQUIRED.filterNot { File(dir, "${it}_$tier.bin").canRead() }.toMutableList()
|
|
113
|
+
if (GATE.none { File(dir, "${it}_$tier.bin").canRead() }) absent += "nsfw"
|
|
114
|
+
return absent
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Whether the optional face enhancer is on disk for [tier]. */
|
|
118
|
+
fun hasEnhancer(context: Context, tier: String): Boolean =
|
|
119
|
+
File(dir(context), "gpen_$tier.bin").canRead()
|
|
120
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
package com.facefusion
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.graphics.BitmapFactory
|
|
6
|
+
import com.facefusion.mobile.NativePipe
|
|
7
|
+
import java.io.File
|
|
8
|
+
import java.io.FileOutputStream
|
|
9
|
+
|
|
10
|
+
/** The result of one still-photo swap. */
|
|
11
|
+
data class PhotoSwapResult(
|
|
12
|
+
val outputPath: String,
|
|
13
|
+
/** Faces found in the target, per [NativePipe.processFrame]. `0` means the swap ran but
|
|
14
|
+
* found nothing to swap — [outputPath] is then an untouched copy of the target. */
|
|
15
|
+
val faceCount: Int,
|
|
16
|
+
/** The tier that ran, from [ModelPaths.tier] — see that file for why it, not the chip's
|
|
17
|
+
* raw probe, is the one that matters here. */
|
|
18
|
+
val tier: String,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Swaps a face from [sourcePath] into every face found in [targetPath], writing the result
|
|
23
|
+
* to [outputPath].
|
|
24
|
+
*
|
|
25
|
+
* Paths in, path out (`docs/01-architecture.md`) — this is the only place in the Kotlin
|
|
26
|
+
* layer that touches pixels, and it does so only to get them into and out of [NativePipe]'s
|
|
27
|
+
* flat BGR arrays. Everything between is native.
|
|
28
|
+
*/
|
|
29
|
+
object PhotoSwap {
|
|
30
|
+
|
|
31
|
+
/** The tier's required models are not on disk. Distinct from a native/runtime failure so
|
|
32
|
+
* the TurboModule can give it its own rejection code — see [FacefusionModule.swapPhoto]. */
|
|
33
|
+
class ModelsMissing(message: String) : Exception(message)
|
|
34
|
+
|
|
35
|
+
fun run(
|
|
36
|
+
context: Context,
|
|
37
|
+
sourcePath: String,
|
|
38
|
+
targetPath: String,
|
|
39
|
+
outputPath: String,
|
|
40
|
+
cfg: SwapConfig,
|
|
41
|
+
/** `[left, top, right, bottom]` from [SourceFaces.detect], or `null` for the default
|
|
42
|
+
* "largest face in the source" that [NativePipe.setSource] already picks on its own. */
|
|
43
|
+
sourceFaceBox: FloatArray? = null,
|
|
44
|
+
/** `[left, top, right, bottom]` from [TargetFaces.detect], or `null` to swap every face
|
|
45
|
+
* found in the target (subject to [SwapConfig.largestFaceOnly]), same as before this
|
|
46
|
+
* option existed. See [FaceCrop]. */
|
|
47
|
+
targetFaceBox: FloatArray? = null,
|
|
48
|
+
): PhotoSwapResult {
|
|
49
|
+
NativePipe.loadError?.let {
|
|
50
|
+
throw IllegalStateException("libffnative.so did not load: $it")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
val tier = ModelPaths.tier(context)
|
|
54
|
+
val missing = ModelPaths.missing(context, tier)
|
|
55
|
+
if (missing.isNotEmpty()) {
|
|
56
|
+
throw ModelsMissing(
|
|
57
|
+
"Models missing for $tier: ${missing.joinToString(", ")} — call downloadModels() first"
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Capped during decode -- see [BitmapDecode] for why, and why the source's cap is
|
|
62
|
+
// lower than the target's. `sourceFaceBox` is in the source's ORIGINAL pixel
|
|
63
|
+
// coordinates (it came from SourceFaces.detect on the same path, decoded with the same
|
|
64
|
+
// cap), so the crop below stays consistent with it.
|
|
65
|
+
val decodedSource = BitmapDecode.decode(sourcePath, BitmapDecode.SOURCE_MAX)
|
|
66
|
+
val source = sourceFaceBox?.let { SourceFaces.cropToFace(decodedSource, it) } ?: decodedSource
|
|
67
|
+
val target = BitmapDecode.decode(targetPath, BitmapDecode.TARGET_MAX)
|
|
68
|
+
|
|
69
|
+
return PipeGuard.run(context, tier, cfg) {
|
|
70
|
+
val sourceBgr = NativePipe.argbToBgr(pixelsOf(source), source.width, source.height)
|
|
71
|
+
if (!NativePipe.setSource(sourceBgr, source.width, source.height)) {
|
|
72
|
+
throw IllegalStateException(NativePipe.lastError())
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
val targetBgr = NativePipe.argbToBgr(pixelsOf(target), target.width, target.height)
|
|
76
|
+
ContentGate.checkFrame(targetBgr, target.width, target.height)
|
|
77
|
+
val faceCount = if (targetFaceBox != null) {
|
|
78
|
+
// Swap only inside a crop around the chosen face, then paste it back -- the same
|
|
79
|
+
// "crop, call the unchanged native function, paste/re-encode" trick SourceFaces
|
|
80
|
+
// uses on the source side. Whatever processFrame finds outside the crop is never
|
|
81
|
+
// touched, so this needs no native "pick face N" support.
|
|
82
|
+
val rect = FaceCrop.rect(targetFaceBox, target.width, target.height)
|
|
83
|
+
val cropped = FaceCrop.crop(targetBgr, target.width, rect)
|
|
84
|
+
val cw = rect[2] - rect[0]
|
|
85
|
+
val ch = rect[3] - rect[1]
|
|
86
|
+
val count = NativePipe.processFrame(cropped, cw, ch)
|
|
87
|
+
if (count > 0) FaceCrop.paste(targetBgr, target.width, cropped, rect)
|
|
88
|
+
count
|
|
89
|
+
} else {
|
|
90
|
+
NativePipe.processFrame(targetBgr, target.width, target.height)
|
|
91
|
+
}
|
|
92
|
+
if (faceCount < 0) throw IllegalStateException(NativePipe.lastError())
|
|
93
|
+
|
|
94
|
+
// One frame, not a loop -- shows the result on any mounted <FacefusionPreview />
|
|
95
|
+
// immediately, before the encode-to-file below even starts.
|
|
96
|
+
PreviewSurfaceHolder.draw(targetBgr, target.width, target.height)
|
|
97
|
+
|
|
98
|
+
val outArgb = NativePipe.bgrToArgb(targetBgr, target.width, target.height, target.width, target.height)
|
|
99
|
+
val outBitmap = Bitmap.createBitmap(target.width, target.height, Bitmap.Config.ARGB_8888)
|
|
100
|
+
outBitmap.setPixels(outArgb, 0, target.width, 0, 0, target.width, target.height)
|
|
101
|
+
write(outBitmap, outputPath)
|
|
102
|
+
|
|
103
|
+
PhotoSwapResult(outputPath, faceCount, tier)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private fun pixelsOf(bitmap: Bitmap): IntArray {
|
|
108
|
+
val pixels = IntArray(bitmap.width * bitmap.height)
|
|
109
|
+
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
|
|
110
|
+
return pixels
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private fun write(bitmap: Bitmap, path: String) {
|
|
114
|
+
val file = File(path)
|
|
115
|
+
file.parentFile?.mkdirs()
|
|
116
|
+
FileOutputStream(file).use { out ->
|
|
117
|
+
val format = if (path.endsWith(".png", ignoreCase = true)) {
|
|
118
|
+
Bitmap.CompressFormat.PNG
|
|
119
|
+
} else {
|
|
120
|
+
Bitmap.CompressFormat.JPEG
|
|
121
|
+
}
|
|
122
|
+
bitmap.compress(format, 92, out)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
package com.facefusion
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import com.facefusion.mobile.NativePipe
|
|
5
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Owns the single native pipeline (`g_pipe`, a C++ global in `ffjni.cpp`) and the fact that
|
|
9
|
+
* only one job can use it at a time.
|
|
10
|
+
*
|
|
11
|
+
* Ported from upstream's `PipeGuard.kt` (see `docs/02-upstream.md`) — not copied, since only
|
|
12
|
+
* the C++ is vendored (ADR-0005) and upstream's Kotlin was never fetched. This reproduces the
|
|
13
|
+
* *behaviour* upstream's class docs describe: a video swap can run for minutes, and a second
|
|
14
|
+
* caller queued behind that on the same lock would look indistinguishable from a hang. So a
|
|
15
|
+
* second job is rejected immediately with [Busy] instead of waiting, the same shape as
|
|
16
|
+
* [ModelDownload]'s `E_BUSY` check.
|
|
17
|
+
*
|
|
18
|
+
* Re-initialising the pipeline is expensive — it `dlopen`s the QNN backend and finalises
|
|
19
|
+
* every graph — so it is kept warm across calls and only reloaded when [SwapConfig] or the
|
|
20
|
+
* resolved tier actually changed. See ADR-0008.
|
|
21
|
+
*/
|
|
22
|
+
object PipeGuard {
|
|
23
|
+
class Busy : Exception("A face-swap job is already running")
|
|
24
|
+
|
|
25
|
+
private val busy = AtomicBoolean(false)
|
|
26
|
+
|
|
27
|
+
@Volatile
|
|
28
|
+
private var initedSignature: String? = null
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Runs [block] with the native pipeline initialised for [tier]/[cfg], holding the single
|
|
32
|
+
* job slot for the duration.
|
|
33
|
+
*
|
|
34
|
+
* @throws Busy immediately if another job already holds the slot.
|
|
35
|
+
* @throws IllegalStateException if `initEx` fails; [NativePipe.lastError] names why.
|
|
36
|
+
*/
|
|
37
|
+
fun <T> run(context: Context, tier: String, cfg: SwapConfig, block: () -> T): T {
|
|
38
|
+
if (!busy.compareAndSet(false, true)) throw Busy()
|
|
39
|
+
try {
|
|
40
|
+
ensureInit(context, tier, cfg)
|
|
41
|
+
return block()
|
|
42
|
+
} finally {
|
|
43
|
+
busy.set(false)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private fun ensureInit(context: Context, tier: String, cfg: SwapConfig) {
|
|
48
|
+
val signature = "$tier|${cfg.signature()}"
|
|
49
|
+
if (signature == initedSignature) return
|
|
50
|
+
|
|
51
|
+
val libDir = context.applicationInfo.nativeLibraryDir
|
|
52
|
+
val modelDir = ModelPaths.dir(context).absolutePath
|
|
53
|
+
val ok = NativePipe.initEx(
|
|
54
|
+
libDir,
|
|
55
|
+
libDir,
|
|
56
|
+
modelDir,
|
|
57
|
+
ModelPaths.SWAPPER,
|
|
58
|
+
cfg.swapperWeight,
|
|
59
|
+
cfg.maskBlur,
|
|
60
|
+
cfg.maskPadding.toIntArray(),
|
|
61
|
+
cfg.detectorScore,
|
|
62
|
+
cfg.landmarkerScore,
|
|
63
|
+
cfg.pixelBoost,
|
|
64
|
+
cfg.largestFaceOnly,
|
|
65
|
+
cfg.faceEnhance,
|
|
66
|
+
cfg.faceEnhancerBlend,
|
|
67
|
+
)
|
|
68
|
+
if (!ok) {
|
|
69
|
+
initedSignature = null
|
|
70
|
+
throw IllegalStateException(NativePipe.lastError())
|
|
71
|
+
}
|
|
72
|
+
initedSignature = signature
|
|
73
|
+
}
|
|
74
|
+
}
|