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
package/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Saad
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ of this software and associated documentation files (the "Software"), to deal
6
+ in the Software without restriction, including without limitation the rights
7
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,259 @@
1
+ # react-native-facefusion
2
+
3
+ A React Native TurboModule that runs face swapping **entirely on the phone**, on
4
+ Qualcomm's Hexagon NPU. No server, no upload, no network call for inference — the
5
+ photo or video never leaves the device.
6
+
7
+ ## Requirements — read this first
8
+
9
+ - **Android only.** There is no iOS implementation. This runs on Qualcomm's Hexagon
10
+ DSP via QNN, which Apple silicon has no equivalent path for.
11
+ - **Android 12+ (`minSdk 31`), `arm64-v8a` only.**
12
+ - **A Snapdragon chip with a Hexagon NPU**, and one whose Hexagon architecture your
13
+ build actually bundles a runtime for. The example app ships **v68, v69, v73, v75, v79
14
+ and v81** — roughly Snapdragon 888 through 8 Elite Gen 5. The architecture is a
15
+ property of the silicon and is chosen independently of the model tier, so a build
16
+ missing that pair cannot reach the DSP on that phone at all; `probeDevice()` reports
17
+ which ones the build contains. v66 and older cannot be supported: that generation
18
+ predates the HTP backend entirely and QAIRT ships it only as a `QnnDsp` build.
19
+ - On anything else — an emulator included, since no emulator has a Hexagon DSP — every
20
+ call still resolves, but the NPU-backed ones report `ok: false` rather than hanging or
21
+ crashing.
22
+ - **A one-time native SDK step at install**, described below. It cannot be skipped
23
+ and it cannot be bundled into this package — see [Install](#install).
24
+
25
+ ## Install
26
+
27
+ Three steps, not one — a plain `npm install` is not enough on its own.
28
+
29
+ ```sh
30
+ npm install react-native-facefusion
31
+ ```
32
+
33
+ ```sh
34
+ # 1. Fetch the upstream C++ engine this module wraps. Not committed to this repo or
35
+ # to the npm package — see "Why the extra steps" below.
36
+ ./node_modules/react-native-facefusion/scripts/fetch-upstream.sh
37
+ ```
38
+
39
+ ```sh
40
+ # 2. Get Qualcomm's QAIRT SDK (Community edition — a plain ZIP, no account needed):
41
+ # https://www.qualcomm.com/developer/software/qualcomm-ai-runtime-sdk-qairt
42
+ #
43
+ # Copy out of the ZIP into THIS PACKAGE's directory (not your app's) -- CMake resolves
44
+ # the headers relative to its own source dir, so they have to live here:
45
+ #
46
+ # PKG=node_modules/react-native-facefusion/android/src/main
47
+ #
48
+ # include/QNN/ -> $PKG/cpp/include/QNN/
49
+ # lib/aarch64-android/libQnnHtp.so -> $PKG/jniLibs/arm64-v8a/
50
+ # lib/aarch64-android/libQnnSystem.so -> $PKG/jniLibs/arm64-v8a/
51
+ # lib/aarch64-android/libQnnHtpV*Stub.so -> $PKG/jniLibs/arm64-v8a/ (ALL of them)
52
+ # lib/hexagon-v*/unsigned/libQnnHtpV*Skel.so -> $PKG/jniLibs/arm64-v8a/ (ALL of them)
53
+ #
54
+ # Copy EVERY architecture, not a chosen few. libQnnHtp.so picks the Stub/Skel pair from
55
+ # the chip's own Hexagon architecture, not from the model tier -- so a missing pair means
56
+ # "deviceCreate failed -- the DSP is not reachable from this process" on every phone of
57
+ # that generation, however many models are downloaded. ~12 MB per architecture.
58
+ #
59
+ # Leave out libQnnHtpPrepare.so -- it's the on-device graph compiler (82 MB) and the
60
+ # models arrive pre-compiled, so it is exactly the step this never performs.
61
+ ```
62
+
63
+ **Script steps 1 and 2.** Both write into `node_modules/`, so a fresh `npm install`
64
+ or `npm ci` wipes them. Put them in a checked-in setup script (or a `postinstall`)
65
+ rather than running them by hand once and forgetting — a CI machine or a new
66
+ teammate's clone will otherwise fail at CMake configure with a message naming the
67
+ missing piece.
68
+
69
+ **Your app's `android/build.gradle` needs `minSdkVersion = 31`.** A fresh React Native
70
+ app is `24`, and this package builds against your value, not its own — so leaving it at
71
+ 24 produces an APK that installs on Android 7 and crashes there instead of reporting an
72
+ unsupported device. The library refuses to build below 31, so the manifest merge fails
73
+ with a message naming it rather than building something that cannot work:
74
+
75
+ ```groovy
76
+ // android/build.gradle
77
+ buildscript {
78
+ ext {
79
+ minSdkVersion = 31 // required -- the default 24 is below what this package supports
80
+ }
81
+ }
82
+ ```
83
+
84
+ Your app's `android/app/build.gradle` also needs:
85
+
86
+ ```groovy
87
+ android {
88
+ packagingOptions {
89
+ jniLibs {
90
+ useLegacyPackaging = true // required -- the QNN backend is dlopen'd by
91
+ // absolute path, which needs real files on
92
+ // disk, not compressed inside the APK
93
+ }
94
+ }
95
+ }
96
+ ```
97
+
98
+ ### Why the extra steps
99
+
100
+ Neither piece can legally ship inside this package:
101
+
102
+ - **The QNN runtime** is Qualcomm's, under a licence that permits redistribution
103
+ "as incorporated in your software application" but not "on a standalone basis" —
104
+ an app may bundle it, an npm package may not.
105
+ - **The C++ engine** this module wraps ([`AbrahamPaulJ/facefusion-mobile`](https://github.com/AbrahamPaulJ/facefusion-mobile))
106
+ ships no LICENSE file, which defaults to all-rights-reserved. It is vendored
107
+ unmodified and fetched at a pinned commit by `scripts/fetch-upstream.sh`, never
108
+ committed to git history.
109
+
110
+ ## Usage
111
+
112
+ ```ts
113
+ import {
114
+ probeDevice,
115
+ getModelStatus,
116
+ downloadModels,
117
+ onModelDownloadProgress,
118
+ swapPhoto,
119
+ swapVideo,
120
+ detectSourceFaces,
121
+ detectTargetFaces,
122
+ saveToGallery,
123
+ FacefusionPreview,
124
+ } from 'react-native-facefusion';
125
+
126
+ // Check the chip and see what's already on disk.
127
+ const device = await probeDevice(); // { ok, tier, tierChain, arch, vtcmMb, ... }
128
+ const status = await getModelStatus(); // { ready, missing, tier, hasEnhancer, ... }
129
+
130
+ // Download the model set for this device's tier (~300+ MB, resumable, SHA256-verified).
131
+ if (!status.ready) {
132
+ const sub = onModelDownloadProgress((p) => console.log(p.fileIndex, p.fileCount));
133
+ await downloadModels();
134
+ sub.remove();
135
+ }
136
+
137
+ // Swap a face into a photo. Paths in, path out -- no pixels cross the JS bridge.
138
+ const result = await swapPhoto(sourcePath, targetPath, outputPath, {
139
+ faceEnhance: true,
140
+ });
141
+ // { outputPath, faceCount, tier }
142
+
143
+ // Same call shape for video -- runs behind a foreground service.
144
+ const videoResult = await swapVideo(sourcePath, targetPath, outputPath, {
145
+ targetFps: 15, // cap the frame rate to trade quality for speed
146
+ });
147
+ // { outputPath, frameCount, faceFrameCount, tier, fps, hasAudio }
148
+
149
+ // Multiple faces in the source or target? Detect first, let the user pick, then pass
150
+ // the chosen box back into swapPhoto/swapVideo as sourceFaceBox / targetFaceBox.
151
+ // Pass the SAME options you pass to the swap. A picker that finds a face the swap then
152
+ // rejects is a picker that lies -- and these thresholds reach the pipeline through its
153
+ // init, so detecting at different values re-opens every model graph twice per swap.
154
+ const sourceFaces = await detectSourceFaces(sourcePath, { detectorScore: 0.5 });
155
+ const targetFaces = await detectTargetFaces(targetPath, { detectorScore: 0.5 });
156
+
157
+ // Copy an output into the Photos app -- swapPhoto/swapVideo write to your own
158
+ // app's private storage, not somewhere the user can see without this.
159
+ const uri = await saveToGallery(result.outputPath, 'image/jpeg');
160
+ ```
161
+
162
+ ```tsx
163
+ // Watch the swap happen live -- a native Surface the pipeline draws into directly,
164
+ // not a stream of frames sent over the bridge.
165
+ <FacefusionPreview style={{ width: '100%', aspectRatio: 16 / 9 }} />
166
+ ```
167
+
168
+ Full option/result shapes (`SwapOptions`, `DeviceProbeResult`, `ModelStatus`, …) are
169
+ documented on the TypeScript types themselves — your editor will show them.
170
+
171
+ Only one swap or video job runs at a time; a second call rejects with `E_BUSY` rather
172
+ than queuing. `swapVideo` can be stopped mid-run with `cancelVideoSwap()`.
173
+
174
+ ## Content gate
175
+
176
+ Every swap checks its target against upstream FaceFusion's NSFW content gate before
177
+ processing it, and refuses with `E_CONTENT` if it's flagged. This is not optional or
178
+ configurable from JS — a safeguard a caller can turn off is not a safeguard.
179
+
180
+ Known limits of this port, stated plainly rather than hidden:
181
+
182
+ - It gates on one model (`nsfw_2`) where upstream votes across three — the other two
183
+ total 461 MB against this package's ~266 MB, and which way a single model errs
184
+ against the full ensemble is unmeasured.
185
+ - On every chip tier except v79, the gate itself runs quantised, which measured
186
+ ~0.087 mean closer to flagging than the full-precision model, 16 of 16 held-out
187
+ frames in the same direction. Not compensated for — reported here instead.
188
+ - A still image is one check; a video samples one frame per second and refuses if
189
+ more than 10% of samples are flagged.
190
+ - A failure to run the check (a native error, not a flagged result) is always treated
191
+ as a refusal, never as "allow."
192
+
193
+ Both branches are verified on real hardware (Snapdragon 8 Elite, tier v79): every
194
+ clean test swap passes the same check, and an NSFW test image scored ~0.8 against the
195
+ 0.25 threshold and was refused with `E_CONTENT` before the swap ran or any output file
196
+ was written (2026-09-11). Not yet exercised on hardware: a video refused on the
197
+ aggregate 10% rate, and the native-error-means-refusal path.
198
+
199
+ ## Known issues
200
+
201
+ - **A photo swap's output is capped at 2560 px on the long edge** (and a source photo
202
+ is subsampled to 1920, which costs nothing — it only contributes an identity, never
203
+ output pixels). This is deliberate: the pipeline holds roughly 19 bytes per pixel at
204
+ once, so an uncapped 50 MP photo — the main camera on the phones this library
205
+ requires — needs ~950 MB and cannot run at all. Videos are unaffected; they process
206
+ at the clip's own resolution.
207
+ - **The same photo can be detected on one chip and not on another.** Every tier ships the
208
+ same 4.0 MB `yoloface` detector, but as a *separately compiled QNN context binary per
209
+ Hexagon architecture* — the weights are identical, the compiled graph is not. The same
210
+ face therefore scores differently on a v68 chip than on a v79 one, and `detectorScore`
211
+ is a hard cutoff (`score <= detectorScore` is dropped outright), so a face scoring 0.46
212
+ on a Snapdragon 8 Gen 1 and 0.58 on an 8 Elite is found on exactly one of the two.
213
+ Observed 2026-09-14: the same photo detected and swapped on an S25 (tier v79) and was
214
+ not detected on an S22 (tier v68); other photos worked on both.
215
+
216
+ This is not something the library can normalise away — there is no per-tier calibration
217
+ to apply and no ground truth on the device to calibrate against. What it does instead is
218
+ make the threshold reachable: `detectorScore` is an option on `swapPhoto`, `swapVideo`,
219
+ `detectSourceFaces` and `detectTargetFaces` alike. **Expose it in your UI rather than
220
+ treating an empty detection result as final** — around `0.3` recovers most of the gap,
221
+ at the cost of more false positives on busy backgrounds. The example app shows the
222
+ pattern: zero faces is reported with the current threshold named, not as silence.
223
+
224
+ - **Qualcomm's Hexagon skel libraries are not 16 KB page-size aligned.** Android 15+ warns when
225
+ an APK contains a native library aligned to the old 4 KB page assumption. Measured
226
+ across all 25 libraries in a release build: 19 are aligned correctly, including this
227
+ package's own `libffnative.so` and every React Native and Hermes library. The six
228
+ that are not are `libQnnHtpV*Skel.so` — one per Hexagon architecture — which ship
229
+ prebuilt in Qualcomm's
230
+ QAIRT SDK and can only be fixed upstream. They are Hexagon DSP images loaded over
231
+ fastrpc rather than mapped into the app's address space, so this is a packaging-check
232
+ warning rather than a loading failure — but the warning is shown to users on Android
233
+ 15+, and it will not go away until Qualcomm ships aligned builds.
234
+
235
+ ## Performance
236
+
237
+ Every number below is measured on real hardware, dated, and says exactly what was
238
+ run. None of it is upstream's own figures, and nothing here is an estimate.
239
+
240
+ | Device | Resolution | Face enhancer | fps | Date |
241
+ |---|---|---|---|---|
242
+ | Samsung SM-S931B (Snapdragon 8 Elite, tier v79) | 1280×720 | off | **9.8** | 2026-09-09 |
243
+
244
+ This is one pipeline's own decode→swap→encode loop end to end, not a swap-graph-only
245
+ figure. Expect it to vary by device tier, resolution, and whether the enhancer runs.
246
+
247
+ ## Contributing
248
+
249
+ - [Development workflow](CONTRIBUTING.md#development-workflow)
250
+ - [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request)
251
+ - [Code of conduct](CODE_OF_CONDUCT.md)
252
+
253
+ ## License
254
+
255
+ MIT
256
+
257
+ ---
258
+
259
+ Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob)
@@ -0,0 +1,129 @@
1
+ buildscript {
2
+ ext.Facefusion = [
3
+ kotlinVersion: "2.0.21",
4
+ minSdkVersion: 31,
5
+ compileSdkVersion: 36,
6
+ ndkVersion: "27.1.12297006"
7
+ ]
8
+
9
+ ext.getExtOrDefault = { prop ->
10
+ if (rootProject.ext.has(prop)) {
11
+ return rootProject.ext.get(prop)
12
+ }
13
+
14
+ return Facefusion[prop]
15
+ }
16
+
17
+ repositories {
18
+ google()
19
+ mavenCentral()
20
+ }
21
+
22
+ dependencies {
23
+ classpath "com.android.tools.build:gradle:8.7.2"
24
+ // noinspection DifferentKotlinGradleVersion
25
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}"
26
+ }
27
+ }
28
+
29
+
30
+ apply plugin: "com.android.library"
31
+ apply plugin: "kotlin-android"
32
+
33
+ apply plugin: "com.facebook.react"
34
+
35
+ android {
36
+ namespace "com.facefusion"
37
+
38
+ compileSdkVersion getExtOrDefault("compileSdkVersion")
39
+ ndkVersion getExtOrDefault("ndkVersion")
40
+
41
+ // arm64 only. Every device this package supports is a 64-bit Snapdragon, and the QNN
42
+ // runtime we dlopen ships no other ABI -- building x86/armeabi would produce a
43
+ // libffnative.so that can never find a backend.
44
+ defaultConfig {
45
+ // A FLOOR, not a preference. getExtOrDefault checks rootProject.ext first, and every
46
+ // React Native app template defines ext.minSdkVersion -- so a consumer's value ALWAYS
47
+ // wins and the `minSdkVersion: 31` default above is dead in any real install. A fresh
48
+ // RN app is 24, which built this library silently at 24 in a clean-consumer test:
49
+ // no error, an APK that installs on Android 7, and API-31+ calls in our own Kotlin
50
+ // (startForeground's 3-arg overload, for one) waiting to throw NoSuchMethodError at
51
+ // runtime instead of ADR-0006's designed "unsupported device" answer.
52
+ //
53
+ // max() keeps a consumer's HIGHER floor and refuses a lower one. The consumer then
54
+ // fails at manifest merge with a message naming this library and the number to
55
+ // raise -- which is the outcome we want: loud, at build time, actionable.
56
+ minSdkVersion Math.max(getExtOrDefault("minSdkVersion") as int, 31)
57
+ ndk {
58
+ abiFilters "arm64-v8a"
59
+ }
60
+ externalNativeBuild {
61
+ cmake {
62
+ // c++_shared, not c++_static: several .so files in one process must share one
63
+ // copy of the STL, and libffnative.so is not the only native library here.
64
+ arguments "-DANDROID_STL=c++_shared"
65
+ }
66
+ }
67
+ }
68
+
69
+ externalNativeBuild {
70
+ cmake {
71
+ path "src/main/cpp/CMakeLists.txt"
72
+ version "3.22.1"
73
+ }
74
+ }
75
+
76
+ compileOptions {
77
+ sourceCompatibility JavaVersion.VERSION_17
78
+ targetCompatibility JavaVersion.VERSION_17
79
+ }
80
+ }
81
+
82
+ dependencies {
83
+ implementation "com.facebook.react:react-android"
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Content gate stub (ADR-0003). ContentGate.kt is the one file a personal, gate-free
88
+ // build deletes. PhotoSwap.kt/VideoSwap.kt call it as an ordinary Kotlin symbol, so
89
+ // deleting it without this task would just fail the library's own compile -- not
90
+ // produce the working "-dev" build the ADR describes. When the real file is present
91
+ // this generates nothing (and removes any stub from a previous build); when it is
92
+ // absent it generates a no-op stand-in with the same public surface, in a directory
93
+ // the real file's package never lives in, so there is never a duplicate-class clash.
94
+ // ---------------------------------------------------------------------------
95
+ def contentGateFile = file("src/main/java/com/facefusion/ContentGate.kt")
96
+ def contentGateStubDir = file("$buildDir/generated/contentGateStub")
97
+
98
+ tasks.register("generateContentGateStub") {
99
+ doLast {
100
+ def stubFile = new File(contentGateStubDir, "com/facefusion/ContentGate.kt")
101
+ if (contentGateFile.exists()) {
102
+ if (stubFile.exists()) stubFile.delete()
103
+ return
104
+ }
105
+ stubFile.parentFile.mkdirs()
106
+ stubFile.text = '''package com.facefusion
107
+
108
+ // Generated by :react-native-facefusion's build.gradle because
109
+ // src/main/java/com/facefusion/ContentGate.kt is absent -- the personal, gate-free
110
+ // build (ADR-0003). A no-op with the real file's public surface, so callers need no
111
+ // change either way.
112
+ object ContentGate {
113
+ class Refused(message: String, val isQuantised: Boolean) : Exception(message)
114
+
115
+ fun checkFrame(bgr: ByteArray, w: Int, h: Int) {}
116
+
117
+ class VideoSampler {
118
+ fun sample(bgr: ByteArray, w: Int, h: Int) {}
119
+ fun finish() {}
120
+ }
121
+ }
122
+ '''
123
+ }
124
+ }
125
+
126
+ android.sourceSets.main.kotlin.srcDirs += contentGateStubDir
127
+ tasks.matching { it.name ==~ /compile.*Kotlin/ }.configureEach {
128
+ dependsOn("generateContentGateStub")
129
+ }
@@ -0,0 +1,66 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+
3
+ <!--
4
+ The models are ~317 MB and are not in the APK: several carry licences that are not
5
+ ours to sublicense, so they are downloaded on first run (docs/04-models.md). Declared
6
+ by the library rather than left to the app, because a consumer that forgets it gets a
7
+ SecurityException from deep inside the downloader instead of a clear failure.
8
+ -->
9
+ <uses-permission android:name="android.permission.INTERNET" />
10
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
11
+
12
+ <!--
13
+ A video swap runs for seconds to minutes (docs/plan/README.md, Phase 7), long enough
14
+ that Android kills a plain background process doing it. FOREGROUND_SERVICE is required
15
+ to start any foreground service at all from API 28; FOREGROUND_SERVICE_MEDIA_PROCESSING
16
+ is the specific type a foreground service must declare from targetSdk 34, and it is the
17
+ one that actually names what VideoSwapService is. POST_NOTIFICATIONS is a runtime
18
+ prompt from API 33: without it granted the service still runs (the OS does not revoke
19
+ startForeground over a missing notification permission), it just shows no notification.
20
+ A real consumer app should ask for it; this package's own example app does not yet
21
+ (Phase 9's picker/permissions UI), so this is a known, documented gap rather than a
22
+ silent one.
23
+ -->
24
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
25
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROCESSING" />
26
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
27
+
28
+ <application>
29
+
30
+ <!--
31
+ VideoSwapService is a notification shell, not the worker: see its class doc. It
32
+ never binds and is only ever started/stopped by FacefusionModule, hence
33
+ exported="false".
34
+ -->
35
+ <service
36
+ android:name=".VideoSwapService"
37
+ android:exported="false"
38
+ android:foregroundServiceType="mediaProcessing" />
39
+ <!--
40
+ The Hexagon DSP is reached over fastrpc through libcdsprpc.so, which lives on the
41
+ VENDOR partition and is listed in /vendor/etc/public.libraries.txt on Qualcomm
42
+ devices. From targetSdk 31 an app must ALSO name a vendor library here, or the
43
+ dynamic linker will not put it in the app's namespace, and the QNN stub that
44
+ needs it fails to load:
45
+
46
+ dlopen failed: library "libcdsprpc.so" not found:
47
+ needed by .../lib/arm64/libQnnHtpV79Stub.so in namespace clns-9
48
+
49
+ which surfaces four layers up as "Failed to load skel, error: 4000" and then a
50
+ failed deviceCreate, an error that says nothing about the actual cause.
51
+
52
+ required="false" on BOTH, which is where this differs from an app that only ever
53
+ ships to Snapdragon. required="true" makes the library absent = the app will not
54
+ install, and this package is a dependency inside somebody else's app that may
55
+ well also ship to Pixels and Exynos. Those devices should install, run, and be
56
+ told "no NPU here" by probeDevice() (ADR-0006), not fail at the Play Store.
57
+
58
+ Declared here rather than in the consumer's manifest for the same reason as the
59
+ permissions above: a consumer who does not know to add it gets a four-layer-deep
60
+ 4000 instead of a working NPU. Manifest merging puts it in their app.
61
+ -->
62
+ <uses-native-library android:name="libcdsprpc.so" android:required="false" />
63
+ <uses-native-library android:name="libadsprpc.so" android:required="false" />
64
+ </application>
65
+
66
+ </manifest>
@@ -0,0 +1,73 @@
1
+ # Builds libffnative.so -- the on-device inference engine.
2
+ #
3
+ # The sources are upstream's, vendored unmodified under third_party/ (ADR-0004). This
4
+ # file is ours because the paths differ in this repo: their sources sit beside their
5
+ # CMakeLists, ours sit two directories away, and the QNN headers live under our
6
+ # android/src/main/cpp/include/. The compile flags and the link line below are
7
+ # identical to theirs on purpose -- see third_party/facefusion-mobile/NOTICE.
8
+ cmake_minimum_required(VERSION 3.22.1)
9
+ project(ffnative)
10
+
11
+ set(FF_UPSTREAM "${CMAKE_CURRENT_SOURCE_DIR}/../../../../third_party/facefusion-mobile/cpp")
12
+
13
+ # Both of these are fetched, not committed, and both fail here rather than 200 lines
14
+ # into a compiler error nobody can read.
15
+ #
16
+ # These two messages point at README.md's "Install" section, NOT at docs/ -- docs/ is
17
+ # the local engineering record and ships in zero files of the npm package, so a
18
+ # consumer told to read docs/03-setup.md is told to read a file they do not have. This
19
+ # is the likeliest error any new installer hits (a plain `npm install` leaves both
20
+ # pieces absent, and a later `npm install` wipes them again), so it has to be
21
+ # actionable from inside node_modules with nothing else to hand.
22
+ if(NOT EXISTS "${FF_UPSTREAM}/ffjni.cpp")
23
+ message(FATAL_ERROR
24
+ "Upstream C++ engine is missing from third_party/facefusion-mobile/cpp/.\n"
25
+ "It is not shipped in this package: upstream publishes no licence.\n"
26
+ "Run: ./node_modules/react-native-facefusion/scripts/fetch-upstream.sh\n"
27
+ "See README.md -> Install, step 1.")
28
+ endif()
29
+
30
+ if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/include/QNN/QnnBackend.h")
31
+ message(FATAL_ERROR
32
+ "Qualcomm QNN headers are missing from android/src/main/cpp/include/QNN/.\n"
33
+ "The QAIRT SDK is proprietary and cannot be shipped in this package; copy it in\n"
34
+ "yourself, into THIS directory (not your app's).\n"
35
+ "See README.md -> Install, step 2, for the download and the exact file list.")
36
+ endif()
37
+
38
+ # The same four objects upstream's app and headless CLI both link.
39
+ add_library(ffnative SHARED
40
+ "${FF_UPSTREAM}/ffjni.cpp"
41
+ "${FF_UPSTREAM}/ffpipe.cpp"
42
+ "${FF_UPSTREAM}/ffcv.cpp"
43
+ "${FF_UPSTREAM}/ffqnn.cpp")
44
+
45
+ # Three roots: the vendored sources include their own headers unqualified ("ffcv.h");
46
+ # ffqnn.cpp includes "QNN/QnnBackend.h"; and the SDK's own sub-headers include their
47
+ # siblings unqualified, so include/QNN has to be on the path as well as include.
48
+ target_include_directories(ffnative PRIVATE
49
+ "${FF_UPSTREAM}"
50
+ "${CMAKE_CURRENT_SOURCE_DIR}/include"
51
+ "${CMAKE_CURRENT_SOURCE_DIR}/include/QNN")
52
+
53
+ # No QNN library is linked. libQnnHtp.so / libQnnSystem.so are dlopen'd at runtime by
54
+ # absolute path out of the app's nativeLibraryDir, which is why this builds against the
55
+ # headers alone and why useLegacyPackaging must stay true (docs/01-architecture.md).
56
+ target_link_libraries(ffnative android log dl)
57
+ target_compile_options(ffnative PRIVATE -O3 -fvisibility=hidden -Wall)
58
+
59
+ # 16 KB page size (Android 15+). Devices with 16 KB pages cannot load a library whose
60
+ # LOAD segments are aligned to the old 4 KB assumption, and Android 15 shows the user a
61
+ # compatibility warning for any APK containing one.
62
+ #
63
+ # Measured on the v0.1.0 release APK before this line existed: 15 of 19 libraries were
64
+ # already aligned to 0x4000, INCLUDING every React Native and Hermes library -- so the
65
+ # toolchain is not the problem and the note in docs/MEMORY.md claiming otherwise was
66
+ # wrong. Exactly four were 0x1000: this library, and Qualcomm's three
67
+ # libQnnHtpV{73,79,81}Skel.so.
68
+ #
69
+ # This flag fixes ours. The three Skel files are prebuilt binaries shipped in the QAIRT
70
+ # SDK, so they can only be fixed by Qualcomm -- and they are Hexagon DSP images loaded
71
+ # over fastrpc rather than mapped into the app's own address space, so the host-side
72
+ # alignment is about the packaging check rather than about whether they can run.
73
+ target_link_options(ffnative PRIVATE "-Wl,-z,max-page-size=16384")
@@ -0,0 +1,112 @@
1
+ package com.facefusion
2
+
3
+ import android.graphics.Bitmap
4
+ import android.graphics.BitmapFactory
5
+
6
+ /**
7
+ * Decoding a photo without letting a big one kill the process.
8
+ *
9
+ * Every image path in this library used to call `BitmapFactory.decodeFile` with no size
10
+ * limit, which crashes on photos that modern phones actually take. The arithmetic is the
11
+ * whole reason this file exists, so it is written down rather than left to be rediscovered:
12
+ *
13
+ * [PhotoSwap] holds, simultaneously, for a `w x h` target — the decoded `Bitmap`
14
+ * (`w*h*4`), the `IntArray` of pixels handed to `argbToBgr` (`w*h*4`), the packed BGR
15
+ * buffer (`w*h*3`), the `IntArray` coming back from `bgrToArgb` (`w*h*4`) and the output
16
+ * `Bitmap` (`w*h*4`). That is **~19 bytes per pixel**, against a heap this project has
17
+ * measured topping out near 220 MB on a Snapdragon 8 Elite.
18
+ *
19
+ * | Photo | Pixels | Peak at ~19 B/px |
20
+ * |---|---|---|
21
+ * | 2560x1920 | 4.9 MP | ~93 MB — fine |
22
+ * | 4096x3072 | 12.6 MP | ~239 MB — **crashes** |
23
+ * | 8160x6120 (a 50 MP phone) | 50 MP | ~950 MB — **crashes immediately** |
24
+ *
25
+ * So this is not an exotic-input problem: a 50 MP main camera is standard on the exact
26
+ * phones this library requires, and a user picking a photo their own phone took was
27
+ * enough to kill it.
28
+ *
29
+ * **The downscale has to happen during decode, not after.** `inSampleSize` tells
30
+ * `BitmapFactory` to subsample as it reads, so the full-size bitmap is never allocated.
31
+ * Decoding first and calling `createScaledBitmap` afterwards would OOM on the decode
32
+ * itself, which is exactly where the original crash was.
33
+ */
34
+ object BitmapDecode {
35
+
36
+ /**
37
+ * Cap for an image that only contributes an **identity**, never pixels to the output.
38
+ *
39
+ * The source face is warped down to 112x112 for the recogniser (`ffpipe.cpp`, the
40
+ * `arcface` stage), so resolution beyond a point contributes literally nothing — this
41
+ * is free, not a tradeoff.
42
+ */
43
+ const val SOURCE_MAX = 1920
44
+
45
+ /**
46
+ * Cap for an image that **becomes** the output, where resolution is real quality.
47
+ *
48
+ * Deliberately higher than [SOURCE_MAX] for that reason, and still comfortably inside
49
+ * the budget above. A swap output at 2560 on the long edge is past what any phone screen
50
+ * or social upload will show; the alternative on a 50 MP input is not "sharper", it is
51
+ * "crash".
52
+ */
53
+ const val TARGET_MAX = 2560
54
+
55
+ /**
56
+ * Decodes the image at [path], subsampled during decode so its long edge does not
57
+ * greatly exceed [maxDimension].
58
+ *
59
+ * `inSampleSize` only takes powers of two, so the result can be up to ~2x under the cap
60
+ * rather than landing exactly on it. That is the right trade: powers of two are the case
61
+ * `BitmapFactory` decodes without an extra resample step, and being under the cap is
62
+ * never the failure.
63
+ *
64
+ * ARGB_8888 is forced explicitly — `decodeFile` already defaults to it, but leaving it
65
+ * implicit invites a HARDWARE bitmap the moment someone switches this to `ImageDecoder`,
66
+ * and `getPixels()` throws on those.
67
+ */
68
+ fun decode(path: String, maxDimension: Int): Bitmap =
69
+ decodeOrNull(path, maxDimension)
70
+ ?: throw IllegalArgumentException("Could not decode image: $path")
71
+
72
+ /**
73
+ * As [decode], but answers `null` instead of throwing when [path] is not a decodable
74
+ * image.
75
+ *
76
+ * [TargetFaces] needs this distinction: a target may legitimately be a *video*, and it
77
+ * tells the two apart by trying a still decode first and falling through on failure. A
78
+ * throwing decode would abort before the video path was ever reached.
79
+ */
80
+ fun decodeOrNull(path: String, maxDimension: Int): Bitmap? {
81
+ // Pass one: read the header only. `inJustDecodeBounds` allocates no pixels, so this is
82
+ // safe on an image far too big to actually decode -- which is the entire point.
83
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
84
+ BitmapFactory.decodeFile(path, bounds)
85
+ if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
86
+
87
+ val options = BitmapFactory.Options().apply {
88
+ inPreferredConfig = Bitmap.Config.ARGB_8888
89
+ inSampleSize = sampleSizeFor(bounds.outWidth, bounds.outHeight, maxDimension)
90
+ }
91
+ return BitmapFactory.decodeFile(path, options)
92
+ }
93
+
94
+ /**
95
+ * The smallest power of two that brings `max(w, h)` to [maxDimension] **or below**.
96
+ *
97
+ * Note the "or below", and that it is not the idiom most Android samples use. The common
98
+ * one is `while (longest / 2 >= requested) sample *= 2`, which deliberately stops at the
99
+ * last size still *larger* than the request, so the caller can scale down afterwards
100
+ * with a good-quality resample. That leaves a result up to 2x the request — and here
101
+ * that is the difference between working and not: a 50 MP photo against a 2560 cap comes
102
+ * out 4080 wide under that idiom, which is ~237 MB peak and the exact crash this class
103
+ * exists to prevent. We want a memory ceiling, not a quality floor.
104
+ */
105
+ fun sampleSizeFor(width: Int, height: Int, maxDimension: Int): Int {
106
+ require(maxDimension > 0) { "maxDimension must be positive" }
107
+ var sample = 1
108
+ val longest = maxOf(width, height)
109
+ while (longest / sample > maxDimension) sample *= 2
110
+ return sample
111
+ }
112
+ }