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.
- package/README.md +103 -11
- package/android/CMakeLists.txt +4 -0
- package/android/src/main/cpp/GaussianBlur.cpp +165 -0
- package/android/src/main/cpp/GaussianBlur.hpp +52 -0
- package/android/src/main/cpp/GaussianBlurJni.cpp +50 -0
- package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/HybridNitroImagePipeline.kt +153 -74
- package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/PipelineImageLoader.kt +177 -0
- package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/BlurTransformation.kt +36 -81
- package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/HardwareBitmapTransformation.kt +45 -0
- package/ios/GaussianBlur.swift +48 -9
- package/ios/HybridNitroImagePipeline.swift +79 -33
- package/ios/PipelineImageLoader.swift +195 -0
- package/ios/RoundedCornersProcessor.swift +5 -0
- package/lib/commonjs/NativePipelineImage.js +71 -0
- package/lib/commonjs/NativePipelineImage.js.map +1 -0
- package/lib/commonjs/index.js +14 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/usePipelineImageLoader.js +69 -0
- package/lib/commonjs/usePipelineImageLoader.js.map +1 -0
- package/lib/module/NativePipelineImage.js +67 -0
- package/lib/module/NativePipelineImage.js.map +1 -0
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/usePipelineImageLoader.js +65 -0
- package/lib/module/usePipelineImageLoader.js.map +1 -0
- package/lib/typescript/src/NativePipelineImage.d.ts +66 -0
- package/lib/typescript/src/NativePipelineImage.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts +54 -1
- package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts.map +1 -1
- package/lib/typescript/src/usePipelineImageLoader.d.ts +15 -0
- package/lib/typescript/src/usePipelineImageLoader.d.ts.map +1 -0
- package/nitrogen/generated/android/c++/JHybridNitroImagePipelineSpec.cpp +17 -0
- package/nitrogen/generated/android/c++/JHybridNitroImagePipelineSpec.hpp +2 -0
- package/nitrogen/generated/android/c++/JViewOptions.hpp +77 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/HybridNitroImagePipelineSpec.kt +9 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ViewOptions.kt +66 -0
- package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.cpp +10 -0
- package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.hpp +53 -0
- package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Umbrella.hpp +8 -0
- package/nitrogen/generated/ios/c++/HybridNitroImagePipelineSpecSwift.hpp +20 -0
- package/nitrogen/generated/ios/swift/HybridNitroImagePipelineSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridNitroImagePipelineSpec_cxx.swift +26 -0
- package/nitrogen/generated/ios/swift/ViewOptions.swift +101 -0
- package/nitrogen/generated/shared/c++/HybridNitroImagePipelineSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridNitroImagePipelineSpec.hpp +8 -0
- package/nitrogen/generated/shared/c++/ViewOptions.hpp +104 -0
- package/package.json +4 -3
- package/src/NativePipelineImage.tsx +103 -0
- package/src/index.ts +7 -0
- package/src/specs/nitro-image-toolkit.nitro.ts +56 -1
- package/src/usePipelineImageLoader.ts +82 -0
package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/HybridNitroImagePipeline.kt
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/BlurTransformation.kt
CHANGED
|
@@ -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.
|
|
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" —
|
|
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
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 &&
|
|
55
|
+
return other is BlurTransformation && sigma == other.sigma
|
|
103
56
|
}
|
|
104
57
|
|
|
105
|
-
override fun hashCode(): Int =
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
+
}
|