react-native-nitro-image-pipeline 0.3.5 → 1.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 (35) hide show
  1. package/README.md +66 -6
  2. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/HybridNitroImagePipeline.kt +24 -6
  3. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/BlurTransformation.kt +50 -24
  4. package/ios/GaussianBlur.swift +135 -0
  5. package/ios/GaussianBlurProcessor.swift +41 -0
  6. package/ios/HybridNitroImagePipeline.swift +15 -20
  7. package/ios/RoundedCornersProcessor.swift +118 -0
  8. package/lib/commonjs/index.js +18 -2
  9. package/lib/commonjs/index.js.map +1 -1
  10. package/lib/module/index.js +18 -2
  11. package/lib/module/index.js.map +1 -1
  12. package/lib/typescript/src/index.d.ts +13 -3
  13. package/lib/typescript/src/index.d.ts.map +1 -1
  14. package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts +32 -1
  15. package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts.map +1 -1
  16. package/nitrogen/generated/android/NitroImagePipeline+autolinking.cmake +1 -0
  17. package/nitrogen/generated/android/c++/JCornerRadii.hpp +69 -0
  18. package/nitrogen/generated/android/c++/JHybridNitroImagePipelineSpec.cpp +6 -0
  19. package/nitrogen/generated/android/c++/JOptions.hpp +9 -5
  20. package/nitrogen/generated/android/c++/JVariant_Double_CornerRadii.cpp +26 -0
  21. package/nitrogen/generated/android/c++/JVariant_Double_CornerRadii.hpp +70 -0
  22. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/CornerRadii.kt +66 -0
  23. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/Options.kt +2 -2
  24. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/Variant_Double_CornerRadii.kt +62 -0
  25. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.hpp +48 -0
  26. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Umbrella.hpp +4 -0
  27. package/nitrogen/generated/ios/c++/HybridNitroImagePipelineSpecSwift.hpp +4 -0
  28. package/nitrogen/generated/ios/swift/CornerRadii.swift +96 -0
  29. package/nitrogen/generated/ios/swift/Options.swift +27 -8
  30. package/nitrogen/generated/ios/swift/Variant_Double_CornerRadii.swift +30 -0
  31. package/nitrogen/generated/shared/c++/CornerRadii.hpp +95 -0
  32. package/nitrogen/generated/shared/c++/Options.hpp +9 -5
  33. package/package.json +21 -13
  34. package/src/index.ts +38 -4
  35. package/src/specs/nitro-image-toolkit.nitro.ts +38 -3
package/README.md CHANGED
@@ -10,7 +10,7 @@ A high-performance image loading, caching, and processing library for React Nati
10
10
 
11
11
  - Load remote images with built-in memory and disk caching
12
12
  - Prefetch single or multiple images in the background
13
- - Apply Gaussian blur and rounded corners at load time
13
+ - Apply Gaussian blur and rounded corners (uniform or per-corner) at load time
14
14
  - Apply Gaussian blur to already-loaded images
15
15
  - Clear the image cache on demand
16
16
  - `useImage` hook for declarative image loading in components
@@ -48,7 +48,7 @@ import { useImage } from 'react-native-nitro-image-pipeline';
48
48
  function MyComponent() {
49
49
  const { image, error } = useImage({
50
50
  url: 'https://example.com/photo.jpg',
51
- blur: 4,
51
+ blur: 4, // Gaussian sigma in source pixels — same result on iOS and Android
52
52
  cornerRadius: 12,
53
53
  });
54
54
 
@@ -67,11 +67,17 @@ import { NitroImagePipeline } from 'react-native-nitro-image-pipeline';
67
67
 
68
68
  // Load an image with options
69
69
  const image = await NitroImagePipeline.loadImage('https://example.com/photo.jpg', {
70
- blur: 4,
70
+ blur: 4, // Gaussian sigma in source pixels — see "Blur units"
71
71
  cornerRadius: 12,
72
72
  cache: 'disk',
73
73
  });
74
74
 
75
+ // Per-corner radii — e.g. a "ticket" shape with larger bottom corners.
76
+ // The rounding is baked into the bitmap, so no view-layer masking is needed.
77
+ const ticket = await NitroImagePipeline.loadImage('https://example.com/photo.jpg', {
78
+ cornerRadius: { topLeft: 24, topRight: 24, bottomLeft: 48, bottomRight: 48 },
79
+ });
80
+
75
81
  // Prefetch a single image
76
82
  await NitroImagePipeline.preLoadImage('https://example.com/photo.jpg');
77
83
 
@@ -96,8 +102,8 @@ Loads an image from a URL and returns a `Promise<Image>`.
96
102
 
97
103
  | Option | Type | Default | Description |
98
104
  |---|---|---|---|
99
- | `blur` | `number` | `0` | Gaussian blur radius applied at load time |
100
- | `cornerRadius` | `number` | `0` | Corner radius in points |
105
+ | `blur` | `number` | `0` | Gaussian blur strength applied at load time — see [Blur units](#blur-units) |
106
+ | `cornerRadius` | `number \| CornerRadii` | `0` | Corner radius in points — a single number for all four corners, or `{ topLeft?, topRight?, bottomLeft?, bottomRight? }` for independent per-corner radii (omitted corners stay square) |
101
107
  | `cache` | `'memory' \| 'disk' \| 'none'` | platform default | Caching strategy |
102
108
 
103
109
  ### `preLoadImage(url)`
@@ -110,12 +116,66 @@ Prefetches multiple images into the cache. Returns `Promise<void>`.
110
116
 
111
117
  ### `gaussianBlur(image, radius)`
112
118
 
113
- Applies a Gaussian blur to an existing `Image` object. Returns `Promise<Image>`.
119
+ Applies a Gaussian blur to an existing `Image` object. Returns `Promise<Image>`. `radius` uses the
120
+ same unit as the `blur` option — see [Blur units](#blur-units).
121
+
122
+ ### Blur units
123
+
124
+ `blur` (and `gaussianBlur`'s `radius`) is the **standard deviation (sigma) of the Gaussian, in
125
+ source-image pixels**. The same value on the same source file produces the same result on iOS and
126
+ Android — the platforms are calibrated against each other rather than each exposing its native
127
+ backend's own idea of "radius".
128
+
129
+ ```ts
130
+ // ~11px of blur on both platforms, whatever the device
131
+ await NitroImagePipeline.loadImage(url, { blur: 11 });
132
+ ```
133
+
134
+ Two things follow from the unit being *source* pixels:
135
+
136
+ - Blur is measured against the image's own resolution, not the size it is displayed at. A 4000px
137
+ photo at `blur: 11` looks subtler than a 400px thumbnail at `blur: 11`. To keep a feed visually
138
+ consistent, scale the value with the source width.
139
+ - Coming from React Native's `<Image blurRadius={n} />`? That halves its input internally, so
140
+ `blurRadius={n}` ≈ `blur: n / 2`. RN's value is also density-scaled on Android and not on iOS,
141
+ which is why the two never quite matched there.
142
+
143
+ Values below ~1 are smaller than the smallest kernel either backend can build and are effectively a
144
+ no-op. There is no upper bound.
145
+
146
+ Implementation: iOS runs three Accelerate box-convolution passes sized to hit the requested sigma
147
+ (the standard three-box Gaussian approximation, accurate to a few percent); Android uses
148
+ RenderScript's true Gaussian, downscaling first when sigma exceeds the single-pass ceiling of
149
+ ~10.6px and compensating the radius so the result is unchanged. Both clamp at the edges, so blurred
150
+ images keep their borders instead of fading out.
114
151
 
115
152
  ### `clearCache()`
116
153
 
117
154
  Removes all cached images from memory and disk. Returns `Promise<void>` that resolves once both caches are cleared.
118
155
 
156
+ ## Upgrading from 0.3.x
157
+
158
+ `blur` and `gaussianBlur(image, radius)` changed meaning in 1.0. They used to hand the number
159
+ straight to each platform's native blur, and the two platforms disagreed about what it meant; now
160
+ both read it as a Gaussian sigma in source-image pixels (see [Blur units](#blur-units)).
161
+
162
+ | | what `blur: n` did in 0.3.x | what it does in 1.0 |
163
+ |---|---|---|
164
+ | iOS | fed `n` to `CIGaussianBlur(inputRadius:)`, measured at `sigma ≈ 1.18 × n` | `sigma = n` |
165
+ | Android | RenderScript on a copy downscaled to 512px, so strength scaled with the source resolution: `sigma ≈ (0.4n + 0.6) × max(w, h) / 512` | `sigma = n`, resolution-independent |
166
+
167
+ To keep the look you had:
168
+
169
+ - **iOS:** multiply your old value by ~1.18 (`blur: 10` → `blur: 12`).
170
+ - **Android:** there is no single factor — the old result depended on the source image's
171
+ resolution. Re-tune against iOS, which the two platforms now agree with.
172
+
173
+ Also changed:
174
+
175
+ - `blur` above 25 used to reject the promise on Android. Sigma is now unbounded.
176
+ - Fractional values used to be truncated to whole numbers on iOS. They are honoured now.
177
+ - Blurred images used to fade out at the borders on iOS. Edges are clamped on both platforms now.
178
+
119
179
  ## Credits
120
180
 
121
181
  Bootstrapped with [create-nitro-module](https://github.com/patrickkabwe/create-nitro-module).
@@ -40,10 +40,23 @@ class HybridNitroImagePipeline : HybridNitroImagePipelineSpec() {
40
40
 
41
41
  override fun loadImage(url: String, options: Options?): Promise<HybridImageSpec> = Promise.async {
42
42
  val blur = options?.blur?.toFloat() ?: 0f
43
- val cornerRadius = options?.cornerRadius?.toFloat() ?: 0f
44
43
  val transformations = buildList {
45
44
  if (blur > 0f) add(BlurTransformation(context, blur))
46
- if (cornerRadius > 0f) add(RoundedCornersTransformation(cornerRadius))
45
+ options?.cornerRadius?.match(
46
+ first = { radius ->
47
+ if (radius > 0.0) add(RoundedCornersTransformation(radius.toFloat()))
48
+ },
49
+ second = { radii ->
50
+ // RoundedCornersTransformation rejects negative radii; treat them as square.
51
+ val topLeft = (radii.topLeft?.toFloat() ?: 0f).coerceAtLeast(0f)
52
+ val topRight = (radii.topRight?.toFloat() ?: 0f).coerceAtLeast(0f)
53
+ val bottomLeft = (radii.bottomLeft?.toFloat() ?: 0f).coerceAtLeast(0f)
54
+ val bottomRight = (radii.bottomRight?.toFloat() ?: 0f).coerceAtLeast(0f)
55
+ if (topLeft > 0f || topRight > 0f || bottomLeft > 0f || bottomRight > 0f) {
56
+ add(RoundedCornersTransformation(topLeft, topRight, bottomLeft, bottomRight))
57
+ }
58
+ },
59
+ )
47
60
  }
48
61
  val request =
49
62
  ImageRequest.Builder(context)
@@ -105,10 +118,15 @@ class HybridNitroImagePipeline : HybridNitroImagePipelineSpec() {
105
118
  override fun gaussianBlur(image: HybridImageSpec, radius: Double): Promise<HybridImageSpec> =
106
119
  Promise.async {
107
120
  val hybridImage = image as? HybridImage ?: throw Error("Image is not a HybridImage")
108
- val clampedRadius = radius.toFloat().coerceIn(0.01f, 25f)
109
- val blurred =
110
- BlurTransformation(context, clampedRadius).transform(hybridImage.bitmap, Size.ORIGINAL)
111
- HybridImage(blurred)
121
+ // `radius` is a Gaussian sigma in source-image pixels — see BlurTransformation.
122
+ val sigma = radius.toFloat()
123
+ if (sigma <= 0f) {
124
+ hybridImage
125
+ } else {
126
+ val blurred =
127
+ BlurTransformation(context, sigma).transform(hybridImage.bitmap, Size.ORIGINAL)
128
+ HybridImage(blurred)
129
+ }
112
130
  }
113
131
 
114
132
  override fun clearCache(): Promise<Unit> = Promise.async {
@@ -13,33 +13,57 @@ import androidx.core.graphics.applyCanvas
13
13
  import androidx.core.graphics.scale
14
14
  import coil3.size.Size
15
15
  import coil3.transform.Transformation
16
+ import kotlin.math.ceil
17
+ import kotlin.math.max
16
18
 
19
+ /**
20
+ * Gaussian blur of standard deviation [sigma], measured in *source image pixels*.
21
+ *
22
+ * The unit is the point of this class: the same sigma on the same source file produces the same
23
+ * result here and on iOS. It is deliberately not a "radius" — RenderScript, CoreImage and React
24
+ * Native's `blurRadius` each define radius differently, which is why blurs never matched across
25
+ * platforms.
26
+ *
27
+ * For reference, React Native's `<Image blurRadius={n} />` halves its input before convolving, so
28
+ * `blurRadius={n}` is roughly `sigma = n / 2`.
29
+ */
17
30
  class BlurTransformation(
18
31
  private val context: Context,
19
- private val radius: Float = 10f,
20
- private val sampling: Float = 1f,
32
+ private val sigma: Float,
21
33
  ) : Transformation() {
22
34
 
23
35
  init {
24
- require(radius in 0.01..25.0) { "radius must be in (0, 25]." }
25
- require(sampling >= 1f) { "sampling must be >= 1." }
36
+ require(sigma > 0f && sigma.isFinite()) { "sigma must be > 0." }
26
37
  }
27
38
 
28
- override val cacheKey = "${BlurTransformation::class.java.name}-$radius-$sampling"
39
+ override val cacheKey = "${BlurTransformation::class.java.name}-$sigma"
29
40
 
30
41
  override suspend fun transform(input: Bitmap, size: Size): Bitmap {
42
+ // ScriptIntrinsicBlur's `radius` maps to a Gaussian sigma of 0.4 * radius + 0.6, and radius is
43
+ // capped at 25 — so a single pass tops out at sigma ~10.6px. Larger blurs are reached by
44
+ // blurring a downscaled copy: scaling down by `s`, blurring with sigma/s and scaling back up
45
+ // multiplies the effective sigma by `s`. A blur is a low-pass filter, so the detail the
46
+ // downscale drops is detail the blur was going to remove anyway. Downscale only as far as the
47
+ // sigma ceiling forces, which keeps small blurs pixel-exact.
48
+ val sampling = max(1f, sigma / MAX_SIGMA)
49
+ val scaledWidth = ceil(input.width / sampling).toInt().coerceAtLeast(1)
50
+ val scaledHeight = ceil(input.height / sampling).toInt().coerceAtLeast(1)
51
+ // Rounding to whole pixels shifts the scale slightly; derive the sigma from the scale actually
52
+ // applied rather than the requested one.
53
+ val scaleX = scaledWidth.toFloat() / input.width
54
+ val scaleY = scaledHeight.toFloat() / input.height
55
+ val scaledSigma = sigma * max(scaleX, scaleY)
56
+ val radius = ((scaledSigma - SIGMA_INTERCEPT) / SIGMA_SLOPE).coerceIn(MIN_RADIUS, MAX_RADIUS)
57
+
31
58
  val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
32
- // Auto-scale so the blurred image is at most 512px on the longest side.
33
- // Blur is a low-pass filter so blurring at lower resolution is identical in quality.
34
- val autoSampling = (maxOf(input.width, input.height) / 512f).coerceAtLeast(sampling)
35
- val scaledWidth = (input.width / autoSampling).toInt().coerceAtLeast(1)
36
- val scaledHeight = (input.height / autoSampling).toInt().coerceAtLeast(1)
37
- val softwareInput = if (input.config == Bitmap.Config.HARDWARE) input.copy(Bitmap.Config.ARGB_8888, false) else input
59
+ val softwareInput =
60
+ if (input.config == Bitmap.Config.HARDWARE) input.copy(Bitmap.Config.ARGB_8888, false)
61
+ else input
38
62
  val softwareConfig = softwareInput.config ?: Bitmap.Config.ARGB_8888
39
63
 
40
64
  val output = Bitmap.createBitmap(scaledWidth, scaledHeight, softwareConfig)
41
65
  output.applyCanvas {
42
- scale(1 / autoSampling, 1 / autoSampling)
66
+ scale(scaleX, scaleY)
43
67
  drawBitmap(softwareInput, 0f, 0f, paint)
44
68
  }
45
69
 
@@ -69,23 +93,25 @@ class BlurTransformation(
69
93
  blur?.destroy()
70
94
  }
71
95
 
72
- return output.scale(input.width, input.height)
96
+ return if (scaledWidth == input.width && scaledHeight == input.height) output
97
+ else output.scale(input.width, input.height)
73
98
  }
74
99
 
75
100
  override fun equals(other: Any?): Boolean {
76
101
  if (this === other) return true
77
- return other is BlurTransformation &&
78
- context == other.context &&
79
- radius == other.radius &&
80
- sampling == other.sampling
102
+ return other is BlurTransformation && context == other.context && sigma == other.sigma
81
103
  }
82
104
 
83
- override fun hashCode(): Int {
84
- var result = context.hashCode()
85
- result = 31 * result + radius.hashCode()
86
- result = 31 * result + sampling.hashCode()
87
- return result
88
- }
105
+ override fun hashCode(): Int = 31 * context.hashCode() + sigma.hashCode()
89
106
 
90
- override fun toString() = "BlurTransformation(radius=$radius, sampling=$sampling)"
107
+ override fun toString() = "BlurTransformation(sigma=$sigma)"
108
+
109
+ private companion object {
110
+ // sigma = 0.4 * radius + 0.6, per RenderScript's ScriptIntrinsicBlur.
111
+ const val SIGMA_SLOPE = 0.4f
112
+ const val SIGMA_INTERCEPT = 0.6f
113
+ const val MIN_RADIUS = 0.01f
114
+ const val MAX_RADIUS = 25f
115
+ const val MAX_SIGMA = SIGMA_SLOPE * MAX_RADIUS + SIGMA_INTERCEPT
116
+ }
91
117
  }
@@ -0,0 +1,135 @@
1
+ //
2
+ // GaussianBlur.swift
3
+ // NitroImagePipeline
4
+ //
5
+ // Cross-platform Gaussian blur — the kernel itself.
6
+ //
7
+ // This file deliberately depends on nothing but Accelerate and CoreGraphics
8
+ // so `scripts/verify-blur.swift` can compile and measure it on the host
9
+ // machine. The UIImage/Nuke plumbing lives in GaussianBlurProcessor.swift.
10
+ //
11
+ // `sigma` is the standard deviation of the Gaussian, measured in *source
12
+ // image pixels* — the same sigma applied to the same source file produces
13
+ // the same result on iOS and Android. It is deliberately not a "radius":
14
+ // CIGaussianBlur, RenderScript and React Native's `blurRadius` each define
15
+ // radius differently, which is why blurs never matched across platforms.
16
+ //
17
+ // For reference, React Native's `<Image blurRadius={n} />` halves its input
18
+ // before convolving, so `blurRadius={n}` is roughly `sigma = n / 2`.
19
+ //
20
+
21
+ import Accelerate
22
+ import CoreGraphics
23
+ import Foundation
24
+
25
+ enum GaussianBlur {
26
+ /// Runs the box-blur passes over `cgImage`. Returns `nil` if the pixel
27
+ /// buffers could not be allocated.
28
+ static func convolve(_ cgImage: CGImage, boxes: [UInt32]) -> CGImage? {
29
+ // Normalise to premultiplied ARGB8888 — vImageBoxConvolve_ARGB8888
30
+ // needs four 8-bit channels, and premultiplied alpha is what keeps
31
+ // transparent edges from bleeding dark halos into the blur.
32
+ guard var format = vImage_CGImageFormat(
33
+ bitsPerComponent: 8,
34
+ bitsPerPixel: 32,
35
+ colorSpace: CGColorSpaceCreateDeviceRGB(),
36
+ bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue),
37
+ renderingIntent: .defaultIntent
38
+ ) else { return nil }
39
+
40
+ var source = vImage_Buffer()
41
+ guard vImageBuffer_InitWithCGImage(
42
+ &source, &format, nil, cgImage, vImage_Flags(kvImageNoFlags)
43
+ ) == kvImageNoError else { return nil }
44
+ defer { free(source.data) }
45
+
46
+ var scratch = vImage_Buffer()
47
+ guard vImageBuffer_Init(
48
+ &scratch, source.height, source.width, 32, vImage_Flags(kvImageNoFlags)
49
+ ) == kvImageNoError else { return nil }
50
+ defer { free(scratch.data) }
51
+
52
+ // kvImageEdgeExtend clamps at the borders instead of sampling
53
+ // transparent black, so the image keeps its edges instead of fading
54
+ // out into the frame (CIGaussianBlur's default, and the reason blurred
55
+ // images used to come back with washed-out borders).
56
+ let flags = vImage_Flags(kvImageEdgeExtend)
57
+ // The passes use two different kernel widths; size the scratch buffer
58
+ // for the widest one so every pass fits in it.
59
+ let widest = boxes.max() ?? 1
60
+ let tempSize = vImageBoxConvolve_ARGB8888(
61
+ &source, &scratch, nil, 0, 0, widest, widest, nil,
62
+ vImage_Flags(kvImageEdgeExtend | kvImageGetTempBufferSize)
63
+ )
64
+ guard tempSize > 0, let temp = malloc(tempSize) else { return nil }
65
+ defer { free(temp) }
66
+
67
+ // Ping-pong between the two buffers; after an odd number of passes the
68
+ // result sits in `input`.
69
+ var input = source
70
+ var output = scratch
71
+ for box in boxes {
72
+ guard vImageBoxConvolve_ARGB8888(
73
+ &input, &output, temp, 0, 0, box, box, nil, flags
74
+ ) == kvImageNoError else { return nil }
75
+ swap(&input, &output)
76
+ }
77
+
78
+ var error = vImage_Error(kvImageNoError)
79
+ guard let blurred = vImageCreateCGImageFromBuffer(
80
+ &input, &format, nil, nil, vImage_Flags(kvImageNoFlags), &error
81
+ )?.takeRetainedValue(), error == kvImageNoError else { return nil }
82
+
83
+ return blurred
84
+ }
85
+
86
+ /// Widths for the three box-blur passes that approximate a Gaussian of
87
+ /// `sigma` (the classic approximation: three boxes land within a few
88
+ /// percent of a true Gaussian, and Accelerate runs them in O(1) per pixel
89
+ /// regardless of how wide they are).
90
+ ///
91
+ /// Three boxes of widths w1…w3 produce a standard deviation of
92
+ /// `sqrt((w1² + w2² + w3² - 3) / 12)`. Box widths have to be odd, so the
93
+ /// passes are split between the two odd integers straddling the ideal
94
+ /// width and the split landing closest to `sigma` wins — which keeps the
95
+ /// error under half a pixel at any sigma instead of always undershooting.
96
+ static func boxSizes(forSigma sigma: Double) -> [UInt32] {
97
+ let passes = 3
98
+ var lower = Int(((12 * sigma * sigma / Double(passes)) + 1).squareRoot())
99
+ if lower % 2 == 0 { lower -= 1 }
100
+ // Upper bound keeps `upper` inside UInt32 for absurd sigmas; a kernel
101
+ // that wide is already far larger than any real image.
102
+ lower = min(max(lower, 1), Int(UInt32.max) - 2)
103
+ let upper = lower + 2
104
+
105
+ // Written as a plain loop rather than map/min(by:): the inferred
106
+ // version tripped "type of expression is ambiguous" on Swift 6.2.
107
+ var best: [UInt32] = []
108
+ var bestError: Double = .infinity
109
+ for lowerCount in 0...passes {
110
+ var candidate: [UInt32] = []
111
+ for pass in 0..<passes {
112
+ candidate.append(UInt32(pass < lowerCount ? lower : upper))
113
+ }
114
+ // `.magnitude` rather than `abs()`: Nitro's C++ interop puts
115
+ // std::abs overloads in scope, and Swift 6.2 calls the resulting
116
+ // `abs` ambiguous when this file is compiled as part of the pod.
117
+ let error: Double = (standardDeviation(of: candidate) - sigma).magnitude
118
+ if error < bestError {
119
+ bestError = error
120
+ best = candidate
121
+ }
122
+ }
123
+ return best
124
+ }
125
+
126
+ /// The standard deviation three box blurs of the given widths add up to.
127
+ private static func standardDeviation(of boxes: [UInt32]) -> Double {
128
+ var sum: Double = 0
129
+ for box in boxes {
130
+ let width = Double(box)
131
+ sum += width * width - 1
132
+ }
133
+ return (sum / 12).squareRoot()
134
+ }
135
+ }
@@ -0,0 +1,41 @@
1
+ //
2
+ // GaussianBlurProcessor.swift
3
+ // NitroImagePipeline
4
+ //
5
+ // UIImage and Nuke plumbing around the kernel in GaussianBlur.swift.
6
+ //
7
+
8
+ import CoreGraphics
9
+ import Foundation
10
+ import Nuke
11
+ import UIKit
12
+
13
+ extension GaussianBlur {
14
+ /// Applies a Gaussian blur of standard deviation `sigma` (in image pixels).
15
+ ///
16
+ /// Returns `image` unchanged when `sigma` is too small to affect a pixel,
17
+ /// and `nil` only when the pixel buffers could not be allocated.
18
+ static func apply(to image: UIImage, sigma: Double) -> UIImage? {
19
+ guard sigma > 0, sigma.isFinite, let cgImage = image.cgImage else { return image }
20
+
21
+ let boxes = boxSizes(forSigma: sigma)
22
+ guard boxes.contains(where: { $0 > 1 }) else { return image }
23
+ guard let blurred = convolve(cgImage, boxes: boxes) else { return nil }
24
+
25
+ return UIImage(cgImage: blurred, scale: image.scale, orientation: image.imageOrientation)
26
+ }
27
+ }
28
+
29
+ /// Nuke processor so `loadImage(url:, { blur })` and `gaussianBlur(image:, sigma)`
30
+ /// go through the exact same kernel.
31
+ struct GaussianBlurProcessor: ImageProcessing {
32
+ let sigma: Double
33
+
34
+ var identifier: String { "com.nitroimagepipeline.gaussianBlur?sigma=\(sigma)" }
35
+
36
+ var hashableIdentifier: AnyHashable { identifier }
37
+
38
+ func process(_ image: PlatformImage) -> PlatformImage? {
39
+ GaussianBlur.apply(to: image, sigma: sigma)
40
+ }
41
+ }
@@ -11,7 +11,6 @@ import NitroImage
11
11
  import Nuke
12
12
 
13
13
  import UIKit
14
- import CoreImage
15
14
 
16
15
  private class HybridImage: HybridImageSpec, NativeImage {
17
16
  let uiImage: UIImage
@@ -101,9 +100,6 @@ class HybridNitroImagePipeline: HybridNitroImagePipelineSpec {
101
100
 
102
101
  private static let sharedPrefetcher = ImagePrefetcher(pipeline: sharedPipeline)
103
102
 
104
- // CIContext is expensive to create; Apple recommends reusing one.
105
- private static let ciContext = CIContext()
106
-
107
103
  private var pipeline: ImagePipeline { Self.sharedPipeline }
108
104
  private var prefetcher: ImagePrefetcher { Self.sharedPrefetcher }
109
105
 
@@ -122,10 +118,20 @@ class HybridNitroImagePipeline: HybridNitroImagePipelineSpec {
122
118
 
123
119
  var processors: [any ImageProcessing] = []
124
120
  if let blur = options?.blur, blur > 0 {
125
- processors.append(.gaussianBlur(radius: Int(blur)))
121
+ processors.append(GaussianBlurProcessor(sigma: blur))
126
122
  }
127
- if let cornerRadius = options?.cornerRadius, cornerRadius > 0 {
128
- processors.append(.roundedCorners(radius: cornerRadius))
123
+ switch options?.cornerRadius {
124
+ case .first(let radius):
125
+ if radius > 0 {
126
+ processors.append(.roundedCorners(radius: radius))
127
+ }
128
+ case .second(let radii):
129
+ let roundedCorners = RoundedCornersProcessor(radii: radii)
130
+ if roundedCorners.hasRounding {
131
+ processors.append(roundedCorners)
132
+ }
133
+ case nil:
134
+ break
129
135
  }
130
136
 
131
137
  let imgRequest = ImageRequest(
@@ -168,22 +174,11 @@ class HybridNitroImagePipeline: HybridNitroImagePipelineSpec {
168
174
  throw RuntimeError.error(withMessage: "Unsupported image type")
169
175
  }
170
176
 
171
- let uiImage = nativeImage.uiImage
172
-
173
- guard let ciImage = CIImage(image: uiImage) else {
174
- throw RuntimeError.error(withMessage: "Failed to read image data")
175
- }
176
-
177
- let filter = CIFilter(name: "CIGaussianBlur")!
178
- filter.setValue(ciImage, forKey: kCIInputImageKey)
179
- filter.setValue(radius, forKey: kCIInputRadiusKey)
180
-
181
- guard let output = filter.outputImage,
182
- let cgImage = Self.ciContext.createCGImage(output, from: ciImage.extent) else {
177
+ guard let blurred = GaussianBlur.apply(to: nativeImage.uiImage, sigma: radius) else {
183
178
  throw RuntimeError.error(withMessage: "Failed to apply gaussian blur")
184
179
  }
185
180
 
186
- return HybridImage(uiImage: UIImage(cgImage: cgImage))
181
+ return HybridImage(uiImage: blurred)
187
182
  }
188
183
  }
189
184
 
@@ -0,0 +1,118 @@
1
+ //
2
+ // RoundedCornersProcessor.swift
3
+ // NitroImagePipeline
4
+ //
5
+ // Bakes independent per-corner radii into the bitmap. Nuke's built-in
6
+ // ImageProcessors.RoundedCorners only supports one uniform radius, so the
7
+ // asymmetric case clips through a hand-built CGPath instead.
8
+ //
9
+
10
+ import CoreGraphics
11
+ import Foundation
12
+ import Nuke
13
+ import UIKit
14
+
15
+ /// Nuke processor that rounds each corner with its own radius (in points,
16
+ /// like `ImageProcessors.RoundedCorners` with `unit: .points`).
17
+ struct RoundedCornersProcessor: ImageProcessing {
18
+ let topLeft: CGFloat
19
+ let topRight: CGFloat
20
+ let bottomLeft: CGFloat
21
+ let bottomRight: CGFloat
22
+
23
+ init(radii: CornerRadii) {
24
+ // Negative radii would make CGPath arcs undefined; treat them as square.
25
+ topLeft = CGFloat(max(radii.topLeft ?? 0, 0))
26
+ topRight = CGFloat(max(radii.topRight ?? 0, 0))
27
+ bottomLeft = CGFloat(max(radii.bottomLeft ?? 0, 0))
28
+ bottomRight = CGFloat(max(radii.bottomRight ?? 0, 0))
29
+ }
30
+
31
+ var hasRounding: Bool {
32
+ topLeft > 0 || topRight > 0 || bottomLeft > 0 || bottomRight > 0
33
+ }
34
+
35
+ var identifier: String {
36
+ "com.nitroimagepipeline.roundedCorners?tl=\(topLeft),tr=\(topRight),bl=\(bottomLeft),br=\(bottomRight)"
37
+ }
38
+
39
+ var hashableIdentifier: AnyHashable { identifier }
40
+
41
+ func process(_ image: PlatformImage) -> PlatformImage? {
42
+ let size = image.size
43
+ guard size.width > 0, size.height > 0 else { return image }
44
+
45
+ let format = UIGraphicsImageRendererFormat()
46
+ format.scale = image.scale
47
+ format.opaque = false
48
+
49
+ let rect = CGRect(origin: .zero, size: size)
50
+ return UIGraphicsImageRenderer(size: size, format: format).image { context in
51
+ context.cgContext.addPath(Self.clipPath(
52
+ in: rect,
53
+ topLeft: topLeft,
54
+ topRight: topRight,
55
+ bottomLeft: bottomLeft,
56
+ bottomRight: bottomRight
57
+ ))
58
+ context.cgContext.clip()
59
+ image.draw(in: rect)
60
+ }
61
+ }
62
+
63
+ /// A rounded-rect outline with independent corner radii, clamped the way
64
+ /// CSS `border-radius` clamps: if two radii on one edge overlap, all four
65
+ /// scale down proportionally until they fit.
66
+ static func clipPath(
67
+ in rect: CGRect,
68
+ topLeft: CGFloat,
69
+ topRight: CGFloat,
70
+ bottomLeft: CGFloat,
71
+ bottomRight: CGFloat
72
+ ) -> CGPath {
73
+ var scale: CGFloat = 1
74
+ for (edge, pair) in [
75
+ (rect.width, topLeft + topRight),
76
+ (rect.width, bottomLeft + bottomRight),
77
+ (rect.height, topLeft + bottomLeft),
78
+ (rect.height, topRight + bottomRight),
79
+ ] where pair > edge {
80
+ scale = min(scale, edge / pair)
81
+ }
82
+ let topLeft = topLeft * scale
83
+ let topRight = topRight * scale
84
+ let bottomLeft = bottomLeft * scale
85
+ let bottomRight = bottomRight * scale
86
+
87
+ // addArc(tangent1End:tangent2End:radius: 0) degenerates to a line
88
+ // through the corner, so square corners need no special-casing.
89
+ let path = CGMutablePath()
90
+ path.move(to: CGPoint(x: rect.minX + topLeft, y: rect.minY))
91
+ path.addLine(to: CGPoint(x: rect.maxX - topRight, y: rect.minY))
92
+ path.addArc(
93
+ tangent1End: CGPoint(x: rect.maxX, y: rect.minY),
94
+ tangent2End: CGPoint(x: rect.maxX, y: rect.minY + topRight),
95
+ radius: topRight
96
+ )
97
+ path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - bottomRight))
98
+ path.addArc(
99
+ tangent1End: CGPoint(x: rect.maxX, y: rect.maxY),
100
+ tangent2End: CGPoint(x: rect.maxX - bottomRight, y: rect.maxY),
101
+ radius: bottomRight
102
+ )
103
+ path.addLine(to: CGPoint(x: rect.minX + bottomLeft, y: rect.maxY))
104
+ path.addArc(
105
+ tangent1End: CGPoint(x: rect.minX, y: rect.maxY),
106
+ tangent2End: CGPoint(x: rect.minX, y: rect.maxY - bottomLeft),
107
+ radius: bottomLeft
108
+ )
109
+ path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + topLeft))
110
+ path.addArc(
111
+ tangent1End: CGPoint(x: rect.minX, y: rect.minY),
112
+ tangent2End: CGPoint(x: rect.minX + topLeft, y: rect.minY),
113
+ radius: topLeft
114
+ )
115
+ path.closeSubpath()
116
+ return path
117
+ }
118
+ }
@@ -27,6 +27,17 @@ function useImage({
27
27
  error: undefined
28
28
  });
29
29
  const loadedUrlRef = (0, _react.useRef)(url);
30
+
31
+ // Split the option into primitives so an inline `{ topLeft: 24, ... }`
32
+ // literal (new identity every render) doesn't re-trigger the effect.
33
+ const isUniformRadius = typeof cornerRadius === 'number';
34
+ const uniformRadius = isUniformRadius ? cornerRadius : 0;
35
+ const {
36
+ topLeft = 0,
37
+ topRight = 0,
38
+ bottomLeft = 0,
39
+ bottomRight = 0
40
+ } = isUniformRadius ? {} : cornerRadius;
30
41
  (0, _react.useEffect)(() => {
31
42
  let cancelled = false;
32
43
  // Only reset to the loading state when the URL changes; for same-URL
@@ -43,7 +54,12 @@ function useImage({
43
54
  try {
44
55
  const result = await NitroImagePipeline.loadImage(url, {
45
56
  blur,
46
- cornerRadius,
57
+ cornerRadius: isUniformRadius ? uniformRadius : {
58
+ topLeft,
59
+ topRight,
60
+ bottomLeft,
61
+ bottomRight
62
+ },
47
63
  cache
48
64
  });
49
65
  if (!cancelled) {
@@ -65,7 +81,7 @@ function useImage({
65
81
  return () => {
66
82
  cancelled = true;
67
83
  };
68
- }, [url, blur, cornerRadius, cache]);
84
+ }, [url, blur, isUniformRadius, uniformRadius, topLeft, topRight, bottomLeft, bottomRight, cache]);
69
85
  return image;
70
86
  }
71
87
  //# sourceMappingURL=index.js.map