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