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,104 @@
1
+ package com.facefusion
2
+
3
+ import android.graphics.Bitmap
4
+ import android.graphics.Canvas
5
+ import android.graphics.Rect
6
+ import android.view.Surface
7
+
8
+ /**
9
+ * The live-preview sink for [PhotoSwap] and [VideoSwap]: a global holder for whichever
10
+ * [FacefusionPreviewView] is currently mounted, if any.
11
+ *
12
+ * **Why a singleton rather than a parameter threaded through the swap call.** `g_pipe` is
13
+ * already a single C++ global — only one swap can run at a time ([PipeGuard]) — so there is
14
+ * never more than one frame stream to preview. A JS-side prop or a second TurboModule call
15
+ * to "attach" a surface would need to cross the bridge with a view reference; a plain object
16
+ * both sides can reach avoids that, and mirrors how [NativePipe] itself is one process-wide
17
+ * object rather than an instance per swap.
18
+ *
19
+ * **Never touches [NativePipe] or the vendored C++.** `bgr`/`w`/`h` here are exactly the
20
+ * bytes [VideoSwap] and [PhotoSwap] already hold in JVM memory after [NativePipe.processFrame]
21
+ * returns — this only has to get them onto a [Surface]'s [Canvas], which is a plain Android
22
+ * API. That is what keeps this out of the "never pass frames across the JS bridge" rule: no
23
+ * pixel ever reaches JS, only the mounted `<FacefusionPreview />` view does, and rendering
24
+ * happens entirely on the native side of it.
25
+ */
26
+ object PreviewSurfaceHolder {
27
+
28
+ // Caps preview redraws independent of how fast the swap loop itself runs -- a 720p frame
29
+ // decoded to a Bitmap and blitted to a Canvas is not free, and the swap loop's own pace
30
+ // (tens of fps) is faster than a screen needs to look "live". ~15 fps is smooth to the eye
31
+ // and cheap enough not to be the thing that slows the swap down.
32
+ private const val MIN_INTERVAL_NANOS = 66_000_000L
33
+
34
+ @Volatile
35
+ private var surface: Surface? = null
36
+
37
+ @Volatile
38
+ private var lastDrawNanos = 0L
39
+
40
+ /** Called by [FacefusionPreviewView] once its [Surface] exists. */
41
+ fun attach(newSurface: Surface) {
42
+ surface = newSurface
43
+ lastDrawNanos = 0L
44
+ }
45
+
46
+ /** Called by [FacefusionPreviewView] when its [Surface] is torn down. Idempotent. */
47
+ fun detach(oldSurface: Surface) {
48
+ // Only clear if it's still the same surface -- a fast unmount+remount (view recycling)
49
+ // could otherwise have a stale detach() race ahead of the new attach() and wipe it.
50
+ if (surface === oldSurface) surface = null
51
+ }
52
+
53
+ /**
54
+ * Draws one BGR frame, letterboxed to fit the attached surface. A no-op — one [Volatile]
55
+ * read — when nothing is mounted, so [VideoSwap]'s loop pays nothing when no preview is
56
+ * showing. Also a no-op inside the throttle window.
57
+ *
58
+ * Safe to call from any thread; [VideoSwap] and [PhotoSwap] both call it from their own
59
+ * worker thread, never the UI thread.
60
+ */
61
+ fun draw(bgr: ByteArray, w: Int, h: Int) {
62
+ val target = surface ?: return
63
+ if (!target.isValid) return
64
+
65
+ val now = System.nanoTime()
66
+ if (now - lastDrawNanos < MIN_INTERVAL_NANOS) return
67
+
68
+ val argb = com.facefusion.mobile.NativePipe.bgrToArgb(bgr, w, h, w, h)
69
+ val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
70
+ bitmap.setPixels(argb, 0, w, 0, 0, w, h)
71
+
72
+ // lockCanvas/unlockCanvasAndPost can throw if the surface is torn down concurrently with
73
+ // this call (SurfaceHolder.Callback runs on the UI thread, this runs on the swap
74
+ // worker) -- caught rather than synchronized against, since a dropped preview frame
75
+ // during teardown is harmless and a lock here would block the swap loop on the UI thread.
76
+ try {
77
+ val canvas: Canvas = target.lockCanvas(null)
78
+ try {
79
+ val dst = fitRect(w, h, canvas.width, canvas.height)
80
+ canvas.drawColor(android.graphics.Color.BLACK)
81
+ canvas.drawBitmap(bitmap, null, dst, null)
82
+ } finally {
83
+ target.unlockCanvasAndPost(canvas)
84
+ }
85
+ lastDrawNanos = now
86
+ } catch (_: Exception) {
87
+ // Surface gone or going -- next attach() (or the next frame, if it recovers) picks
88
+ // back up. Nothing here is worth crashing a swap over.
89
+ } finally {
90
+ bitmap.recycle()
91
+ }
92
+ }
93
+
94
+ /** Centers a `srcW`x`srcH` rect inside `dstW`x`dstH`, preserving aspect ratio. */
95
+ private fun fitRect(srcW: Int, srcH: Int, dstW: Int, dstH: Int): Rect {
96
+ if (srcW <= 0 || srcH <= 0 || dstW <= 0 || dstH <= 0) return Rect(0, 0, dstW, dstH)
97
+ val scale = minOf(dstW.toFloat() / srcW, dstH.toFloat() / srcH)
98
+ val w = (srcW * scale).toInt()
99
+ val h = (srcH * scale).toInt()
100
+ val left = (dstW - w) / 2
101
+ val top = (dstH - h) / 2
102
+ return Rect(left, top, left + w, top + h)
103
+ }
104
+ }
@@ -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
+
8
+ /**
9
+ * One face found in an image.
10
+ *
11
+ * The box is in the pixel coordinates of **the bitmap that was actually analysed**, which
12
+ * is not the file on disk: a source photo is decoded capped at [BitmapDecode.SOURCE_MAX],
13
+ * a photo target at [BitmapDecode.TARGET_MAX], and a video target not capped at all (its
14
+ * own frame size). That is deliberate — it is the same space [PhotoSwap]/[VideoSwap] crop
15
+ * in, which is what lets a box picked here be handed straight back as
16
+ * `SwapOptions.targetFaceBox` with no conversion.
17
+ *
18
+ * It also means a caller cannot work out the scale on their own, so [imageWidth] and
19
+ * [imageHeight] travel with every face. Without them, drawing this box over the original
20
+ * file — the obvious thing to do with a detection API — silently lands in the wrong place
21
+ * on any photo bigger than the cap, and there is no way to detect that from the outside.
22
+ */
23
+ data class DetectedFace(
24
+ val left: Float,
25
+ val top: Float,
26
+ val right: Float,
27
+ val bottom: Float,
28
+ /** Detector confidence, `0..1`. */
29
+ val score: Float,
30
+ /** Width of the analysed bitmap, the space [left]/[right] are in. */
31
+ val imageWidth: Int,
32
+ /** Height of the analysed bitmap, the space [top]/[bottom] are in. */
33
+ val imageHeight: Int,
34
+ )
35
+
36
+ /**
37
+ * Detects every face in a source photo, and crops to one of them for [PhotoSwap]/
38
+ * [VideoSwap] to use as the identity instead of always "the largest" — see ADR-0012
39
+ * (`docs/06-decisions/0012-source-face-picker-patches-ffjni.md`).
40
+ *
41
+ * [NativePipe.analyseFaces] is the one native symbol in this project that is not
42
+ * upstream's own — a small, tracked patch (`docs/02-upstream.md` "Patches"), because
43
+ * reaching the already-warm pipeline for a detect-only pass needed a new export inside
44
+ * `ffjni.cpp` itself. Everything below this point is our own layer, same as the rest of
45
+ * the app, and needed no further native changes: choosing a face is done by cropping the
46
+ * source bitmap and calling the existing, unmodified `setSource`, not by a new native
47
+ * "pick face N" entry point.
48
+ */
49
+ object SourceFaces {
50
+
51
+ /** Mirrors [PhotoSwap.ModelsMissing] — same required models a swap needs. */
52
+ class ModelsMissing(message: String) : Exception(message)
53
+
54
+ /** How much wider/taller than the detected box to crop, so `setSource`'s own detector
55
+ * sees the same kind of context it would in an uncropped photo instead of a box drawn
56
+ * exactly to the model's own edges — a hair too tight can lose the chin or forehead. */
57
+ private const val MARGIN = 0.4f
58
+
59
+ fun detect(context: Context, sourcePath: String, cfg: SwapConfig): List<DetectedFace> {
60
+ NativePipe.loadError?.let {
61
+ throw IllegalStateException("libffnative.so did not load: $it")
62
+ }
63
+
64
+ val tier = ModelPaths.tier(context)
65
+ val missing = ModelPaths.missing(context, tier)
66
+ if (missing.isNotEmpty()) {
67
+ throw ModelsMissing(
68
+ "Models missing for $tier: ${missing.joinToString(", ")} — call downloadModels() first"
69
+ )
70
+ }
71
+
72
+ val bitmap = decode(sourcePath)
73
+ return PipeGuard.run(context, tier, cfg) {
74
+ val bgr = NativePipe.argbToBgr(pixelsOf(bitmap), bitmap.width, bitmap.height)
75
+ val flat = NativePipe.analyseFaces(bgr, bitmap.width, bitmap.height)
76
+ ?: throw IllegalStateException(NativePipe.lastError())
77
+ (flat.indices step 5).map { i ->
78
+ DetectedFace(
79
+ flat[i],
80
+ flat[i + 1],
81
+ flat[i + 2],
82
+ flat[i + 3],
83
+ flat[i + 4],
84
+ bitmap.width,
85
+ bitmap.height,
86
+ )
87
+ }
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Crops [bitmap] to the box `[left, top, right, bottom]` (as returned by [detect] /
93
+ * [NativePipe.analyseFaces]), expanded by [MARGIN] on every side and clamped to the
94
+ * bitmap's own bounds. The result is what gets handed to [NativePipe.setSource] — since
95
+ * it contains only (or overwhelmingly) the chosen face, `setSource`'s own unmodified
96
+ * "pick the largest face" logic naturally lands on it.
97
+ */
98
+ fun cropToFace(bitmap: Bitmap, box: FloatArray): Bitmap {
99
+ require(box.size == 4) { "box must be [left, top, right, bottom]" }
100
+ val (boxLeft, boxTop, boxRight, boxBottom) = box
101
+ val w = boxRight - boxLeft
102
+ val h = boxBottom - boxTop
103
+ val left = (boxLeft - w * MARGIN).toInt().coerceIn(0, bitmap.width - 1)
104
+ val top = (boxTop - h * MARGIN).toInt().coerceIn(0, bitmap.height - 1)
105
+ val right = (boxRight + w * MARGIN).toInt().coerceIn(left + 1, bitmap.width)
106
+ val bottom = (boxBottom + h * MARGIN).toInt().coerceIn(top + 1, bitmap.height)
107
+ return Bitmap.createBitmap(bitmap, left, top, right - left, bottom - top)
108
+ }
109
+
110
+ // Duplicated from PhotoSwap rather than shared — see that file's note on the same choice.
111
+ /**
112
+ * Capped at [BitmapDecode.SOURCE_MAX] — **the same cap [PhotoSwap] decodes the source
113
+ * with, and that is load-bearing.** The boxes this returns are in the decoded image's
114
+ * pixel coordinates and come back as `SwapOptions.sourceFaceBox`; if the two paths
115
+ * decoded at different scales, a picked face would crop the wrong region.
116
+ */
117
+ private fun decode(path: String): Bitmap =
118
+ BitmapDecode.decode(path, BitmapDecode.SOURCE_MAX)
119
+
120
+ private fun pixelsOf(bitmap: Bitmap): IntArray {
121
+ val pixels = IntArray(bitmap.width * bitmap.height)
122
+ bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
123
+ return pixels
124
+ }
125
+ }
@@ -0,0 +1,31 @@
1
+ package com.facefusion
2
+
3
+ /**
4
+ * JS-tunable knobs for a swap, mirroring `ffpipe::Config`'s defaults for the `hyperswap`
5
+ * swapper ([ModelPaths.SWAPPER]) field for field.
6
+ *
7
+ * [maskPadding] is `[top, right, bottom, left]`, each `0..100`; out-of-range values are
8
+ * clamped again on the C++ side (`initEx` in `ffjni.cpp`), so this is a convenience default,
9
+ * not the only safety net.
10
+ */
11
+ data class SwapConfig(
12
+ val swapperWeight: Float = 0.5f,
13
+ val maskBlur: Float = 0.3f,
14
+ val maskPadding: List<Int> = listOf(0, 0, 0, 0),
15
+ val detectorScore: Float = 0.5f,
16
+ val landmarkerScore: Float = 0.5f,
17
+ val pixelBoost: Int = 1,
18
+ val largestFaceOnly: Boolean = false,
19
+ val faceEnhance: Boolean = false,
20
+ val faceEnhancerBlend: Float = 0.8f,
21
+ ) {
22
+ /**
23
+ * Identifies this config for [PipeGuard]'s "does the pipeline need reloading" check.
24
+ * A plain string rather than relying on `data class` equality because the values that
25
+ * matter are the ones that cross into `initEx`, not Kotlin identity.
26
+ */
27
+ fun signature(): String = listOf(
28
+ swapperWeight, maskBlur, maskPadding.joinToString(","), detectorScore,
29
+ landmarkerScore, pixelBoost, largestFaceOnly, faceEnhance, faceEnhancerBlend,
30
+ ).joinToString("|")
31
+ }
@@ -0,0 +1,88 @@
1
+ package com.facefusion
2
+
3
+ import android.content.Context
4
+ import android.graphics.Bitmap
5
+ import android.graphics.BitmapFactory
6
+ import android.media.MediaMetadataRetriever
7
+ import com.facefusion.mobile.NativePipe
8
+
9
+ /**
10
+ * Detects every face in a *target* — a photo, or the first frame of a video — so a UI can
11
+ * let the user pick which one gets swapped, instead of every face found (or just the
12
+ * largest, via [SwapConfig.largestFaceOnly]). Pass the chosen box back as
13
+ * `targetFaceBox`; [PhotoSwap]/[VideoSwap] crop to it with [FaceCrop] the same way
14
+ * [SourceFaces] crops for `setSource`, so this needed no native change either.
15
+ *
16
+ * Structurally a twin of [SourceFaces.detect] — duplicated rather than shared, same as
17
+ * [VideoSwap]'s own note on why it doesn't share code with [PhotoSwap]: each is a small,
18
+ * self-contained entry point, and the one real difference (this one also has to handle a
19
+ * video target) doesn't belong in the source-only file.
20
+ */
21
+ object TargetFaces {
22
+
23
+ /** Mirrors [PhotoSwap.ModelsMissing] — same required models a swap needs. */
24
+ class ModelsMissing(message: String) : Exception(message)
25
+
26
+ fun detect(context: Context, targetPath: String, cfg: SwapConfig): List<DetectedFace> {
27
+ NativePipe.loadError?.let {
28
+ throw IllegalStateException("libffnative.so did not load: $it")
29
+ }
30
+
31
+ val tier = ModelPaths.tier(context)
32
+ val missing = ModelPaths.missing(context, tier)
33
+ if (missing.isNotEmpty()) {
34
+ throw ModelsMissing(
35
+ "Models missing for $tier: ${missing.joinToString(", ")} — call downloadModels() first"
36
+ )
37
+ }
38
+
39
+ val bitmap = decode(targetPath)
40
+ return PipeGuard.run(context, tier, cfg) {
41
+ val bgr = NativePipe.argbToBgr(pixelsOf(bitmap), bitmap.width, bitmap.height)
42
+ val flat = NativePipe.analyseFaces(bgr, bitmap.width, bitmap.height)
43
+ ?: throw IllegalStateException(NativePipe.lastError())
44
+ (flat.indices step 5).map { i ->
45
+ DetectedFace(
46
+ flat[i],
47
+ flat[i + 1],
48
+ flat[i + 2],
49
+ flat[i + 3],
50
+ flat[i + 4],
51
+ bitmap.width,
52
+ bitmap.height,
53
+ )
54
+ }
55
+ }
56
+ }
57
+
58
+ /** A photo decodes directly; a video falls back to its first frame, already rotated
59
+ * upright by [MediaMetadataRetriever] itself — the same orientation [VideoSwap]'s own
60
+ * loop presents to `processFrame`, which is what makes a box detected here line up with
61
+ * what that loop sees per frame, with no rotation math needed on this side at all. */
62
+ private fun decode(path: String): Bitmap {
63
+ // Capped at [BitmapDecode.TARGET_MAX] -- the same cap [PhotoSwap] decodes a photo
64
+ // target with, which is what keeps a box picked here aligned with what the swap
65
+ // actually crops. `decodeOrNull`, not `decode`, because "not a still image" is how the
66
+ // video branch below is reached.
67
+ BitmapDecode.decodeOrNull(path, BitmapDecode.TARGET_MAX)?.let { return it }
68
+
69
+ // A video frame arrives at the clip's own resolution, uncapped -- and deliberately so:
70
+ // VideoSwap's loop processes frames at that resolution too, so capping here would
71
+ // misalign every box against the frames the swap actually sees. Codec limits keep this
72
+ // bounded in practice where a photo's megapixels are not.
73
+ val retriever = MediaMetadataRetriever()
74
+ try {
75
+ retriever.setDataSource(path)
76
+ return retriever.frameAtTime
77
+ ?: throw IllegalArgumentException("Could not decode image or video frame: $path")
78
+ } finally {
79
+ retriever.release()
80
+ }
81
+ }
82
+
83
+ private fun pixelsOf(bitmap: Bitmap): IntArray {
84
+ val pixels = IntArray(bitmap.width * bitmap.height)
85
+ bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
86
+ return pixels
87
+ }
88
+ }