react-native-nitro-image-pipeline 1.3.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +103 -11
  2. package/android/CMakeLists.txt +4 -0
  3. package/android/src/main/cpp/GaussianBlur.cpp +165 -0
  4. package/android/src/main/cpp/GaussianBlur.hpp +52 -0
  5. package/android/src/main/cpp/GaussianBlurJni.cpp +50 -0
  6. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/HybridNitroImagePipeline.kt +153 -74
  7. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/PipelineImageLoader.kt +177 -0
  8. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/BlurTransformation.kt +36 -81
  9. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/HardwareBitmapTransformation.kt +45 -0
  10. package/ios/GaussianBlur.swift +48 -9
  11. package/ios/HybridNitroImagePipeline.swift +79 -33
  12. package/ios/PipelineImageLoader.swift +195 -0
  13. package/ios/RoundedCornersProcessor.swift +5 -0
  14. package/lib/commonjs/NativePipelineImage.js +71 -0
  15. package/lib/commonjs/NativePipelineImage.js.map +1 -0
  16. package/lib/commonjs/index.js +14 -0
  17. package/lib/commonjs/index.js.map +1 -1
  18. package/lib/commonjs/usePipelineImageLoader.js +69 -0
  19. package/lib/commonjs/usePipelineImageLoader.js.map +1 -0
  20. package/lib/module/NativePipelineImage.js +67 -0
  21. package/lib/module/NativePipelineImage.js.map +1 -0
  22. package/lib/module/index.js +2 -0
  23. package/lib/module/index.js.map +1 -1
  24. package/lib/module/usePipelineImageLoader.js +65 -0
  25. package/lib/module/usePipelineImageLoader.js.map +1 -0
  26. package/lib/typescript/src/NativePipelineImage.d.ts +66 -0
  27. package/lib/typescript/src/NativePipelineImage.d.ts.map +1 -0
  28. package/lib/typescript/src/index.d.ts +3 -1
  29. package/lib/typescript/src/index.d.ts.map +1 -1
  30. package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts +54 -1
  31. package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts.map +1 -1
  32. package/lib/typescript/src/usePipelineImageLoader.d.ts +15 -0
  33. package/lib/typescript/src/usePipelineImageLoader.d.ts.map +1 -0
  34. package/nitrogen/generated/android/c++/JHybridNitroImagePipelineSpec.cpp +17 -0
  35. package/nitrogen/generated/android/c++/JHybridNitroImagePipelineSpec.hpp +2 -0
  36. package/nitrogen/generated/android/c++/JViewOptions.hpp +77 -0
  37. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/HybridNitroImagePipelineSpec.kt +9 -0
  38. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ViewOptions.kt +66 -0
  39. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.cpp +10 -0
  40. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.hpp +53 -0
  41. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Umbrella.hpp +8 -0
  42. package/nitrogen/generated/ios/c++/HybridNitroImagePipelineSpecSwift.hpp +20 -0
  43. package/nitrogen/generated/ios/swift/HybridNitroImagePipelineSpec.swift +2 -0
  44. package/nitrogen/generated/ios/swift/HybridNitroImagePipelineSpec_cxx.swift +26 -0
  45. package/nitrogen/generated/ios/swift/ViewOptions.swift +101 -0
  46. package/nitrogen/generated/shared/c++/HybridNitroImagePipelineSpec.cpp +2 -0
  47. package/nitrogen/generated/shared/c++/HybridNitroImagePipelineSpec.hpp +8 -0
  48. package/nitrogen/generated/shared/c++/ViewOptions.hpp +104 -0
  49. package/package.json +4 -3
  50. package/src/NativePipelineImage.tsx +103 -0
  51. package/src/index.ts +7 -0
  52. package/src/specs/nitro-image-toolkit.nitro.ts +56 -1
  53. package/src/usePipelineImageLoader.ts +82 -0
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,26 @@ 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.
366
+
367
+ ### `setMemoryCacheLimit(bytes)`
368
+
369
+ Caps the in-memory cache of decoded bitmaps at `bytes`, evicting least-recently-used entries
370
+ immediately if the cache is currently larger. Pass `0` to disable in-memory caching entirely — the
371
+ disk cache keeps working. Synchronous; throws on negative or non-finite values.
372
+
373
+ ```ts
374
+ // Keep at most 32 MB of decoded bitmaps in RAM
375
+ NitroImagePipeline.setMemoryCacheLimit(32 * 1024 * 1024);
376
+
377
+ // Or opt out of decoded-bitmap caching altogether
378
+ NitroImagePipeline.setMemoryCacheLimit(0);
379
+ ```
308
380
 
309
381
  ### `clearCache()`
310
382
 
@@ -318,14 +390,34 @@ The pipeline is set up so RAM scales with what you display, not with what you do
318
390
  size known, both platforms decode the source *near that size* instead of at full resolution —
319
391
  iOS via a downsampled thumbnail decode, Android via Coil's subsampling. Without `resize`, a
320
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.
321
402
  - **Prefetching stores bytes, not bitmaps.** `preLoadImage(s)` writes the download to the disk
322
403
  cache and skips decoding entirely.
323
- - **The in-memory cache is capped** (128 MB on iOS, 25% of the app's memory class on Android),
324
- holds decoded bitmaps for instant re-display, and evicts least-recently-used entries — also in
325
- response to memory warnings and backgrounding. Memory profilers attribute this cache to the app;
326
- a plateau at the cap is expected and evictable, not a leak. Use `cache: 'disk'` or
327
- `cache: 'none'` on images you know you won't show again soon, and `clearCache()` to drop
328
- everything.
404
+ - **The in-memory cache is capped and tunable** (defaults: 128 MB on iOS, 25% of the app's memory
405
+ class on Android). It holds decoded bitmaps for instant re-display and evicts
406
+ least-recently-used entries — also in response to memory warnings and backgrounding. Memory
407
+ profilers attribute this cache to the app; a plateau at the cap is expected and evictable, not a
408
+ leak. Lower the cap with [`setMemoryCacheLimit`](#setmemorycachelimitbytes), use `cache: 'disk'`
409
+ or `cache: 'none'` on images you won't show again soon, and `clearCache()` to drop everything.
410
+ - **Coming from a setup with no decoded-image cache** (e.g. loading files you downloaded
411
+ yourself)? Steady-state RAM will read higher here *by design*: after screens unmount, the cache
412
+ keeps their bitmaps around for instant re-display. For the old memory profile with the
413
+ pipeline's features intact, pass `cache: 'disk'` on your requests or call
414
+ `setMemoryCacheLimit(0)` once — RAM then holds only the images currently referenced, and
415
+ re-displays decode from the disk cache (cheap, since decodes are subsampled to the target size).
416
+ - **Android + `Image.dispose()`:** only call `dispose()` on images loaded with `cache: 'disk'` /
417
+ `cache: 'none'` (or with the memory cache disabled). With the memory cache on, the returned
418
+ image shares its bitmap with the cache, and disposing recycles a bitmap the cache may serve
419
+ again. Without `dispose()`, images are freed by the JS garbage collector — their bitmap size is
420
+ reported to it, so unreferenced images do get collected under pressure.
329
421
 
330
422
  ## Upgrading from 0.3.x
331
423
 
@@ -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
+ }