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,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
+ }