react-native-nitro-image-pipeline 1.3.2 → 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 +103 -11
  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 +153 -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 +79 -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 +54 -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 +17 -0
  35. package/nitrogen/generated/android/c++/JHybridNitroImagePipelineSpec.hpp +2 -0
  36. package/nitrogen/generated/android/c++/JViewOptions.hpp +77 -0
  37. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/HybridNitroImagePipelineSpec.kt +9 -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 +53 -0
  41. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Umbrella.hpp +8 -0
  42. package/nitrogen/generated/ios/c++/HybridNitroImagePipelineSpecSwift.hpp +20 -0
  43. package/nitrogen/generated/ios/swift/HybridNitroImagePipelineSpec.swift +2 -0
  44. package/nitrogen/generated/ios/swift/HybridNitroImagePipelineSpec_cxx.swift +26 -0
  45. package/nitrogen/generated/ios/swift/ViewOptions.swift +101 -0
  46. package/nitrogen/generated/shared/c++/HybridNitroImagePipelineSpec.cpp +2 -0
  47. package/nitrogen/generated/shared/c++/HybridNitroImagePipelineSpec.hpp +8 -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 +56 -1
  53. package/src/usePipelineImageLoader.ts +82 -0
@@ -1,6 +1,7 @@
1
1
  package com.margelo.nitro.nitroimagepipeline
2
2
 
3
3
  import android.content.Context
4
+ import android.graphics.Bitmap
4
5
  import androidx.core.graphics.drawable.toBitmap
5
6
  import coil3.BitmapImage
6
7
  import coil3.ColorImage
@@ -26,12 +27,15 @@ import com.margelo.nitro.core.Promise
26
27
  import com.margelo.nitro.image.HybridImage
27
28
  import com.margelo.nitro.image.HybridImageSpec
28
29
  import com.margelo.nitro.nitroimagepipeline.transform.BlurTransformation
30
+ import com.margelo.nitro.nitroimagepipeline.transform.HardwareBitmapTransformation
29
31
  import com.margelo.nitro.nitroimagepipeline.transform.ResizeTransformation
30
32
  import kotlin.math.roundToInt
33
+ import kotlinx.coroutines.CoroutineScope
31
34
  import kotlinx.coroutines.Dispatchers
32
35
  import kotlinx.coroutines.async
33
36
  import kotlinx.coroutines.awaitAll
34
37
  import kotlinx.coroutines.coroutineScope
38
+ import kotlinx.coroutines.launch
35
39
  import kotlinx.coroutines.withContext
36
40
  import okhttp3.OkHttpClient
37
41
  import okio.Path.Companion.toOkioPath
@@ -44,82 +48,27 @@ class HybridNitroImagePipeline : HybridNitroImagePipelineSpec() {
44
48
  private val imageLoader: ImageLoader
45
49
  get() = getOrCreateImageLoader(context)
46
50
 
47
- override fun loadImage(url: String, options: Options?): Promise<HybridImageSpec> = Promise.async {
48
- val blur = options?.blur?.toFloat() ?: 0f
49
- val resize =
50
- options?.resize?.let { r ->
51
- val width = r.width.roundToInt()
52
- val height = r.height.roundToInt()
53
- if (width > 0 && height > 0) width to height else null
54
- }
55
- val transformations = buildList {
56
- // Resize first: blur sigma and corner radii are in pixels of the bitmap
57
- // they run on, so they must see the final size.
58
- resize?.let { (width, height) -> add(ResizeTransformation(width, height)) }
59
- if (blur > 0f) add(BlurTransformation(context, blur))
60
- options
61
- ?.cornerRadius
62
- ?.match(
63
- first = { radius ->
64
- if (radius > 0.0) add(RoundedCornersTransformation(radius.toFloat()))
65
- },
66
- second = { radii ->
67
- // RoundedCornersTransformation rejects negative radii; treat them as square.
68
- val topLeft = (radii.topLeft?.toFloat() ?: 0f).coerceAtLeast(0f)
69
- val topRight = (radii.topRight?.toFloat() ?: 0f).coerceAtLeast(0f)
70
- val bottomLeft = (radii.bottomLeft?.toFloat() ?: 0f).coerceAtLeast(0f)
71
- val bottomRight = (radii.bottomRight?.toFloat() ?: 0f).coerceAtLeast(0f)
72
- if (topLeft > 0f || topRight > 0f || bottomLeft > 0f || bottomRight > 0f) {
73
- add(RoundedCornersTransformation(topLeft, topRight, bottomLeft, bottomRight))
74
- }
75
- },
76
- )
77
- }
78
- val request =
79
- ImageRequest.Builder(context)
80
- .data(url)
81
- .apply {
82
- when (options?.cache) {
83
- CacheOption.MEMORY -> {
84
- memoryCachePolicy(CachePolicy.ENABLED)
85
- diskCachePolicy(CachePolicy.DISABLED)
86
- }
87
- CacheOption.DISK -> {
88
- memoryCachePolicy(CachePolicy.DISABLED)
89
- diskCachePolicy(CachePolicy.ENABLED)
90
- }
91
- CacheOption.NONE -> {
92
- memoryCachePolicy(CachePolicy.DISABLED)
93
- diskCachePolicy(CachePolicy.DISABLED)
94
- }
95
- null -> Unit // Coil defaults: both enabled
96
- }
97
- // Ask the decoder for the target size so a large source is
98
- // subsampled near it instead of decoded at full resolution;
99
- // ResizeTransformation then makes the size exact.
100
- resize?.let { (width, height) ->
101
- size(width, height)
102
- scale(Scale.FILL)
103
- }
104
- }
105
- .allowHardware(true)
106
- .transformations(transformations)
107
- .build()
51
+ init {
52
+ // Build the shared loader now, on a background thread, instead of on
53
+ // whichever thread the first request happens to run on. Creating it
54
+ // builds a CronetEngine, an OkHttpClient and the disk cache — well over
55
+ // a frame's worth of work — and the first `PipelineImageLoader` request
56
+ // would otherwise do that on the main thread.
57
+ (NitroModules.applicationContext as? Context)?.let { warmUpImageLoader(it) }
58
+ }
108
59
 
109
- when (val result = imageLoader.execute(request)) {
60
+ override fun loadImage(url: String, options: Options?): Promise<HybridImageSpec> = Promise.async {
61
+ when (val result = imageLoader.execute(buildRequest(context, url, options))) {
110
62
  is ErrorResult -> throw result.throwable
111
- is SuccessResult -> {
112
- val bitmap =
113
- when (val img = result.image) {
114
- is BitmapImage -> img.bitmap
115
- is DrawableImage -> img.drawable.toBitmap()
116
- else -> throw Error("Unsupported image type: ${img.javaClass.simpleName}")
117
- }
118
- HybridImage(bitmap)
119
- }
63
+ is SuccessResult -> HybridImage(bitmapOf(result))
120
64
  }
121
65
  }
122
66
 
67
+ override fun createImageLoader(
68
+ url: String,
69
+ options: ViewOptions?,
70
+ ): com.margelo.nitro.image.HybridImageLoaderSpec = PipelineImageLoader(context, url, options)
71
+
123
72
  // Preloading warms the disk cache only: the memory cache is skipped and the
124
73
  // decode step is replaced with a no-op (Coil's documented preload pattern),
125
74
  // so prefetching N URLs costs network + disk I/O instead of N full-resolution
@@ -151,12 +100,23 @@ class HybridNitroImagePipeline : HybridNitroImagePipelineSpec() {
151
100
  if (sigma <= 0f) {
152
101
  hybridImage
153
102
  } else {
154
- val blurred =
155
- BlurTransformation(context, sigma).transform(hybridImage.bitmap, Size.ORIGINAL)
103
+ val blurred = BlurTransformation(sigma).transform(hybridImage.bitmap, Size.ORIGINAL)
156
104
  HybridImage(blurred)
157
105
  }
158
106
  }
159
107
 
108
+ override fun setMemoryCacheLimit(bytes: Double) {
109
+ require(bytes >= 0 && bytes.isFinite()) {
110
+ "Memory cache limit must be a non-negative, finite number of bytes (got $bytes)"
111
+ }
112
+ imageLoader.memoryCache?.apply {
113
+ maxSize = bytes.toLong()
114
+ // Setting maxSize only affects future inserts; evict down to it now so
115
+ // the call frees memory immediately.
116
+ trimToSize(bytes.toLong())
117
+ }
118
+ }
119
+
160
120
  override fun clearCache(): Promise<Unit> = Promise.async {
161
121
  imageLoader.memoryCache?.clear()
162
122
  // DiskCache.clear() does file I/O; keep it off the JS thread but await
@@ -170,12 +130,131 @@ class HybridNitroImagePipeline : HybridNitroImagePipelineSpec() {
170
130
  // must be shared across all HybridNitroImagePipeline instances.
171
131
  @Volatile private var sharedImageLoader: ImageLoader? = null
172
132
 
173
- private fun getOrCreateImageLoader(context: Context): ImageLoader =
133
+ /**
134
+ * The [ImageRequest] for [url] with [options] applied — shared by [loadImage] and
135
+ * [PipelineImageLoader] so both hit the same caches with identical cache keys.
136
+ *
137
+ * With [hardwareResult], a transformed result is converted to a hardware bitmap before it is
138
+ * cached (see [HardwareBitmapTransformation]). Only for callers that draw the result: its
139
+ * pixels cannot be read back, and its cache key differs from the software one.
140
+ */
141
+ internal fun buildRequest(
142
+ context: Context,
143
+ url: String,
144
+ options: Options?,
145
+ hardwareResult: Boolean = false,
146
+ ): ImageRequest {
147
+ val blur = options?.blur?.toFloat() ?: 0f
148
+ val resize =
149
+ options?.resize?.let { r ->
150
+ val width = r.width.roundToInt()
151
+ val height = r.height.roundToInt()
152
+ if (width > 0 && height > 0) width to height else null
153
+ }
154
+ val roundedCorners: RoundedCornersTransformation? =
155
+ options
156
+ ?.cornerRadius
157
+ ?.match(
158
+ first = { radius ->
159
+ if (radius > 0.0) RoundedCornersTransformation(radius.toFloat()) else null
160
+ },
161
+ second = { radii ->
162
+ // RoundedCornersTransformation rejects negative radii; treat them as square.
163
+ val topLeft = (radii.topLeft?.toFloat() ?: 0f).coerceAtLeast(0f)
164
+ val topRight = (radii.topRight?.toFloat() ?: 0f).coerceAtLeast(0f)
165
+ val bottomLeft = (radii.bottomLeft?.toFloat() ?: 0f).coerceAtLeast(0f)
166
+ val bottomRight = (radii.bottomRight?.toFloat() ?: 0f).coerceAtLeast(0f)
167
+ if (topLeft > 0f || topRight > 0f || bottomLeft > 0f || bottomRight > 0f) {
168
+ RoundedCornersTransformation(topLeft, topRight, bottomLeft, bottomRight)
169
+ } else {
170
+ null
171
+ }
172
+ },
173
+ )
174
+ val transformations = buildList {
175
+ // Resize first: blur sigma and corner radii are in pixels of the bitmap
176
+ // they run on, so they must see the final size. Coil's
177
+ // RoundedCornersTransformation already scale-fills and center-crops
178
+ // to the request size (`resize`, when set) as part of its own draw,
179
+ // so with rounded corners and no blur the explicit resize would only
180
+ // add an intermediate bitmap — and the radii still apply to the final
181
+ // size, because that is the size its output has.
182
+ if (resize != null && (blur > 0f || roundedCorners == null)) {
183
+ add(ResizeTransformation(resize.first, resize.second))
184
+ }
185
+ if (blur > 0f) add(BlurTransformation(blur))
186
+ roundedCorners?.let { add(it) }
187
+ // Without transformations Coil decodes straight to a hardware bitmap
188
+ // already (allowHardware below); only a transformed result needs the
189
+ // explicit upload.
190
+ if (hardwareResult && isNotEmpty() && HardwareBitmapTransformation.isSupported) {
191
+ add(HardwareBitmapTransformation.instance)
192
+ }
193
+ }
194
+ return ImageRequest.Builder(context)
195
+ .data(url)
196
+ .apply {
197
+ when (options?.cache) {
198
+ CacheOption.MEMORY -> {
199
+ memoryCachePolicy(CachePolicy.ENABLED)
200
+ diskCachePolicy(CachePolicy.DISABLED)
201
+ }
202
+ CacheOption.DISK -> {
203
+ memoryCachePolicy(CachePolicy.DISABLED)
204
+ diskCachePolicy(CachePolicy.ENABLED)
205
+ }
206
+ CacheOption.NONE -> {
207
+ memoryCachePolicy(CachePolicy.DISABLED)
208
+ diskCachePolicy(CachePolicy.DISABLED)
209
+ }
210
+ null -> Unit // Coil defaults: both enabled
211
+ }
212
+ // Ask the decoder for the target size so a large source is
213
+ // subsampled near it instead of decoded at full resolution;
214
+ // ResizeTransformation then makes the size exact.
215
+ resize?.let { (width, height) ->
216
+ size(width, height)
217
+ scale(Scale.FILL)
218
+ }
219
+ }
220
+ .allowHardware(true)
221
+ .transformations(transformations)
222
+ .build()
223
+ }
224
+
225
+ /** The decoded bitmap of a successful load. */
226
+ internal fun bitmapOf(result: SuccessResult): Bitmap =
227
+ when (val img = result.image) {
228
+ is BitmapImage -> img.bitmap
229
+ is DrawableImage -> img.drawable.toBitmap()
230
+ else -> throw Error("Unsupported image type: ${img.javaClass.simpleName}")
231
+ }
232
+
233
+ internal fun getOrCreateImageLoader(context: Context): ImageLoader =
174
234
  sharedImageLoader
175
235
  ?: synchronized(this) {
176
236
  sharedImageLoader ?: createImageLoader(context).also { sharedImageLoader = it }
177
237
  }
178
238
 
239
+ /**
240
+ * The shared loader, creating it on [Dispatchers.IO] if it doesn't exist yet. Use this from
241
+ * main-thread coroutines: once the loader exists this returns synchronously (so memory-cache
242
+ * hits stay synchronous), and until then the expensive creation runs off the main thread.
243
+ */
244
+ internal suspend fun awaitImageLoader(context: Context): ImageLoader =
245
+ sharedImageLoader ?: withContext(Dispatchers.IO) { getOrCreateImageLoader(context) }
246
+
247
+ /** Starts creating the shared loader on a background thread, if it doesn't exist yet. */
248
+ private fun warmUpImageLoader(context: Context) {
249
+ if (sharedImageLoader != null) return
250
+ CoroutineScope(Dispatchers.IO).launch {
251
+ // Warm-up only: an uncaught exception here would take the process
252
+ // down. If creation fails, `sharedImageLoader` stays null and the
253
+ // next request retries it and surfaces the error itself.
254
+ runCatching { getOrCreateImageLoader(context) }
255
+ }
256
+ }
257
+
179
258
  private fun createImageLoader(context: Context): ImageLoader {
180
259
  val okHttpClient =
181
260
  OkHttpClient.Builder()
@@ -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
+ }