react-native-nitro-image-pipeline 1.5.0 → 1.7.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/README.md +142 -18
- package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/HybridNitroImagePipeline.kt +72 -16
- package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/PipelineImageLoader.kt +47 -8
- package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/FitGeometry.kt +104 -0
- package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/PipelineRoundedCornersTransformation.kt +103 -0
- package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/ResizeTransformation.kt +69 -16
- package/ios/FitResizeProcessor.swift +127 -0
- package/ios/HybridNitroImagePipeline.swift +88 -24
- package/ios/PipelineImageLoader.swift +108 -18
- package/lib/commonjs/NativePipelineImage.js +20 -4
- package/lib/commonjs/NativePipelineImage.js.map +1 -1
- package/lib/commonjs/PipelineImage.js +13 -1
- package/lib/commonjs/PipelineImage.js.map +1 -1
- package/lib/commonjs/index.js +13 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/resizeForStyle.js +3 -1
- package/lib/commonjs/resizeForStyle.js.map +1 -1
- package/lib/commonjs/resolveImageSource.js +89 -0
- package/lib/commonjs/resolveImageSource.js.map +1 -0
- package/lib/commonjs/useImage.js +14 -6
- package/lib/commonjs/useImage.js.map +1 -1
- package/lib/commonjs/usePipelineImageLoader.js +114 -11
- package/lib/commonjs/usePipelineImageLoader.js.map +1 -1
- package/lib/module/NativePipelineImage.js +20 -4
- package/lib/module/NativePipelineImage.js.map +1 -1
- package/lib/module/PipelineImage.js +13 -1
- package/lib/module/PipelineImage.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/resizeForStyle.js +3 -1
- package/lib/module/resizeForStyle.js.map +1 -1
- package/lib/module/resolveImageSource.js +83 -0
- package/lib/module/resolveImageSource.js.map +1 -0
- package/lib/module/useImage.js +14 -6
- package/lib/module/useImage.js.map +1 -1
- package/lib/module/usePipelineImageLoader.js +115 -12
- package/lib/module/usePipelineImageLoader.js.map +1 -1
- package/lib/typescript/src/NativePipelineImage.d.ts +51 -5
- package/lib/typescript/src/NativePipelineImage.d.ts.map +1 -1
- package/lib/typescript/src/PipelineImage.d.ts +35 -3
- package/lib/typescript/src/PipelineImage.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/resizeForStyle.d.ts +3 -1
- package/lib/typescript/src/resizeForStyle.d.ts.map +1 -1
- package/lib/typescript/src/resolveImageSource.d.ts +56 -0
- package/lib/typescript/src/resolveImageSource.d.ts.map +1 -0
- package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts +95 -8
- package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts.map +1 -1
- package/lib/typescript/src/useImage.d.ts +13 -6
- package/lib/typescript/src/useImage.d.ts.map +1 -1
- package/lib/typescript/src/usePipelineImageLoader.d.ts +7 -3
- package/lib/typescript/src/usePipelineImageLoader.d.ts.map +1 -1
- package/nitrogen/generated/android/NitroImagePipelineOnLoad.cpp +4 -0
- package/nitrogen/generated/android/c++/JFunc_void_double_double.hpp +75 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
- package/nitrogen/generated/android/c++/JHybridNitroImagePipelineSpec.cpp +8 -0
- package/nitrogen/generated/android/c++/JOptions.hpp +2 -0
- package/nitrogen/generated/android/c++/JResizeFit.hpp +64 -0
- package/nitrogen/generated/android/c++/JResizeOptions.hpp +14 -4
- package/nitrogen/generated/android/c++/JViewOptions.hpp +42 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/Func_void_double_double.kt +78 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/Func_void_std__string.kt +78 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ResizeFit.kt +25 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ResizeOptions.kt +14 -4
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ViewOptions.kt +29 -5
- package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.cpp +16 -0
- package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.hpp +107 -0
- package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Umbrella.hpp +4 -0
- package/nitrogen/generated/ios/c++/HybridNitroImagePipelineSpecSwift.hpp +4 -0
- package/nitrogen/generated/ios/swift/Func_void_double_double.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
- package/nitrogen/generated/ios/swift/ResizeFit.swift +48 -0
- package/nitrogen/generated/ios/swift/ResizeOptions.swift +31 -2
- package/nitrogen/generated/ios/swift/ViewOptions.swift +82 -1
- package/nitrogen/generated/shared/c++/ResizeFit.hpp +84 -0
- package/nitrogen/generated/shared/c++/ResizeOptions.hpp +14 -4
- package/nitrogen/generated/shared/c++/ViewOptions.hpp +24 -3
- package/package.json +1 -1
- package/src/NativePipelineImage.tsx +75 -6
- package/src/PipelineImage.tsx +46 -3
- package/src/index.ts +6 -0
- package/src/resizeForStyle.ts +3 -1
- package/src/resolveImageSource.ts +83 -0
- package/src/specs/nitro-image-toolkit.nitro.ts +96 -8
- package/src/useImage.ts +38 -17
- package/src/usePipelineImageLoader.ts +126 -11
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
package com.margelo.nitro.nitroimagepipeline.transform
|
|
2
|
+
|
|
3
|
+
import android.graphics.Bitmap
|
|
4
|
+
import android.graphics.BitmapShader
|
|
5
|
+
import android.graphics.Canvas
|
|
6
|
+
import android.graphics.Paint
|
|
7
|
+
import android.graphics.Path
|
|
8
|
+
import android.graphics.RectF
|
|
9
|
+
import android.graphics.Shader
|
|
10
|
+
import coil3.size.Size
|
|
11
|
+
import coil3.transform.Transformation
|
|
12
|
+
import kotlin.math.min
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Rounds the corners of the input **at its own size**, in pixels of that bitmap — a port of
|
|
16
|
+
* `ios/RoundedCornersProcessor.swift`, including its CSS-style clamping of radii that overlap.
|
|
17
|
+
*
|
|
18
|
+
* Coil's own `RoundedCornersTransformation` scale-fills and crops its input to the *request* size
|
|
19
|
+
* as part of its draw. That is right for the default resize (the request size is the output size)
|
|
20
|
+
* and is why [ResizeTransformation] is skipped for "rounded corners, no blur" there. Under any
|
|
21
|
+
* other fit the request size is only the box — a `contain` or `center` bitmap is smaller — so
|
|
22
|
+
* Coil's transformation would scale the result back up to the box. This one leaves the size alone.
|
|
23
|
+
*/
|
|
24
|
+
class PipelineRoundedCornersTransformation(
|
|
25
|
+
topLeft: Float,
|
|
26
|
+
topRight: Float,
|
|
27
|
+
bottomLeft: Float,
|
|
28
|
+
bottomRight: Float,
|
|
29
|
+
) : Transformation() {
|
|
30
|
+
// Negative radii would make the path undefined; treat them as square.
|
|
31
|
+
private val topLeft = topLeft.coerceAtLeast(0f)
|
|
32
|
+
private val topRight = topRight.coerceAtLeast(0f)
|
|
33
|
+
private val bottomLeft = bottomLeft.coerceAtLeast(0f)
|
|
34
|
+
private val bottomRight = bottomRight.coerceAtLeast(0f)
|
|
35
|
+
|
|
36
|
+
override val cacheKey =
|
|
37
|
+
"${PipelineRoundedCornersTransformation::class.java.name}-$topLeft-$topRight-$bottomLeft-$bottomRight"
|
|
38
|
+
|
|
39
|
+
override suspend fun transform(input: Bitmap, size: Size): Bitmap {
|
|
40
|
+
val softwareInput =
|
|
41
|
+
if (input.config == Bitmap.Config.HARDWARE) input.copy(Bitmap.Config.ARGB_8888, false)
|
|
42
|
+
else input
|
|
43
|
+
val width = softwareInput.width
|
|
44
|
+
val height = softwareInput.height
|
|
45
|
+
if (width <= 0 || height <= 0) return softwareInput
|
|
46
|
+
|
|
47
|
+
// Clamp the way CSS `border-radius` does: if two radii on one edge
|
|
48
|
+
// overlap, all four scale down proportionally until they fit.
|
|
49
|
+
var scale = 1f
|
|
50
|
+
for ((edge, pair) in
|
|
51
|
+
listOf(
|
|
52
|
+
width to topLeft + topRight,
|
|
53
|
+
width to bottomLeft + bottomRight,
|
|
54
|
+
height to topLeft + bottomLeft,
|
|
55
|
+
height to topRight + bottomRight,
|
|
56
|
+
)) {
|
|
57
|
+
if (pair > edge) scale = min(scale, edge / pair)
|
|
58
|
+
}
|
|
59
|
+
val tl = topLeft * scale
|
|
60
|
+
val tr = topRight * scale
|
|
61
|
+
val bl = bottomLeft * scale
|
|
62
|
+
val br = bottomRight * scale
|
|
63
|
+
|
|
64
|
+
val path =
|
|
65
|
+
Path().apply {
|
|
66
|
+
addRoundRect(
|
|
67
|
+
RectF(0f, 0f, width.toFloat(), height.toFloat()),
|
|
68
|
+
// x/y radius pairs, clockwise from the top-left corner.
|
|
69
|
+
floatArrayOf(tl, tl, tr, tr, br, br, bl, bl),
|
|
70
|
+
Path.Direction.CW,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
val paint =
|
|
74
|
+
Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply {
|
|
75
|
+
shader = BitmapShader(softwareInput, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
|
|
76
|
+
}
|
|
77
|
+
// Always ARGB_8888: the corners need an alpha channel whatever the input has.
|
|
78
|
+
val output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
|
79
|
+
Canvas(output).drawPath(path, paint)
|
|
80
|
+
return output
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
override fun equals(other: Any?): Boolean {
|
|
84
|
+
if (this === other) return true
|
|
85
|
+
return other is PipelineRoundedCornersTransformation &&
|
|
86
|
+
topLeft == other.topLeft &&
|
|
87
|
+
topRight == other.topRight &&
|
|
88
|
+
bottomLeft == other.bottomLeft &&
|
|
89
|
+
bottomRight == other.bottomRight
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
override fun hashCode(): Int {
|
|
93
|
+
var result = topLeft.hashCode()
|
|
94
|
+
result = 31 * result + topRight.hashCode()
|
|
95
|
+
result = 31 * result + bottomLeft.hashCode()
|
|
96
|
+
result = 31 * result + bottomRight.hashCode()
|
|
97
|
+
return result
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
override fun toString() =
|
|
101
|
+
"PipelineRoundedCornersTransformation(topLeft=$topLeft, topRight=$topRight, " +
|
|
102
|
+
"bottomLeft=$bottomLeft, bottomRight=$bottomRight)"
|
|
103
|
+
}
|
package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/ResizeTransformation.kt
CHANGED
|
@@ -6,53 +6,106 @@ import android.graphics.Matrix
|
|
|
6
6
|
import android.graphics.Paint
|
|
7
7
|
import coil3.size.Size
|
|
8
8
|
import coil3.transform.Transformation
|
|
9
|
+
import com.margelo.nitro.nitroimagepipeline.ResizeFit
|
|
9
10
|
import kotlin.math.max
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Resizes the input into a [width] × [height] pixel box under [fit] (see [FitGeometry] for the size
|
|
14
|
+
* each mode produces). The default — [ResizeFit.COVER] with [allowUpscale] — scales the input to
|
|
15
|
+
* fill the box exactly and center-crops the overflow (CSS `object-fit: cover`), upscaling smaller
|
|
16
|
+
* sources, on the very same code path (and cache key) it had before `fit` existed, matching Nuke's
|
|
17
|
+
* `ImageProcessors.Resize(contentMode: .aspectFill, crop: true)` on iOS. Every other fit runs
|
|
18
|
+
* through [FitGeometry], matching iOS's `FitResizeProcessor`.
|
|
19
|
+
*
|
|
20
|
+
* It runs before [BlurTransformation] and rounded corners so their pixel units refer to the final
|
|
21
|
+
* bitmap.
|
|
16
22
|
*/
|
|
17
23
|
class ResizeTransformation(
|
|
18
24
|
private val width: Int,
|
|
19
25
|
private val height: Int,
|
|
26
|
+
private val fit: ResizeFit = ResizeFit.COVER,
|
|
27
|
+
private val allowUpscale: Boolean = true,
|
|
20
28
|
) : Transformation() {
|
|
21
29
|
|
|
22
30
|
init {
|
|
23
31
|
require(width > 0 && height > 0) { "width and height must be > 0." }
|
|
24
32
|
}
|
|
25
33
|
|
|
26
|
-
|
|
34
|
+
private val isDefaultFit: Boolean
|
|
35
|
+
get() = fit == ResizeFit.COVER && allowUpscale
|
|
36
|
+
|
|
37
|
+
// The default keeps its original key so results cached by earlier versions stay valid.
|
|
38
|
+
override val cacheKey = buildString {
|
|
39
|
+
append(ResizeTransformation::class.java.name)
|
|
40
|
+
.append('-')
|
|
41
|
+
.append(width)
|
|
42
|
+
.append('-')
|
|
43
|
+
.append(height)
|
|
44
|
+
if (fit != ResizeFit.COVER) append('-').append(fit.name.lowercase())
|
|
45
|
+
if (!allowUpscale) append("-noupscale")
|
|
46
|
+
}
|
|
27
47
|
|
|
28
48
|
override suspend fun transform(input: Bitmap, size: Size): Bitmap {
|
|
29
49
|
val softwareInput =
|
|
30
50
|
if (input.config == Bitmap.Config.HARDWARE) input.copy(Bitmap.Config.ARGB_8888, false)
|
|
31
51
|
else input
|
|
32
|
-
if (
|
|
52
|
+
return if (isDefaultFit) cover(softwareInput) else fitted(softwareInput)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private fun cover(input: Bitmap): Bitmap {
|
|
56
|
+
if (input.width == width && input.height == height) return input
|
|
33
57
|
|
|
34
|
-
val scale = max(width.toFloat() /
|
|
58
|
+
val scale = max(width.toFloat() / input.width, height.toFloat() / input.height)
|
|
35
59
|
val matrix =
|
|
36
60
|
Matrix().apply {
|
|
37
61
|
setScale(scale, scale)
|
|
38
|
-
postTranslate(
|
|
39
|
-
|
|
40
|
-
|
|
62
|
+
postTranslate((width - input.width * scale) / 2f, (height - input.height * scale) / 2f)
|
|
63
|
+
}
|
|
64
|
+
return draw(input, width, height, matrix)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private fun fitted(input: Bitmap): Bitmap {
|
|
68
|
+
val geometry = FitGeometry.of(input.width, input.height, width, height, fit, allowUpscale)
|
|
69
|
+
// `center` of a source that fits the box, or `contain` without upscaling
|
|
70
|
+
// of a small source: the bitmap is already the output.
|
|
71
|
+
if (geometry.isIdentity) return input
|
|
72
|
+
|
|
73
|
+
val matrix =
|
|
74
|
+
Matrix().apply {
|
|
75
|
+
// Scale to the rounded size exactly, so the drawn extent is whole pixels.
|
|
76
|
+
setScale(
|
|
77
|
+
geometry.scaledWidth.toFloat() / input.width,
|
|
78
|
+
geometry.scaledHeight.toFloat() / input.height,
|
|
41
79
|
)
|
|
80
|
+
postTranslate(geometry.originX.toFloat(), geometry.originY.toFloat())
|
|
42
81
|
}
|
|
43
|
-
|
|
44
|
-
|
|
82
|
+
return draw(input, geometry.outputWidth, geometry.outputHeight, matrix)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private fun draw(input: Bitmap, outputWidth: Int, outputHeight: Int, matrix: Matrix): Bitmap {
|
|
86
|
+
val config = input.config ?: Bitmap.Config.ARGB_8888
|
|
87
|
+
val output = Bitmap.createBitmap(outputWidth, outputHeight, config)
|
|
45
88
|
Canvas(output)
|
|
46
|
-
.drawBitmap(
|
|
89
|
+
.drawBitmap(input, matrix, Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG))
|
|
47
90
|
return output
|
|
48
91
|
}
|
|
49
92
|
|
|
50
93
|
override fun equals(other: Any?): Boolean {
|
|
51
94
|
if (this === other) return true
|
|
52
|
-
return other is ResizeTransformation &&
|
|
95
|
+
return other is ResizeTransformation &&
|
|
96
|
+
width == other.width &&
|
|
97
|
+
height == other.height &&
|
|
98
|
+
fit == other.fit &&
|
|
99
|
+
allowUpscale == other.allowUpscale
|
|
53
100
|
}
|
|
54
101
|
|
|
55
|
-
override fun hashCode(): Int
|
|
102
|
+
override fun hashCode(): Int {
|
|
103
|
+
var result = 31 * width + height
|
|
104
|
+
result = 31 * result + fit.hashCode()
|
|
105
|
+
result = 31 * result + allowUpscale.hashCode()
|
|
106
|
+
return result
|
|
107
|
+
}
|
|
56
108
|
|
|
57
|
-
override fun toString() =
|
|
109
|
+
override fun toString() =
|
|
110
|
+
"ResizeTransformation(width=$width, height=$height, fit=$fit, allowUpscale=$allowUpscale)"
|
|
58
111
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
//
|
|
2
|
+
// FitResizeProcessor.swift
|
|
3
|
+
// NitroImagePipeline
|
|
4
|
+
//
|
|
5
|
+
// Resizes into the `ResizeOptions` box under any `fit` other than the
|
|
6
|
+
// default. The default — `cover` with upscaling — stays on Nuke's own
|
|
7
|
+
// `ImageProcessors.Resize(.aspectFill, crop: true)`, so its output bytes and
|
|
8
|
+
// cache keys are exactly what they were before `fit` existed.
|
|
9
|
+
//
|
|
10
|
+
|
|
11
|
+
import CoreGraphics
|
|
12
|
+
import Foundation
|
|
13
|
+
import Nuke
|
|
14
|
+
import UIKit
|
|
15
|
+
|
|
16
|
+
/// The resize geometry both platforms implement — Android's port is
|
|
17
|
+
/// `transform/FitGeometry.kt`, and `example/__tests__/fit-geometry.ts` is the
|
|
18
|
+
/// copy the harness checks both against. With a `w` × `h` source and a
|
|
19
|
+
/// `W` × `H` box:
|
|
20
|
+
///
|
|
21
|
+
/// ```
|
|
22
|
+
/// cover : sx = sy = max(W/w, H/h)
|
|
23
|
+
/// contain : sx = sy = min(W/w, H/h)
|
|
24
|
+
/// stretch : sx = W/w ; sy = H/h
|
|
25
|
+
/// center : sx = sy = 1
|
|
26
|
+
/// if !allowUpscale: sx = min(sx, 1) ; sy = min(sy, 1)
|
|
27
|
+
/// scaled = (round(w·sx), round(h·sy))
|
|
28
|
+
/// output = (min(W, scaled.w), min(H, scaled.h))
|
|
29
|
+
/// origin = trunc((output − scaled) / 2) — a centred, whole-pixel crop
|
|
30
|
+
/// ```
|
|
31
|
+
///
|
|
32
|
+
/// The source is drawn scaled to `scaled` at `origin` on an `output`-sized
|
|
33
|
+
/// canvas. Rounding is half-away-from-zero (`.rounded()` here,
|
|
34
|
+
/// `roundToInt()` in Kotlin — identical for the positive values involved).
|
|
35
|
+
struct FitGeometry: Equatable {
|
|
36
|
+
let source: CGSize
|
|
37
|
+
let scaled: CGSize
|
|
38
|
+
let output: CGSize
|
|
39
|
+
let origin: CGPoint
|
|
40
|
+
|
|
41
|
+
init(source: CGSize, box: CGSize, fit: ResizeFit, allowUpscale: Bool) {
|
|
42
|
+
let boxWidth = box.width.rounded()
|
|
43
|
+
let boxHeight = box.height.rounded()
|
|
44
|
+
var scaleX: CGFloat
|
|
45
|
+
var scaleY: CGFloat
|
|
46
|
+
switch fit {
|
|
47
|
+
case .cover:
|
|
48
|
+
let scale = max(boxWidth / source.width, boxHeight / source.height)
|
|
49
|
+
scaleX = scale
|
|
50
|
+
scaleY = scale
|
|
51
|
+
case .contain:
|
|
52
|
+
let scale = min(boxWidth / source.width, boxHeight / source.height)
|
|
53
|
+
scaleX = scale
|
|
54
|
+
scaleY = scale
|
|
55
|
+
case .stretch:
|
|
56
|
+
scaleX = boxWidth / source.width
|
|
57
|
+
scaleY = boxHeight / source.height
|
|
58
|
+
case .center:
|
|
59
|
+
scaleX = 1
|
|
60
|
+
scaleY = 1
|
|
61
|
+
}
|
|
62
|
+
if !allowUpscale {
|
|
63
|
+
scaleX = min(scaleX, 1)
|
|
64
|
+
scaleY = min(scaleY, 1)
|
|
65
|
+
}
|
|
66
|
+
let scaledWidth = max((source.width * scaleX).rounded(), 1)
|
|
67
|
+
let scaledHeight = max((source.height * scaleY).rounded(), 1)
|
|
68
|
+
let outputWidth = min(boxWidth, scaledWidth)
|
|
69
|
+
let outputHeight = min(boxHeight, scaledHeight)
|
|
70
|
+
self.source = source
|
|
71
|
+
scaled = CGSize(width: scaledWidth, height: scaledHeight)
|
|
72
|
+
output = CGSize(width: outputWidth, height: outputHeight)
|
|
73
|
+
origin = CGPoint(
|
|
74
|
+
x: ((outputWidth - scaledWidth) / 2).rounded(.towardZero),
|
|
75
|
+
y: ((outputHeight - scaledHeight) / 2).rounded(.towardZero)
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Whether the source is already the output — nothing to draw. Both
|
|
80
|
+
/// sizes are compared with the *source*: `scaled == output` alone also
|
|
81
|
+
/// holds for every plain downscale (nothing is cropped), which is not an
|
|
82
|
+
/// identity.
|
|
83
|
+
var isIdentity: Bool { scaled == source && output == source }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// Nuke processor for the non-default fits. Runs before the blur and corner
|
|
87
|
+
/// processors, like `ImageProcessors.Resize`, so their pixel units refer to
|
|
88
|
+
/// the produced bitmap. Sizes are in points of the image, which the pipeline
|
|
89
|
+
/// decodes at scale 1 (1 pt = 1 px) — the same convention as
|
|
90
|
+
/// `RoundedCornersProcessor`.
|
|
91
|
+
struct FitResizeProcessor: ImageProcessing {
|
|
92
|
+
let width: Double
|
|
93
|
+
let height: Double
|
|
94
|
+
let fit: ResizeFit
|
|
95
|
+
let allowUpscale: Bool
|
|
96
|
+
|
|
97
|
+
var identifier: String {
|
|
98
|
+
"com.nitroimagepipeline.resize?w=\(width),h=\(height),fit=\(fit.stringValue),upscale=\(allowUpscale)"
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
var hashableIdentifier: AnyHashable { identifier }
|
|
102
|
+
|
|
103
|
+
func process(_ image: PlatformImage) -> PlatformImage? {
|
|
104
|
+
let source = image.size
|
|
105
|
+
guard source.width > 0, source.height > 0 else { return image }
|
|
106
|
+
let geometry = FitGeometry(
|
|
107
|
+
source: source,
|
|
108
|
+
box: CGSize(width: width, height: height),
|
|
109
|
+
fit: fit,
|
|
110
|
+
allowUpscale: allowUpscale
|
|
111
|
+
)
|
|
112
|
+
// `center` of a source that fits the box, or `contain` without
|
|
113
|
+
// upscaling of a small source: the bitmap is already the output.
|
|
114
|
+
if geometry.isIdentity { return image }
|
|
115
|
+
|
|
116
|
+
let format = UIGraphicsImageRendererFormat()
|
|
117
|
+
format.scale = image.scale
|
|
118
|
+
format.opaque = false
|
|
119
|
+
// See RoundedCornersProcessor: the extended range doubles the bytes
|
|
120
|
+
// of an 8-bit source for nothing.
|
|
121
|
+
format.preferredRange = .standard
|
|
122
|
+
|
|
123
|
+
return UIGraphicsImageRenderer(size: geometry.output, format: format).image { _ in
|
|
124
|
+
image.draw(in: CGRect(origin: geometry.origin, size: geometry.scaled))
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -138,13 +138,43 @@ class HybridNitroImagePipeline: HybridNitroImagePipelineSpec {
|
|
|
138
138
|
private var pipeline: ImagePipeline { Self.sharedPipeline }
|
|
139
139
|
private var prefetcher: ImagePrefetcher { Self.sharedPrefetcher }
|
|
140
140
|
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
/// The `URL` for a `url` string as the JS API accepts it: a URL with a
|
|
142
|
+
/// scheme (`https://`, `file://`, …), or a plain absolute file-system
|
|
143
|
+
/// path — the form react-native-nitro-image's `saveToTemporaryFileAsync`
|
|
144
|
+
/// returns — which becomes a `file://` URL.
|
|
145
|
+
static func url(from string: String) -> URL? {
|
|
146
|
+
if string.hasPrefix("/") {
|
|
147
|
+
return URL(fileURLWithPath: string)
|
|
148
|
+
}
|
|
149
|
+
return URL(string: string)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// Whether `url` is fetched over the network, as opposed to read from the
|
|
153
|
+
/// file system (`file://`, incl. `require()`d assets in release builds)
|
|
154
|
+
/// or decoded inline (`data:`).
|
|
155
|
+
static func isNetworkURL(_ url: URL) -> Bool {
|
|
156
|
+
switch url.scheme?.lowercased() {
|
|
157
|
+
case "http", "https": true
|
|
158
|
+
default: false
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private static func cacheOptions(for cache: CacheOption?, url: URL) -> ImageRequest.Options {
|
|
163
|
+
var options: ImageRequest.Options = switch cache {
|
|
143
164
|
case .memory: [.disableDiskCache]
|
|
144
165
|
case .disk: [.disableMemoryCache]
|
|
145
166
|
case .none?: [.disableDiskCache, .disableMemoryCache]
|
|
146
167
|
default: []
|
|
147
168
|
}
|
|
169
|
+
// A local source has nothing to gain from the disk cache: the
|
|
170
|
+
// pipeline's `.storeAll` policy would copy the file's bytes into the
|
|
171
|
+
// data cache next to the original. Keep local images in memory only —
|
|
172
|
+
// Coil does the same on Android, where only network fetches are
|
|
173
|
+
// written to disk — so `cache: 'disk'` on a local URL means no cache.
|
|
174
|
+
if !isNetworkURL(url) {
|
|
175
|
+
options.insert(.disableDiskCache)
|
|
176
|
+
}
|
|
177
|
+
return options
|
|
148
178
|
}
|
|
149
179
|
|
|
150
180
|
/// The processor that bakes `cornerRadius` into the bitmap, or `nil` when
|
|
@@ -168,26 +198,46 @@ class HybridNitroImagePipeline: HybridNitroImagePipelineSpec {
|
|
|
168
198
|
}
|
|
169
199
|
}
|
|
170
200
|
|
|
171
|
-
/// The
|
|
172
|
-
private static func
|
|
201
|
+
/// The requested resize, or `nil` when there is no (valid) one.
|
|
202
|
+
private static func requestedResize(for options: Options?) -> ResizeOptions? {
|
|
173
203
|
guard let resize = options?.resize, resize.width > 0, resize.height > 0 else {
|
|
174
204
|
return nil
|
|
175
205
|
}
|
|
176
|
-
return
|
|
206
|
+
return resize
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private static func fit(of resize: ResizeOptions) -> ResizeFit { resize.fit ?? .cover }
|
|
210
|
+
|
|
211
|
+
private static func allowsUpscale(_ resize: ResizeOptions) -> Bool { resize.allowUpscale ?? true }
|
|
212
|
+
|
|
213
|
+
/// The resize as it behaved before `fit` existed — aspect-fill, crop,
|
|
214
|
+
/// upscale — which keeps Nuke's own processor (and its cache keys).
|
|
215
|
+
private static func isDefaultFit(_ resize: ResizeOptions) -> Bool {
|
|
216
|
+
fit(of: resize) == .cover && allowsUpscale(resize)
|
|
177
217
|
}
|
|
178
218
|
|
|
179
219
|
private static func processors(for options: Options?) -> [any ImageProcessing] {
|
|
180
220
|
var processors: [any ImageProcessing] = []
|
|
181
221
|
// Resize first: blur sigma and corner radii are defined in pixels
|
|
182
222
|
// of the bitmap they run on, so they must see the final size.
|
|
183
|
-
if let
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
223
|
+
if let resize = requestedResize(for: options) {
|
|
224
|
+
let size = CGSize(width: resize.width, height: resize.height)
|
|
225
|
+
if isDefaultFit(resize) {
|
|
226
|
+
processors.append(ImageProcessors.Resize(
|
|
227
|
+
size: size,
|
|
228
|
+
unit: .pixels,
|
|
229
|
+
contentMode: .aspectFill,
|
|
230
|
+
crop: true,
|
|
231
|
+
upscale: true
|
|
232
|
+
))
|
|
233
|
+
} else {
|
|
234
|
+
processors.append(FitResizeProcessor(
|
|
235
|
+
width: size.width,
|
|
236
|
+
height: size.height,
|
|
237
|
+
fit: fit(of: resize),
|
|
238
|
+
allowUpscale: allowsUpscale(resize)
|
|
239
|
+
))
|
|
240
|
+
}
|
|
191
241
|
}
|
|
192
242
|
if let blur = options?.blur, blur > 0 {
|
|
193
243
|
processors.append(GaussianBlurProcessor(sigma: blur))
|
|
@@ -205,19 +255,25 @@ class HybridNitroImagePipeline: HybridNitroImagePipelineSpec {
|
|
|
205
255
|
var imgRequest = ImageRequest(
|
|
206
256
|
url: url,
|
|
207
257
|
processors: processors(for: options),
|
|
208
|
-
options: cacheOptions(for: options?.cache)
|
|
258
|
+
options: cacheOptions(for: options?.cache, url: url)
|
|
209
259
|
)
|
|
210
260
|
// With a target size known, decode near it (aspect-fill, so the
|
|
211
261
|
// decoded image always covers the target) instead of at full
|
|
212
262
|
// resolution — a 48 MP photo displayed as a 300 pt card would
|
|
213
263
|
// otherwise decompress to ~190 MB before Resize shrinks it.
|
|
214
264
|
// Matches Android, where the request's size() drives subsampling;
|
|
215
|
-
// the exact size and crop still come from the
|
|
216
|
-
|
|
265
|
+
// the exact size and crop still come from the resize processor.
|
|
266
|
+
// `contain` decodes aspect-fit: its output follows the smaller scale,
|
|
267
|
+
// and an aspect-fill decode of a wide panorama into a small box would
|
|
268
|
+
// be near the source's full width before the processor shrinks it.
|
|
269
|
+
// Android's Scale.FIT subsampling bounds the decode the same way.
|
|
270
|
+
// `center` never scales, so it must see the source's own pixels: a
|
|
271
|
+
// subsampled decode would crop the wrong ones.
|
|
272
|
+
if let resize = requestedResize(for: options), fit(of: resize) != .center {
|
|
217
273
|
imgRequest.thumbnail = ImageRequest.ThumbnailOptions(
|
|
218
|
-
size:
|
|
274
|
+
size: CGSize(width: resize.width, height: resize.height),
|
|
219
275
|
unit: .pixels,
|
|
220
|
-
contentMode: .aspectFill
|
|
276
|
+
contentMode: fit(of: resize) == .contain ? .aspectFit : .aspectFill
|
|
221
277
|
)
|
|
222
278
|
}
|
|
223
279
|
return imgRequest
|
|
@@ -225,7 +281,7 @@ class HybridNitroImagePipeline: HybridNitroImagePipelineSpec {
|
|
|
225
281
|
|
|
226
282
|
func loadImage(url: String, options: Options?) throws -> Promise<any HybridImageSpec> {
|
|
227
283
|
return Promise.async {
|
|
228
|
-
guard let imageUrl =
|
|
284
|
+
guard let imageUrl = Self.url(from: url) else {
|
|
229
285
|
throw RuntimeError.error(withMessage: "Invalid URL: \(url)")
|
|
230
286
|
}
|
|
231
287
|
|
|
@@ -243,21 +299,29 @@ class HybridNitroImagePipeline: HybridNitroImagePipelineSpec {
|
|
|
243
299
|
|
|
244
300
|
func preLoadImage(url: String) throws -> Promise<Void> {
|
|
245
301
|
return Promise.async {
|
|
246
|
-
guard let imageUrl =
|
|
302
|
+
guard let imageUrl = Self.url(from: url) else {
|
|
247
303
|
throw RuntimeError.error(withMessage: "Invalid URL: \(url)")
|
|
248
304
|
}
|
|
249
|
-
|
|
250
|
-
self.prefetcher.startPrefetching(with: [imageUrl])
|
|
305
|
+
self.prefetch([imageUrl])
|
|
251
306
|
}
|
|
252
307
|
}
|
|
253
308
|
|
|
254
309
|
func preLoadImages(urls: [String]) throws -> Promise<Void> {
|
|
255
310
|
return Promise.async {
|
|
256
|
-
|
|
257
|
-
self.prefetcher.startPrefetching(with: imageUrls)
|
|
311
|
+
self.prefetch(urls.compactMap { Self.url(from: $0) })
|
|
258
312
|
}
|
|
259
313
|
}
|
|
260
314
|
|
|
315
|
+
/// Prefetching warms the disk cache with a download; a local source has no
|
|
316
|
+
/// download to warm it with, so those are a no-op (as on Android, where
|
|
317
|
+
/// Coil never writes non-network sources to disk) rather than a copy of
|
|
318
|
+
/// the file into the data cache.
|
|
319
|
+
private func prefetch(_ urls: [URL]) {
|
|
320
|
+
let networkUrls = urls.filter(Self.isNetworkURL)
|
|
321
|
+
guard !networkUrls.isEmpty else { return }
|
|
322
|
+
prefetcher.startPrefetching(with: networkUrls)
|
|
323
|
+
}
|
|
324
|
+
|
|
261
325
|
func setMemoryCacheLimit(bytes: Double) throws {
|
|
262
326
|
guard bytes >= 0, bytes.isFinite else {
|
|
263
327
|
throw RuntimeError.error(
|