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.
- package/README.md +72 -5
- 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 +141 -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 +63 -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 +44 -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 +13 -0
- package/nitrogen/generated/android/c++/JHybridNitroImagePipelineSpec.hpp +1 -0
- package/nitrogen/generated/android/c++/JViewOptions.hpp +77 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/HybridNitroImagePipelineSpec.kt +5 -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 +44 -0
- package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Umbrella.hpp +8 -0
- package/nitrogen/generated/ios/c++/HybridNitroImagePipelineSpecSwift.hpp +14 -0
- package/nitrogen/generated/ios/swift/HybridNitroImagePipelineSpec.swift +1 -0
- package/nitrogen/generated/ios/swift/HybridNitroImagePipelineSpec_cxx.swift +15 -0
- package/nitrogen/generated/ios/swift/ViewOptions.swift +101 -0
- package/nitrogen/generated/shared/c++/HybridNitroImagePipelineSpec.cpp +1 -0
- package/nitrogen/generated/shared/c++/HybridNitroImagePipelineSpec.hpp +7 -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 +45 -1
- 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:
|
|
304
|
-
(the standard three-box Gaussian approximation, accurate to a few percent)
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
package/android/CMakeLists.txt
CHANGED
|
@@ -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
|
+
}
|
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,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
|
-
|
|
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()
|