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
package/README.md CHANGED
@@ -71,6 +71,38 @@ so a style that already rounds the view rounds the bitmap too, with no separate
71
71
  other prop (`resizeMode`, `recyclingKey`, `testID`, …) is passed straight through to
72
72
  `NativeNitroImage`.
73
73
 
74
+ ### `<NativePipelineImage>` component
75
+
76
+ The fully native-driven variant of `<PipelineImage>`, for when per-image JS work matters (long,
77
+ fast-scrolling lists): after the first render there are **zero JS round trips per image**. The
78
+ native view starts the request the moment it attaches to the window — at its own laid-out size,
79
+ so nothing waits for an `onLayout` event to reach JS — and cancels it (releasing the bitmap) when
80
+ it detaches, which makes off-screen list cells free. Re-attaching hits the shared memory cache,
81
+ so recycled cells re-display instantly.
82
+
83
+ ```tsx
84
+ import { NativePipelineImage } from 'react-native-nitro-image-pipeline';
85
+
86
+ <NativePipelineImage
87
+ url="https://example.com/photo.jpg"
88
+ style={styles.photo} // size measured natively; borderRadius baked into the bitmap
89
+ blur={2} // points, like PipelineImage
90
+ />;
91
+ ```
92
+
93
+ `blur`/`cornerRadius` are in points and `style`'s `borderRadius` is picked up automatically,
94
+ exactly like `<PipelineImage>`. The trade-offs of going fully native:
95
+
96
+ - No `onLoad`/`onError` — the loaded `Image` never crosses into JS. Use `<PipelineImage>` or
97
+ `useImage` when you need them.
98
+ - The bitmap is loaded once at the size the view first has; if the view resizes later, the bitmap
99
+ scales with it instead of reloading.
100
+
101
+ Under the hood this is `NitroImagePipeline.createImageLoader(url, options)` — an
102
+ [`ImageLoader`](https://github.com/mrousavy/react-native-nitro-image) driven by
103
+ `NativeNitroImage` — so you can also use the `usePipelineImageLoader(url, options)` hook directly
104
+ with your own `<NativeNitroImage image={loader} />`.
105
+
74
106
  ### Animating with `react-native-reanimated`
75
107
 
76
108
  `<PipelineImage>` forwards its `ref` to the underlying `NativeNitroImage` host view, so it can be
@@ -241,6 +273,31 @@ Loads an image from a URL and returns a `Promise<Image>`.
241
273
  | `ref` | `Ref<PipelineImageRef>` | — | Forwarded to the underlying `NativeNitroImage` host view — gives access to native-view methods (`measure`, …) and makes the component work with `Animated.createAnimatedComponent` (see [Animating](#animating-with-react-native-reanimated)) |
242
274
  | `…NativeNitroImage props` | — | — | Everything else (`resizeMode`, `recyclingKey`, `testID`, …) is passed through to `NativeNitroImage` |
243
275
 
276
+ ### `<NativePipelineImage>`
277
+
278
+ | Prop | Type | Default | Description |
279
+ |---|---|---|---|
280
+ | `url` | `string` | — | Image URL to load |
281
+ | `style` | `StyleProp<ViewStyle>` | — | Layout style. The native side measures the view and loads at that size; `borderRadius`-family properties drive `cornerRadius` when it's omitted |
282
+ | `blur` | `number` | `0` | Gaussian blur strength, in **points** (screen scale applied natively) |
283
+ | `cornerRadius` | `number \| CornerRadii` | derived from `style` | Corner radius, in **points** (screen scale applied natively) |
284
+ | `cache` | `'memory' \| 'disk' \| 'none'` | platform default | Caching strategy |
285
+ | `resize` | `{ width, height }` | measured from the view | Explicit target bitmap size in **pixels**, skipping the native measurement. Rarely needed |
286
+ | `ref` | `Ref<NativePipelineImageRef>` | — | Forwarded to the underlying `NativeNitroImage` host view |
287
+ | `…NativeNitroImage props` | — | — | Everything else (`resizeMode`, `recyclingKey`, `testID`, …) is passed through; `recyclingKey` defaults to `url` |
288
+
289
+ No `onLoad`/`onError`: loading happens entirely natively and the result never crosses into JS.
290
+
291
+ ### `createImageLoader(url, options?)` / `usePipelineImageLoader(url, options?)`
292
+
293
+ Creates the [`ImageLoader`](https://github.com/mrousavy/react-native-nitro-image) that powers
294
+ `<NativePipelineImage>`, for use with your own `<NativeNitroImage image={loader} />`. The view
295
+ calls into it natively when it attaches (load at the view's laid-out size) and detaches
296
+ (cancel + release). `options` takes `blur`/`cornerRadius` in **points** and an optional
297
+ pixel-based `resize` override — see the `ViewOptions` type. The hook memoizes by value, so
298
+ inline options literals are fine. `loader.loadImage()` also works imperatively and resolves with
299
+ the processed `Image`.
300
+
244
301
  ### `resizeForStyle(style)` / `resizeForLayout(width, height)`
245
302
 
246
303
  Converts a layout size in points to a bitmap `resize` option in pixels. Returns
@@ -300,11 +357,12 @@ Two things follow from the unit being *source* pixels:
300
357
  Values below ~1 are smaller than the smallest kernel either backend can build and are effectively a
301
358
  no-op. There is no upper bound.
302
359
 
303
- Implementation: iOS runs three Accelerate box-convolution passes sized to hit the requested sigma
304
- (the standard three-box Gaussian approximation, accurate to a few percent); Android uses
305
- RenderScript's true Gaussian, downscaling first when sigma exceeds the single-pass ceiling of
306
- ~10.6px and compensating the radius so the result is unchanged. Both clamp at the edges, so blurred
307
- images keep their borders instead of fading out.
360
+ Implementation: both platforms run the same three box-convolution passes sized to hit the requested
361
+ sigma (the standard three-box Gaussian approximation, accurate to a few percent) iOS through
362
+ Accelerate's `vImageBoxConvolve_ARGB8888`, Android through a C++ port of that kernel working directly
363
+ on the bitmap's pixels. The two are checked against each other on every CI run and produce
364
+ byte-identical output for the same input. Both clamp at the edges, so blurred images keep their
365
+ borders instead of fading out.
308
366
 
309
367
  ### `setMemoryCacheLimit(bytes)`
310
368
 
@@ -332,6 +390,15 @@ The pipeline is set up so RAM scales with what you display, not with what you do
332
390
  size known, both platforms decode the source *near that size* instead of at full resolution —
333
391
  iOS via a downsampled thumbnail decode, Android via Coil's subsampling. Without `resize`, a
334
392
  48 MP photo decompresses to ~190 MB of bitmap no matter how small you display it.
393
+ - **Android draws transformed images from hardware bitmaps** (API 26+). When a view loads its
394
+ image natively (`<NativePipelineImage>`, or `<NativeNitroImage image={createImageLoader(...)}>`),
395
+ a resized, blurred or rounded result is uploaded to a `Bitmap.Config.HARDWARE` bitmap once and
396
+ cached like that, so its pixels live in GPU memory instead of the native heap and each view
397
+ drawing it skips a texture upload. In a 200-cell list of rounded thumbnails this cut the app's
398
+ PSS by ~100 MB on a Pixel 6a. Images returned to JavaScript — `loadImage` and
399
+ `createImageLoader(...).loadImage()` — skip that step, so their transformed results stay
400
+ software bitmaps with readable pixels (`toArrayBuffer`/`toBase64`); the two paths cache under
401
+ different keys only when that upload step is present.
335
402
  - **Prefetching stores bytes, not bitmaps.** `preLoadImage(s)` writes the download to the disk
336
403
  cache and skips decoding entirely.
337
404
  - **The in-memory cache is capped and tunable** (defaults: 128 MB on iOS, 25% of the app's memory
@@ -11,6 +11,8 @@ add_compile_options(-DRN_SERIALIZABLE_STATE=1)
11
11
  # Define C++ library and add all sources
12
12
  add_library(${PACKAGE_NAME} SHARED
13
13
  src/main/cpp/cpp-adapter.cpp
14
+ src/main/cpp/GaussianBlur.cpp
15
+ src/main/cpp/GaussianBlurJni.cpp
14
16
  )
15
17
 
16
18
  # Add Nitrogen specs :)
@@ -23,12 +25,14 @@ include_directories(
23
25
  )
24
26
 
25
27
  find_library(LOG_LIB log)
28
+ find_library(JNIGRAPHICS_LIB jnigraphics) # <-- AndroidBitmap_lockPixels for the blur
26
29
  find_package(react-native-nitro-image REQUIRED) # <-- for the HybridImage type
27
30
 
28
31
  # Link all libraries together
29
32
  target_link_libraries(
30
33
  ${PACKAGE_NAME}
31
34
  ${LOG_LIB}
35
+ ${JNIGRAPHICS_LIB}
32
36
  android # <-- Android core
33
37
  react-native-nitro-image::NitroImage # <-- NitroImage
34
38
  )
@@ -0,0 +1,165 @@
1
+ //
2
+ // GaussianBlur.cpp
3
+ // NitroImagePipeline
4
+ //
5
+ // See GaussianBlur.hpp. Three box-blur passes approximate the Gaussian;
6
+ // each pass is a separable 2D box (horizontal running sum, then vertical
7
+ // running sum) that rounds to 8 bits only once, so it matches vImage's
8
+ // single 2D `vImageBoxConvolve_ARGB8888` pass on iOS rather than
9
+ // accumulating an extra rounding error per direction.
10
+ //
11
+
12
+ #include "GaussianBlur.hpp"
13
+
14
+ #include <algorithm>
15
+ #include <cmath>
16
+ #include <cstring>
17
+ #include <vector>
18
+
19
+ namespace {
20
+
21
+ constexpr int kChannels = 4;
22
+
23
+ /// The standard deviation three box blurs of the given widths add up to.
24
+ double standardDeviation(const uint32_t boxes[NITRO_BLUR_PASSES]) {
25
+ double sum = 0;
26
+ for (int i = 0; i < NITRO_BLUR_PASSES; i++) {
27
+ const double width = boxes[i];
28
+ sum += width * width - 1;
29
+ }
30
+ return std::sqrt(sum / 12);
31
+ }
32
+
33
+ /// One box pass of width `box` over the whole image, in place.
34
+ ///
35
+ /// `rowSums` caches the horizontal running sums of the rows the vertical
36
+ /// window currently covers (a ring of `ring` rows), so each row's horizontal
37
+ /// pass runs once. The image edges are clamped: sampling outside the image
38
+ /// returns the nearest border pixel, exactly like vImage's kvImageEdgeExtend.
39
+ void boxPass(
40
+ uint8_t* pixels,
41
+ uint32_t width,
42
+ uint32_t height,
43
+ size_t stride,
44
+ uint32_t box,
45
+ std::vector<uint32_t>& rowSums,
46
+ std::vector<int64_t>& rowIndex,
47
+ std::vector<uint64_t>& column) {
48
+ const int64_t radius = (static_cast<int64_t>(box) - 1) / 2;
49
+ const int64_t w = width;
50
+ const int64_t h = height;
51
+ const size_t rowLength = static_cast<size_t>(width) * kChannels;
52
+ const int64_t ring = std::min<int64_t>(box, h) + 1;
53
+ const uint64_t area = static_cast<uint64_t>(box) * box;
54
+ const uint64_t half = area / 2;
55
+
56
+ std::fill(rowIndex.begin(), rowIndex.end(), -1);
57
+
58
+ auto clampRow = [h](int64_t y) { return std::max<int64_t>(0, std::min(h - 1, y)); };
59
+
60
+ // Horizontal running sum of image row `y` into the ring slot for it.
61
+ auto horizontal = [&](int64_t y) -> const uint32_t* {
62
+ const int64_t slot = y % ring;
63
+ uint32_t* out = rowSums.data() + static_cast<size_t>(slot) * rowLength;
64
+ if (rowIndex[static_cast<size_t>(slot)] == y) return out;
65
+ rowIndex[static_cast<size_t>(slot)] = y;
66
+
67
+ const uint8_t* row = pixels + static_cast<size_t>(y) * stride;
68
+ auto px = [&](int64_t x) {
69
+ return row + static_cast<size_t>(std::max<int64_t>(0, std::min(w - 1, x))) * kChannels;
70
+ };
71
+ uint32_t sum[kChannels] = {0, 0, 0, 0};
72
+ for (int64_t dx = -radius; dx <= radius; dx++) {
73
+ const uint8_t* p = px(dx);
74
+ for (int c = 0; c < kChannels; c++) sum[c] += p[c];
75
+ }
76
+ for (int64_t x = 0; x < w; x++) {
77
+ for (int c = 0; c < kChannels; c++) out[x * kChannels + c] = sum[c];
78
+ const uint8_t* leaving = px(x - radius);
79
+ const uint8_t* entering = px(x + radius + 1);
80
+ for (int c = 0; c < kChannels; c++) sum[c] += entering[c] - leaving[c];
81
+ }
82
+ return out;
83
+ };
84
+
85
+ // Vertical running sum over the clamped window [y - radius, y + radius].
86
+ std::fill(column.begin(), column.end(), 0);
87
+ for (int64_t dy = -radius; dy <= radius; dy++) {
88
+ const uint32_t* r = horizontal(clampRow(dy));
89
+ for (size_t i = 0; i < rowLength; i++) column[i] += r[i];
90
+ }
91
+
92
+ for (int64_t y = 0; y < h; y++) {
93
+ uint8_t* out = pixels + static_cast<size_t>(y) * stride;
94
+ for (size_t i = 0; i < rowLength; i++) {
95
+ out[i] = static_cast<uint8_t>((column[i] + half) / area);
96
+ }
97
+ if (y + 1 == h) break;
98
+ // The row leaving the window is still cached: it is inside the window,
99
+ // and the ring holds one row more than the window can span.
100
+ const uint32_t* leaving = horizontal(clampRow(y - radius));
101
+ const uint32_t* entering = horizontal(clampRow(y + radius + 1));
102
+ // Two steps: a uint32 `entering - leaving` would wrap before it reached
103
+ // the 64-bit accumulator.
104
+ for (size_t i = 0; i < rowLength; i++) column[i] += entering[i];
105
+ for (size_t i = 0; i < rowLength; i++) column[i] -= leaving[i];
106
+ }
107
+ }
108
+
109
+ } // namespace
110
+
111
+ extern "C" void nitro_blur_box_sizes(double sigma, uint32_t boxes[NITRO_BLUR_PASSES]) {
112
+ // Three boxes of widths w1…w3 produce a standard deviation of
113
+ // sqrt((w1² + w2² + w3² - 3) / 12). Box widths have to be odd, so the
114
+ // passes are split between the two odd integers straddling the ideal width
115
+ // and the split landing closest to `sigma` wins — the same search as
116
+ // `GaussianBlur.boxSizes(forSigma:)` on iOS, so both platforms pick the
117
+ // same widths.
118
+ const int passes = NITRO_BLUR_PASSES;
119
+ // A NaN or infinite sigma would make every candidate's error NaN and
120
+ // leave `boxes` unwritten; treat it, and sigma <= 0, as "no blur".
121
+ if (!std::isfinite(sigma) || sigma <= 0) {
122
+ for (int i = 0; i < passes; i++) boxes[i] = 1;
123
+ return;
124
+ }
125
+ int64_t lower = static_cast<int64_t>(std::sqrt((12 * sigma * sigma / passes) + 1));
126
+ if (lower % 2 == 0) lower -= 1;
127
+ lower = std::min<int64_t>(std::max<int64_t>(lower, 1), static_cast<int64_t>(UINT32_MAX) - 2);
128
+ const int64_t upper = lower + 2;
129
+
130
+ double bestError = INFINITY;
131
+ for (int lowerCount = 0; lowerCount <= passes; lowerCount++) {
132
+ uint32_t candidate[NITRO_BLUR_PASSES];
133
+ for (int pass = 0; pass < passes; pass++) {
134
+ candidate[pass] = static_cast<uint32_t>(pass < lowerCount ? lower : upper);
135
+ }
136
+ const double error = std::fabs(standardDeviation(candidate) - sigma);
137
+ if (error < bestError) {
138
+ bestError = error;
139
+ std::memcpy(boxes, candidate, sizeof(candidate));
140
+ }
141
+ }
142
+ }
143
+
144
+ extern "C" int nitro_blur_premultiplied_8888(
145
+ uint8_t* pixels, uint32_t width, uint32_t height, size_t stride, double sigma) {
146
+ if (width == 0 || height == 0) return 0;
147
+ uint32_t boxes[NITRO_BLUR_PASSES];
148
+ nitro_blur_box_sizes(sigma, boxes);
149
+
150
+ // Size the scratch buffers for the widest pass so every pass fits in them.
151
+ const uint32_t widest = *std::max_element(boxes, boxes + NITRO_BLUR_PASSES);
152
+ const size_t rowLength = static_cast<size_t>(width) * kChannels;
153
+ const size_t ring = std::min<size_t>(widest, height) + 1;
154
+ try {
155
+ std::vector<uint32_t> rowSums(ring * rowLength);
156
+ std::vector<int64_t> rowIndex(ring);
157
+ std::vector<uint64_t> column(rowLength);
158
+ for (int i = 0; i < NITRO_BLUR_PASSES; i++) {
159
+ boxPass(pixels, width, height, stride, boxes[i], rowSums, rowIndex, column);
160
+ }
161
+ } catch (const std::bad_alloc&) {
162
+ return -1;
163
+ }
164
+ return 0;
165
+ }
@@ -0,0 +1,52 @@
1
+ //
2
+ // GaussianBlur.hpp
3
+ // NitroImagePipeline
4
+ //
5
+ // Cross-platform Gaussian blur — the Android kernel, a port of
6
+ // ios/GaussianBlur.swift.
7
+ //
8
+ // This file deliberately depends on nothing but the C and C++ standard
9
+ // libraries (no JNI, no Android headers) so `scripts/verify-blur.swift` can
10
+ // compile it on the host and measure it against the iOS kernel. The JNI
11
+ // glue lives in GaussianBlurJni.cpp.
12
+ //
13
+ // `sigma` is the standard deviation of the Gaussian, measured in *source
14
+ // image pixels* — the same sigma applied to the same source file produces
15
+ // the same result on iOS and Android. See the Swift file for why it is a
16
+ // sigma and not a "radius".
17
+ //
18
+
19
+ #pragma once
20
+
21
+ #include <stddef.h>
22
+ #include <stdint.h>
23
+
24
+ #ifdef __cplusplus
25
+ extern "C" {
26
+ #endif
27
+
28
+ /// Number of box-blur passes; both platforms run three.
29
+ #define NITRO_BLUR_PASSES 3
30
+
31
+ /// Widths for the three box-blur passes that approximate a Gaussian of
32
+ /// `sigma` — the same numbers `GaussianBlur.boxSizes(forSigma:)` returns on
33
+ /// iOS. Every width is odd and at least 1.
34
+ void nitro_blur_box_sizes(double sigma, uint32_t boxes[NITRO_BLUR_PASSES]);
35
+
36
+ /// Blurs `pixels` in place with the box widths for `sigma`.
37
+ ///
38
+ /// The buffer is `height` rows of `width` 4-byte pixels, rows `stride` bytes
39
+ /// apart. All four channels are convolved identically, so the byte order
40
+ /// (ARGB on iOS, RGBA on Android) does not matter — but alpha must be
41
+ /// premultiplied, otherwise transparent edges bleed dark halos into the blur.
42
+ /// The image edges are clamped (each border pixel extends outward), matching
43
+ /// vImage's kvImageEdgeExtend, so blurred images keep their borders instead
44
+ /// of fading out into transparent black.
45
+ ///
46
+ /// Returns 0 on success, or -1 if the scratch memory could not be allocated.
47
+ int nitro_blur_premultiplied_8888(
48
+ uint8_t* pixels, uint32_t width, uint32_t height, size_t stride, double sigma);
49
+
50
+ #ifdef __cplusplus
51
+ }
52
+ #endif
@@ -0,0 +1,50 @@
1
+ //
2
+ // GaussianBlurJni.cpp
3
+ // NitroImagePipeline
4
+ //
5
+ // JNI entry point for the blur kernel, called from
6
+ // transform/BlurTransformation.kt with a mutable ARGB_8888 bitmap.
7
+ //
8
+
9
+ #include <android/bitmap.h>
10
+ #include <android/log.h>
11
+ #include <jni.h>
12
+
13
+ #include "GaussianBlur.hpp"
14
+
15
+ namespace {
16
+ constexpr const char* kTag = "NitroImagePipeline";
17
+ }
18
+
19
+ extern "C" JNIEXPORT jboolean JNICALL
20
+ Java_com_margelo_nitro_nitroimagepipeline_transform_BlurTransformation_nativeBlur(
21
+ JNIEnv* env, jclass, jobject bitmap, jfloat sigma) {
22
+ AndroidBitmapInfo info;
23
+ if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
24
+ __android_log_print(ANDROID_LOG_ERROR, kTag, "blur: AndroidBitmap_getInfo failed");
25
+ return JNI_FALSE;
26
+ }
27
+ if (info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) {
28
+ __android_log_print(
29
+ ANDROID_LOG_ERROR,
30
+ kTag,
31
+ "blur: expected an ARGB_8888 bitmap (RGBA_8888 to the NDK), got NDK format %d",
32
+ info.format);
33
+ return JNI_FALSE;
34
+ }
35
+
36
+ void* pixels = nullptr;
37
+ if (AndroidBitmap_lockPixels(env, bitmap, &pixels) != ANDROID_BITMAP_RESULT_SUCCESS ||
38
+ pixels == nullptr) {
39
+ __android_log_print(ANDROID_LOG_ERROR, kTag, "blur: AndroidBitmap_lockPixels failed");
40
+ return JNI_FALSE;
41
+ }
42
+ const int result = nitro_blur_premultiplied_8888(
43
+ static_cast<uint8_t*>(pixels), info.width, info.height, info.stride, sigma);
44
+ AndroidBitmap_unlockPixels(env, bitmap);
45
+ if (result != 0) {
46
+ __android_log_print(ANDROID_LOG_ERROR, kTag, "blur: out of memory for the scratch buffers");
47
+ return JNI_FALSE;
48
+ }
49
+ return JNI_TRUE;
50
+ }
@@ -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,8 +100,7 @@ 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
  }
@@ -182,12 +130,131 @@ class HybridNitroImagePipeline : HybridNitroImagePipelineSpec() {
182
130
  // must be shared across all HybridNitroImagePipeline instances.
183
131
  @Volatile private var sharedImageLoader: ImageLoader? = null
184
132
 
185
- 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 =
186
234
  sharedImageLoader
187
235
  ?: synchronized(this) {
188
236
  sharedImageLoader ?: createImageLoader(context).also { sharedImageLoader = it }
189
237
  }
190
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
+
191
258
  private fun createImageLoader(context: Context): ImageLoader {
192
259
  val okHttpClient =
193
260
  OkHttpClient.Builder()