react-native-nitro-image-pipeline 1.4.0 → 1.5.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 (53) hide show
  1. package/README.md +72 -5
  2. package/android/CMakeLists.txt +4 -0
  3. package/android/src/main/cpp/GaussianBlur.cpp +165 -0
  4. package/android/src/main/cpp/GaussianBlur.hpp +52 -0
  5. package/android/src/main/cpp/GaussianBlurJni.cpp +50 -0
  6. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/HybridNitroImagePipeline.kt +141 -74
  7. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/PipelineImageLoader.kt +177 -0
  8. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/BlurTransformation.kt +36 -81
  9. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/HardwareBitmapTransformation.kt +45 -0
  10. package/ios/GaussianBlur.swift +48 -9
  11. package/ios/HybridNitroImagePipeline.swift +63 -33
  12. package/ios/PipelineImageLoader.swift +195 -0
  13. package/ios/RoundedCornersProcessor.swift +5 -0
  14. package/lib/commonjs/NativePipelineImage.js +71 -0
  15. package/lib/commonjs/NativePipelineImage.js.map +1 -0
  16. package/lib/commonjs/index.js +14 -0
  17. package/lib/commonjs/index.js.map +1 -1
  18. package/lib/commonjs/usePipelineImageLoader.js +69 -0
  19. package/lib/commonjs/usePipelineImageLoader.js.map +1 -0
  20. package/lib/module/NativePipelineImage.js +67 -0
  21. package/lib/module/NativePipelineImage.js.map +1 -0
  22. package/lib/module/index.js +2 -0
  23. package/lib/module/index.js.map +1 -1
  24. package/lib/module/usePipelineImageLoader.js +65 -0
  25. package/lib/module/usePipelineImageLoader.js.map +1 -0
  26. package/lib/typescript/src/NativePipelineImage.d.ts +66 -0
  27. package/lib/typescript/src/NativePipelineImage.d.ts.map +1 -0
  28. package/lib/typescript/src/index.d.ts +3 -1
  29. package/lib/typescript/src/index.d.ts.map +1 -1
  30. package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts +44 -1
  31. package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts.map +1 -1
  32. package/lib/typescript/src/usePipelineImageLoader.d.ts +15 -0
  33. package/lib/typescript/src/usePipelineImageLoader.d.ts.map +1 -0
  34. package/nitrogen/generated/android/c++/JHybridNitroImagePipelineSpec.cpp +13 -0
  35. package/nitrogen/generated/android/c++/JHybridNitroImagePipelineSpec.hpp +1 -0
  36. package/nitrogen/generated/android/c++/JViewOptions.hpp +77 -0
  37. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/HybridNitroImagePipelineSpec.kt +5 -0
  38. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ViewOptions.kt +66 -0
  39. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.cpp +10 -0
  40. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.hpp +44 -0
  41. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Umbrella.hpp +8 -0
  42. package/nitrogen/generated/ios/c++/HybridNitroImagePipelineSpecSwift.hpp +14 -0
  43. package/nitrogen/generated/ios/swift/HybridNitroImagePipelineSpec.swift +1 -0
  44. package/nitrogen/generated/ios/swift/HybridNitroImagePipelineSpec_cxx.swift +15 -0
  45. package/nitrogen/generated/ios/swift/ViewOptions.swift +101 -0
  46. package/nitrogen/generated/shared/c++/HybridNitroImagePipelineSpec.cpp +1 -0
  47. package/nitrogen/generated/shared/c++/HybridNitroImagePipelineSpec.hpp +7 -0
  48. package/nitrogen/generated/shared/c++/ViewOptions.hpp +104 -0
  49. package/package.json +4 -3
  50. package/src/NativePipelineImage.tsx +103 -0
  51. package/src/index.ts +7 -0
  52. package/src/specs/nitro-image-toolkit.nitro.ts +45 -1
  53. package/src/usePipelineImageLoader.ts +82 -0
@@ -0,0 +1,177 @@
1
+ package com.margelo.nitro.nitroimagepipeline
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import android.view.View
6
+ import android.widget.ImageView
7
+ import androidx.annotation.Keep
8
+ import coil3.request.ErrorResult
9
+ import coil3.request.SuccessResult
10
+ import com.facebook.proguard.annotations.DoNotStrip
11
+ import com.margelo.nitro.core.Promise
12
+ import com.margelo.nitro.image.HybridImage
13
+ import com.margelo.nitro.image.HybridImageLoaderSpec
14
+ import com.margelo.nitro.image.HybridImageSpec
15
+ import com.margelo.nitro.image.HybridImageView
16
+ import com.margelo.nitro.image.HybridNitroImageViewSpec
17
+ import kotlin.coroutines.resume
18
+ import kotlinx.coroutines.CoroutineScope
19
+ import kotlinx.coroutines.Dispatchers
20
+ import kotlinx.coroutines.Job
21
+ import kotlinx.coroutines.SupervisorJob
22
+ import kotlinx.coroutines.isActive
23
+ import kotlinx.coroutines.launch
24
+ import kotlinx.coroutines.suspendCancellableCoroutine
25
+
26
+ /**
27
+ * An `ImageLoader` (react-native-nitro-image) backed by the shared pipeline.
28
+ *
29
+ * `<NativeNitroImage image={loader} />` drives it entirely natively: the view calls [requestImage]
30
+ * when it attaches to the window and [dropImage] when it detaches. The load runs at the view's
31
+ * laid-out size with no JS round trips, and detaching cancels the request and releases the bitmap —
32
+ * the decoded image stays in the shared memory/disk caches, so re-attaching (list recycling) is
33
+ * instant.
34
+ *
35
+ * [ViewOptions] values are in points (dp); this class converts them to the pixel-based [Options] of
36
+ * the shared request builder using the display density, so cache keys match an equivalent
37
+ * `loadImage` call. View sizes are already physical pixels on Android, so only blur and corner
38
+ * radii need scaling.
39
+ */
40
+ @DoNotStrip
41
+ @Keep
42
+ class PipelineImageLoader(
43
+ private val context: Context,
44
+ private val url: String,
45
+ private val options: ViewOptions?,
46
+ ) : HybridImageLoaderSpec() {
47
+ // Per-view in-flight work, keyed by the view's identity, so a loader shared
48
+ // between several views cancels only the right request. Confined to the
49
+ // main thread — the view calls requestImage/dropImage there.
50
+ private val jobs = HashMap<HybridNitroImageViewSpec, Job>()
51
+ private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
52
+
53
+ /** An explicit `resize` override (pixels), when set and valid. */
54
+ private val explicitResize: ResizeOptions?
55
+ get() = options?.resize?.takeIf { it.width > 0 && it.height > 0 }
56
+
57
+ /**
58
+ * The point-based [ViewOptions] as pixel-based [Options], resolved against the display [scale]
59
+ * and the target size in pixels.
60
+ */
61
+ private fun pixelOptions(scale: Float, resize: ResizeOptions?): Options {
62
+ val cornerRadius =
63
+ options
64
+ ?.cornerRadius
65
+ ?.match(
66
+ first = { radius -> Variant_Double_CornerRadii.create(radius * scale) },
67
+ second = { radii ->
68
+ Variant_Double_CornerRadii.create(
69
+ CornerRadii(
70
+ topLeft = radii.topLeft?.times(scale),
71
+ topRight = radii.topRight?.times(scale),
72
+ bottomLeft = radii.bottomLeft?.times(scale),
73
+ bottomRight = radii.bottomRight?.times(scale),
74
+ )
75
+ )
76
+ },
77
+ )
78
+ return Options(
79
+ blur = options?.blur?.times(scale),
80
+ cache = options?.cache,
81
+ cornerRadius = cornerRadius,
82
+ resize = resize,
83
+ )
84
+ }
85
+
86
+ override fun loadImage(): Promise<HybridImageSpec> = Promise.async {
87
+ // No view to measure here: use the explicit resize if given, and the
88
+ // display density for the point-based options.
89
+ val scale = context.resources.displayMetrics.density
90
+ val request =
91
+ HybridNitroImagePipeline.buildRequest(context, url, pixelOptions(scale, explicitResize))
92
+ when (val result = HybridNitroImagePipeline.getOrCreateImageLoader(context).execute(request)) {
93
+ is ErrorResult -> throw result.throwable
94
+ is SuccessResult -> HybridImage(HybridNitroImagePipeline.bitmapOf(result))
95
+ }
96
+ }
97
+
98
+ override fun requestImage(forView: HybridNitroImageViewSpec) {
99
+ val view = forView as? HybridImageView ?: return
100
+ val imageView = view.imageView
101
+ jobs[forView]?.cancel()
102
+ val job = mainScope.launch {
103
+ // Views attach before they are laid out, so the size may not be
104
+ // known yet — wait for the first non-empty layout.
105
+ val resize = explicitResize ?: measuredSize(imageView)
106
+ val scale = imageView.resources.displayMetrics.density
107
+ // The result is only ever drawn here, so a transformed one is cached as
108
+ // a hardware bitmap: no texture upload per view that shows it.
109
+ val request =
110
+ HybridNitroImagePipeline.buildRequest(
111
+ context,
112
+ url,
113
+ pixelOptions(scale, resize),
114
+ hardwareResult = true,
115
+ )
116
+ // Synchronous once the shared loader exists; otherwise its (slow)
117
+ // creation runs on a background thread instead of here on main.
118
+ val loader = HybridNitroImagePipeline.awaitImageLoader(context)
119
+ val result = loader.execute(request)
120
+ // Cancellation normally surfaces as a CancellationException at the
121
+ // suspension point above, but make it explicit: a job that was
122
+ // dropped or replaced while the result was in flight must never
123
+ // touch the view.
124
+ if (!isActive) return@launch
125
+ when (result) {
126
+ is SuccessResult -> imageView.setImageBitmap(HybridNitroImagePipeline.bitmapOf(result))
127
+ // Deliberately no URL in the message — signed URLs and query
128
+ // tokens must not leak into consuming apps' Logcat.
129
+ is ErrorResult -> Log.w(TAG, "Failed to load image", result.throwable)
130
+ }
131
+ }
132
+ jobs[forView] = job
133
+ job.invokeOnCompletion { if (jobs[forView] === job) jobs.remove(forView) }
134
+ }
135
+
136
+ override fun dropImage(forView: HybridNitroImageViewSpec) {
137
+ val view = forView as? HybridImageView ?: return
138
+ jobs.remove(forView)?.cancel()
139
+ view.imageView.setImageDrawable(null)
140
+ }
141
+
142
+ /** The view's laid-out size in pixels, suspending until it has one. */
143
+ private suspend fun measuredSize(view: ImageView): ResizeOptions {
144
+ if (view.width > 0 && view.height > 0) {
145
+ return ResizeOptions(view.width.toDouble(), view.height.toDouble())
146
+ }
147
+ return suspendCancellableCoroutine { continuation ->
148
+ val listener =
149
+ object : View.OnLayoutChangeListener {
150
+ override fun onLayoutChange(
151
+ v: View,
152
+ left: Int,
153
+ top: Int,
154
+ right: Int,
155
+ bottom: Int,
156
+ oldLeft: Int,
157
+ oldTop: Int,
158
+ oldRight: Int,
159
+ oldBottom: Int,
160
+ ) {
161
+ val width = right - left
162
+ val height = bottom - top
163
+ if (width > 0 && height > 0) {
164
+ v.removeOnLayoutChangeListener(this)
165
+ continuation.resume(ResizeOptions(width.toDouble(), height.toDouble()))
166
+ }
167
+ }
168
+ }
169
+ view.addOnLayoutChangeListener(listener)
170
+ continuation.invokeOnCancellation { view.removeOnLayoutChangeListener(listener) }
171
+ }
172
+ }
173
+
174
+ companion object {
175
+ private const val TAG = "PipelineImageLoader"
176
+ }
177
+ }
@@ -1,36 +1,27 @@
1
- @file:Suppress("DEPRECATION")
2
-
3
1
  package com.margelo.nitro.nitroimagepipeline.transform
4
2
 
5
- import android.content.Context
6
3
  import android.graphics.Bitmap
7
- import android.graphics.Paint
8
- import android.renderscript.Allocation
9
- import android.renderscript.Element
10
- import android.renderscript.RenderScript
11
- import android.renderscript.ScriptIntrinsicBlur
12
- import androidx.core.graphics.applyCanvas
13
- import androidx.core.graphics.scale
4
+ import android.graphics.Canvas
14
5
  import coil3.size.Size
15
6
  import coil3.transform.Transformation
16
- import kotlin.math.ceil
17
- import kotlin.math.max
18
7
 
19
8
  /**
20
9
  * Gaussian blur of standard deviation [sigma], measured in *source image pixels*.
21
10
  *
22
11
  * 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
12
+ * result here and on iOS. It is deliberately not a "radius" — CoreImage, RenderScript and React
24
13
  * Native's `blurRadius` each define radius differently, which is why blurs never matched across
25
14
  * platforms.
26
15
  *
16
+ * The kernel is `android/src/main/cpp/GaussianBlur.cpp`, a port of the iOS one: three box-blur
17
+ * passes whose widths are solved for the requested sigma, clamping at the image edges. There is no
18
+ * upper bound on sigma and no downscaling — `bun run verify:blur` measures both kernels against
19
+ * each other.
20
+ *
27
21
  * For reference, React Native's `<Image blurRadius={n} />` halves its input before convolving, so
28
22
  * `blurRadius={n}` is roughly `sigma = n / 2`.
29
23
  */
30
- class BlurTransformation(
31
- private val context: Context,
32
- private val sigma: Float,
33
- ) : Transformation() {
24
+ class BlurTransformation(private val sigma: Float) : Transformation() {
34
25
 
35
26
  init {
36
27
  require(sigma > 0f && sigma.isFinite()) { "sigma must be > 0." }
@@ -39,79 +30,43 @@ class BlurTransformation(
39
30
  override val cacheKey = "${BlurTransformation::class.java.name}-$sigma"
40
31
 
41
32
  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
-
58
- val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
59
- val softwareInput =
60
- if (input.config == Bitmap.Config.HARDWARE) input.copy(Bitmap.Config.ARGB_8888, false)
61
- else input
62
- val softwareConfig = softwareInput.config ?: Bitmap.Config.ARGB_8888
63
-
64
- val output = Bitmap.createBitmap(scaledWidth, scaledHeight, softwareConfig)
65
- output.applyCanvas {
66
- scale(scaleX, scaleY)
67
- drawBitmap(softwareInput, 0f, 0f, paint)
68
- }
69
-
70
- var script: RenderScript? = null
71
- var tmpIn: Allocation? = null
72
- var tmpOut: Allocation? = null
73
- var blur: ScriptIntrinsicBlur? = null
74
- try {
75
- script = RenderScript.create(context)
76
- tmpIn =
77
- Allocation.createFromBitmap(
78
- script,
79
- output,
80
- Allocation.MipmapControl.MIPMAP_NONE,
81
- Allocation.USAGE_SCRIPT,
82
- )
83
- tmpOut = Allocation.createTyped(script, tmpIn.type)
84
- blur = ScriptIntrinsicBlur.create(script, Element.U8_4(script))
85
- blur.setRadius(radius)
86
- blur.setInput(tmpIn)
87
- blur.forEach(tmpOut)
88
- tmpOut.copyTo(output)
89
- } finally {
90
- script?.destroy()
91
- tmpIn?.destroy()
92
- tmpOut?.destroy()
93
- blur?.destroy()
94
- }
95
-
96
- return if (scaledWidth == input.width && scaledHeight == input.height) output
97
- else output.scale(input.width, input.height)
33
+ // The kernel blurs in place, on premultiplied ARGB_8888 pixels. A plain
34
+ // copy is the cheapest way to get a mutable bitmap of that layout also
35
+ // from a hardware bitmap, which a software canvas could not draw. Only a
36
+ // non-premultiplied input goes through a canvas, which converts it.
37
+ val output =
38
+ if (
39
+ input.config == Bitmap.Config.HARDWARE ||
40
+ (input.config == Bitmap.Config.ARGB_8888 &&
41
+ (input.isPremultiplied || !input.hasAlpha()))
42
+ ) {
43
+ input.copy(Bitmap.Config.ARGB_8888, true) ?: error("Bitmap.copy returned null")
44
+ } else {
45
+ Bitmap.createBitmap(input.width, input.height, Bitmap.Config.ARGB_8888).also {
46
+ Canvas(it).drawBitmap(input, 0f, 0f, null)
47
+ }
48
+ }
49
+ check(nativeBlur(output, sigma)) { "Native blur failed (see logcat)." }
50
+ return output
98
51
  }
99
52
 
100
53
  override fun equals(other: Any?): Boolean {
101
54
  if (this === other) return true
102
- return other is BlurTransformation && context == other.context && sigma == other.sigma
55
+ return other is BlurTransformation && sigma == other.sigma
103
56
  }
104
57
 
105
- override fun hashCode(): Int = 31 * context.hashCode() + sigma.hashCode()
58
+ override fun hashCode(): Int = sigma.hashCode()
106
59
 
107
60
  override fun toString() = "BlurTransformation(sigma=$sigma)"
108
61
 
109
62
  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
63
+ init {
64
+ // Nitro loads the library at module registration, but the transformation
65
+ // must not depend on that order; loadLibrary is idempotent.
66
+ System.loadLibrary("NitroImagePipeline")
67
+ }
68
+
69
+ /** Blurs [bitmap] (mutable ARGB_8888) in place. Returns false on failure. */
70
+ @JvmStatic private external fun nativeBlur(bitmap: Bitmap, sigma: Float): Boolean
116
71
  }
117
72
  }
@@ -0,0 +1,45 @@
1
+ package com.margelo.nitro.nitroimagepipeline.transform
2
+
3
+ import android.graphics.Bitmap
4
+ import android.os.Build
5
+ import coil3.size.Size
6
+ import coil3.transform.Transformation
7
+
8
+ /**
9
+ * Uploads the transformed bitmap to a [Bitmap.Config.HARDWARE] bitmap (API 26+), so that is what
10
+ * the memory cache holds and the view draws.
11
+ *
12
+ * Coil decodes straight to a hardware bitmap when a request has no transformations, but with any
13
+ * (resize, blur, rounded corners) the decode and the transformations run on software bitmaps and
14
+ * the software result is what gets cached. Every view showing it then costs a texture upload (and,
15
+ * with the pixels kept for the bitmap, a copy in RAM). Appending this transformation converts the
16
+ * result once, on the transformation thread, and it stays converted in the cache.
17
+ *
18
+ * A hardware bitmap's pixels are not readable, so this must only be added by callers that draw the
19
+ * result — `PipelineImageLoader.requestImage` — never by `loadImage`, whose `HybridImage` needs
20
+ * readable pixels for `toArrayBuffer`/`toBase64`. The extra cache key keeps the two paths' cache
21
+ * entries apart.
22
+ *
23
+ * Below API 26, or if the upload fails, the software bitmap is passed through unchanged.
24
+ */
25
+ class HardwareBitmapTransformation private constructor() : Transformation() {
26
+
27
+ override val cacheKey = HardwareBitmapTransformation::class.java.name
28
+
29
+ override suspend fun transform(input: Bitmap, size: Size): Bitmap {
30
+ if (!isSupported || input.config == Bitmap.Config.HARDWARE) return input
31
+ // Performance-only: a failed upload (null, or an allocation/driver
32
+ // exception) must not fail the request, so fall back to the software
33
+ // bitmap either way.
34
+ return runCatching { input.copy(Bitmap.Config.HARDWARE, false) }.getOrNull() ?: input
35
+ }
36
+
37
+ override fun toString() = "HardwareBitmapTransformation"
38
+
39
+ companion object {
40
+ val isSupported: Boolean
41
+ get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
42
+
43
+ val instance = HardwareBitmapTransformation()
44
+ }
45
+ }
@@ -29,25 +29,30 @@ enum GaussianBlur {
29
29
  // Normalise to premultiplied ARGB8888 — vImageBoxConvolve_ARGB8888
30
30
  // needs four 8-bit channels, and premultiplied alpha is what keeps
31
31
  // transparent edges from bleeding dark halos into the blur.
32
+ let colorSpace = CGColorSpaceCreateDeviceRGB()
33
+ let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
32
34
  guard var format = vImage_CGImageFormat(
33
35
  bitsPerComponent: 8,
34
36
  bitsPerPixel: 32,
35
- colorSpace: CGColorSpaceCreateDeviceRGB(),
36
- bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue),
37
+ colorSpace: colorSpace,
38
+ bitmapInfo: bitmapInfo,
37
39
  renderingIntent: .defaultIntent
38
40
  ) else { return nil }
39
41
 
42
+ // Both buffers are freed on exit, except the one handed over to the
43
+ // result CGImage below (`handedOver`), which is freed with the image.
44
+ var handedOver: UnsafeMutableRawPointer?
40
45
  var source = vImage_Buffer()
41
46
  guard vImageBuffer_InitWithCGImage(
42
47
  &source, &format, nil, cgImage, vImage_Flags(kvImageNoFlags)
43
48
  ) == kvImageNoError else { return nil }
44
- defer { free(source.data) }
49
+ defer { if source.data != handedOver { free(source.data) } }
45
50
 
46
51
  var scratch = vImage_Buffer()
47
52
  guard vImageBuffer_Init(
48
53
  &scratch, source.height, source.width, 32, vImage_Flags(kvImageNoFlags)
49
54
  ) == kvImageNoError else { return nil }
50
- defer { free(scratch.data) }
55
+ defer { if scratch.data != handedOver { free(scratch.data) } }
51
56
 
52
57
  // kvImageEdgeExtend clamps at the borders instead of sampling
53
58
  // transparent black, so the image keeps its edges instead of fading
@@ -75,12 +80,46 @@ enum GaussianBlur {
75
80
  swap(&input, &output)
76
81
  }
77
82
 
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 }
83
+ // Ownership of the result buffer moves to the CGImage (no copy);
84
+ // `handedOver` keeps the exits above from freeing it a second time.
85
+ return makeImage(from: input, colorSpace: colorSpace, bitmapInfo: bitmapInfo, handedOver: &handedOver)
86
+ }
87
+
88
+ /// Wraps `buffer` in a CGImage instead of copying it (one full-bitmap
89
+ /// memcpy less per blur). Ownership is explicit: once the data provider
90
+ /// exists, it frees the buffer — together with the image, or right away
91
+ /// if creating the image fails and the provider is released — so
92
+ /// `handedOver` is set to the buffer's data as soon as that is the case.
93
+ /// (vImageCreateCGImageFromBuffer with kvImageNoAllocate would do the
94
+ /// same, but leaves unspecified whether its free callback runs when the
95
+ /// call fails.)
96
+ private static func makeImage(
97
+ from buffer: vImage_Buffer,
98
+ colorSpace: CGColorSpace,
99
+ bitmapInfo: CGBitmapInfo,
100
+ handedOver: inout UnsafeMutableRawPointer?
101
+ ) -> CGImage? {
102
+ guard let provider = CGDataProvider(
103
+ dataInfo: nil,
104
+ data: buffer.data,
105
+ size: buffer.rowBytes * Int(buffer.height),
106
+ releaseData: { _, data, _ in free(UnsafeMutableRawPointer(mutating: data)) }
107
+ ) else { return nil }
108
+ handedOver = buffer.data
82
109
 
83
- return blurred
110
+ return CGImage(
111
+ width: Int(buffer.width),
112
+ height: Int(buffer.height),
113
+ bitsPerComponent: 8,
114
+ bitsPerPixel: 32,
115
+ bytesPerRow: buffer.rowBytes,
116
+ space: colorSpace,
117
+ bitmapInfo: bitmapInfo,
118
+ provider: provider,
119
+ decode: nil,
120
+ shouldInterpolate: true,
121
+ intent: .defaultIntent
122
+ )
84
123
  }
85
124
 
86
125
  /// Widths for the three box-blur passes that approximate a Gaussian of
@@ -12,7 +12,7 @@ import Nuke
12
12
 
13
13
  import UIKit
14
14
 
15
- private class HybridImage: HybridImageSpec, NativeImage {
15
+ class HybridImage: HybridImageSpec, NativeImage {
16
16
  let uiImage: UIImage
17
17
 
18
18
  // PNG encoding is expensive; encode once and reuse for both
@@ -21,26 +21,22 @@ private class HybridImage: HybridImageSpec, NativeImage {
21
21
  // doesn't pin its encoding forever.
22
22
  private let pngLock = NSLock()
23
23
  private var cachedPngData: Data?
24
+ // Registered only once there is encoded data to drop: most images are
25
+ // only ever displayed, and an observer per image is wasted work in a
26
+ // list of hundreds. Guarded by `pngLock` like the data it protects.
24
27
  private var memoryWarningObserver: (any NSObjectProtocol)?
25
28
 
26
29
  init(uiImage: UIImage) {
27
30
  self.uiImage = uiImage
28
31
  super.init()
29
- memoryWarningObserver = NotificationCenter.default.addObserver(
30
- forName: UIApplication.didReceiveMemoryWarningNotification,
31
- object: nil,
32
- queue: nil
33
- ) { [weak self] _ in
34
- guard let self else { return }
35
- self.pngLock.lock()
36
- self.cachedPngData = nil
37
- self.pngLock.unlock()
38
- }
39
32
  }
40
33
 
41
34
  deinit {
42
- if let memoryWarningObserver {
43
- NotificationCenter.default.removeObserver(memoryWarningObserver)
35
+ pngLock.lock()
36
+ let observer = memoryWarningObserver
37
+ pngLock.unlock()
38
+ if let observer {
39
+ NotificationCenter.default.removeObserver(observer)
44
40
  }
45
41
  }
46
42
 
@@ -52,6 +48,18 @@ private class HybridImage: HybridImageSpec, NativeImage {
52
48
  }
53
49
  let data = uiImage.pngData()
54
50
  cachedPngData = data
51
+ if data != nil, memoryWarningObserver == nil {
52
+ memoryWarningObserver = NotificationCenter.default.addObserver(
53
+ forName: UIApplication.didReceiveMemoryWarningNotification,
54
+ object: nil,
55
+ queue: nil
56
+ ) { [weak self] _ in
57
+ guard let self else { return }
58
+ self.pngLock.lock()
59
+ self.cachedPngData = nil
60
+ self.pngLock.unlock()
61
+ }
62
+ }
55
63
  return data
56
64
  }
57
65
 
@@ -89,7 +97,7 @@ class HybridNitroImagePipeline: HybridNitroImagePipelineSpec {
89
97
  // users in the host app are left untouched, and repeated instantiation
90
98
  // (e.g. Metro reloads) never puts two DataCache instances on the same
91
99
  // directory.
92
- private static let sharedPipeline: ImagePipeline = {
100
+ static let sharedPipeline: ImagePipeline = {
93
101
  var configuration = ImagePipeline.Configuration.withDataCache
94
102
  // Store processed (blurred/rounded) variants on disk in addition to
95
103
  // the original download, so they survive memory eviction and
@@ -105,6 +113,15 @@ class HybridNitroImagePipeline: HybridNitroImagePipelineSpec {
105
113
  configuration.imageCache = ImageCache(
106
114
  costLimit: min(ImageCache.defaultCostLimit, 128 * 1024 * 1024)
107
115
  )
116
+ // Nuke decodes on a serial queue by default, so a screenful of new
117
+ // list cells is decoded one image at a time however many cores the
118
+ // device has. Decode in parallel instead — capped so a burst of
119
+ // full-resolution decodes (no `resize`) can't multiply peak memory
120
+ // by the core count. Processing stays at Nuke's default of 2: the
121
+ // blur allocates several full-size buffers per image.
122
+ configuration.imageDecodingQueue.maxConcurrentOperationCount = min(
123
+ 4, max(1, ProcessInfo.processInfo.activeProcessorCount)
124
+ )
108
125
  return ImagePipeline(configuration: configuration)
109
126
  }()
110
127
 
@@ -181,36 +198,49 @@ class HybridNitroImagePipeline: HybridNitroImagePipelineSpec {
181
198
  return processors
182
199
  }
183
200
 
201
+ /// The `ImageRequest` for `url` with `options` applied — shared by
202
+ /// `loadImage` and `PipelineImageLoader` so both hit the same caches with
203
+ /// identical cache keys.
204
+ static func makeRequest(url: URL, options: Options?) -> ImageRequest {
205
+ var imgRequest = ImageRequest(
206
+ url: url,
207
+ processors: processors(for: options),
208
+ options: cacheOptions(for: options?.cache)
209
+ )
210
+ // With a target size known, decode near it (aspect-fill, so the
211
+ // decoded image always covers the target) instead of at full
212
+ // resolution — a 48 MP photo displayed as a 300 pt card would
213
+ // otherwise decompress to ~190 MB before Resize shrinks it.
214
+ // Matches Android, where the request's size() drives subsampling;
215
+ // the exact size and crop still come from the Resize processor.
216
+ if let size = resizeSize(for: options) {
217
+ imgRequest.thumbnail = ImageRequest.ThumbnailOptions(
218
+ size: size,
219
+ unit: .pixels,
220
+ contentMode: .aspectFill
221
+ )
222
+ }
223
+ return imgRequest
224
+ }
225
+
184
226
  func loadImage(url: String, options: Options?) throws -> Promise<any HybridImageSpec> {
185
227
  return Promise.async {
186
228
  guard let imageUrl = URL(string: url) else {
187
229
  throw RuntimeError.error(withMessage: "Invalid URL: \(url)")
188
230
  }
189
231
 
190
- var imgRequest = ImageRequest(
191
- url: imageUrl,
192
- processors: Self.processors(for: options),
193
- options: Self.cacheOptions(for: options?.cache)
194
- )
195
- // With a target size known, decode near it (aspect-fill, so the
196
- // decoded image always covers the target) instead of at full
197
- // resolution — a 48 MP photo displayed as a 300 pt card would
198
- // otherwise decompress to ~190 MB before Resize shrinks it.
199
- // Matches Android, where the request's size() drives subsampling;
200
- // the exact size and crop still come from the Resize processor.
201
- if let size = Self.resizeSize(for: options) {
202
- imgRequest.thumbnail = ImageRequest.ThumbnailOptions(
203
- size: size,
204
- unit: .pixels,
205
- contentMode: .aspectFill
206
- )
207
- }
208
-
232
+ let imgRequest = Self.makeRequest(url: imageUrl, options: options)
209
233
  let image = try await self.pipeline.image(for: imgRequest)
210
234
  return HybridImage(uiImage: image)
211
235
  }
212
236
  }
213
237
 
238
+ func createImageLoader(url: String, options: ViewOptions?) throws -> any HybridImageLoaderSpec {
239
+ // An unparseable URL fails at load time, not here — matching Android,
240
+ // where Coil validates the URL only when the request runs.
241
+ return PipelineImageLoader(url: url, options: options)
242
+ }
243
+
214
244
  func preLoadImage(url: String) throws -> Promise<Void> {
215
245
  return Promise.async {
216
246
  guard let imageUrl = URL(string: url) else {