Skip to content

Commit 78c2054

Browse files
authored
feat: Add HEIC to Image Formats (#71)
* fix: Fix build because of missing `allowGpu` * feat: Add `HEIC` to `ImageFormat`s * Add `supportsHeicLoading` * Make quality go from 0...100 * Use it on Android * fix: Fix `toByteArray`
1 parent 93198dd commit 78c2054

30 files changed

Lines changed: 270 additions & 65 deletions

README.md

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,22 @@ const image = useImage(require('./image.png'))
112112

113113
#### `ArrayBuffer`
114114

115-
The `Image` type can be converted to- and from- an `ArrayBuffer`, which gives you access to the raw pixel data in ARGB format:
115+
The `Image` type can be converted to- and from- an `ArrayBuffer`, which gives you access to the raw pixel data in an RGB format:
116116

117117
```ts
118118
const image = // ...
119-
const arrayBuffer = await image.toArrayBufferAsync()
120-
const sameImageCopied = await Images.loadFromArrayBufferAsync(arrayBuffer)
119+
const pixelData = await image.toRawPixelData()
120+
const sameImageCopied = await Images.loadFromRawPixelData(pixelData)
121+
```
122+
123+
#### `EncodedImageData`
124+
125+
The `Image` type can be encoded to- and decoded from- an `ArrayBuffer` using a container format like `jpg`, `png` or `heic`:
126+
127+
```ts
128+
const image = // ...
129+
const imageData = await image.toEncodedImageData('jpg', 90)
130+
const sameImageCopied = await Images.loadFromEncodedImageData(imageData)
121131
```
122132

123133
#### Resizing
@@ -143,8 +153,37 @@ const smaller = await webImage.cropAsync(100, 100, 50, 50)
143153
An in-memory `Image` object can also be written/saved to a file:
144154

145155
```ts
146-
const smaller = ...
147-
const path = await smaller.saveToTemporaryFileAsync('jpg', 90)
156+
const image = ...
157+
const path = await image.saveToTemporaryFileAsync('jpg', 90)
158+
```
159+
160+
#### Compressing
161+
162+
Images can be compressed using the `jpg` container format - either in-memory or when writing to a file:
163+
164+
```ts
165+
const image = ...
166+
const path = await image.saveToTemporaryFileAsync('jpg', 50) // 50% compression
167+
const compressed = await image.toEncodedImageData('jpg', 50) // 50% compression
168+
```
169+
170+
#### HEIC/HEIF
171+
172+
NitroImage supports `HEIC`/`HEIF` format if the host OS natively supports it.
173+
174+
| | iOS | Android |
175+
|--------------|----------------|----------------|
176+
| Loading HEIC || ✅ (>= SDK 28) |
177+
| Writing HEIC | ✅ (>= iOS 17) ||
178+
179+
You can check whether your OS supports `HEIC` via NitroImage:
180+
181+
```ts
182+
import { supportsHeicWriting } from 'react-native-nitro-modules'
183+
184+
const image = ...
185+
const format = supportsHeicWriting ? 'heic' : 'jpg'
186+
const path = await image.saveToTemporaryFileAsync(format, 100)
148187
```
149188

150189
### Hooks

packages/react-native-nitro-image/android/src/main/java/com/margelo/nitro/image/ArrayBuffer+toByteArray.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,15 @@ fun ArrayBuffer.toByteArray(): ByteArray {
77
val buffer = this.getBuffer(false)
88
if (buffer.hasArray()) {
99
// It's a CPU-backed array - we can return this directly
10-
return buffer.array()
10+
val array = buffer.array()
11+
if (array.size == this.size) {
12+
// The CPU-backed array is exactly the view we have in our ArrayBuffer.
13+
// Return as is!
14+
return array
15+
}
16+
// we had a CPU-backed array, but it's size differs from our ArrayBuffer size.
17+
// This might be because the ArrayBuffer has a smaller view of the data, so we need
18+
// to resort back to a good ol' copy.
1119
}
1220
// It's not a CPU-backed array (e.g. HardwareBuffer) - we need to copy to the CPU
1321
val copy = ByteBuffer.allocate(buffer.capacity())
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.margelo.nitro.image
2+
3+
import android.graphics.Bitmap
4+
import java.nio.ByteBuffer
5+
6+
fun Bitmap.compressInMemory(format: ImageFormat, quality: Int): ByteBuffer {
7+
if (quality < 0 || quality > 100) {
8+
throw Error("Image quality has to be between 0 and 100! (Received: $quality)")
9+
}
10+
val estimatedByteSize = when (format) {
11+
ImageFormat.JPG -> (width * height) / 2
12+
ImageFormat.PNG -> width * height
13+
ImageFormat.HEIC -> width * height
14+
}
15+
16+
FastByteArrayOutputStream(estimatedByteSize).use { out ->
17+
val successful = this.compress(format.toBitmapFormat(), quality, out)
18+
if (!successful) {
19+
throw Error("Failed to compress the Bitmap into EncodedImageData! (Format: ${format.name}, " +
20+
"Quality: ${quality}, Written Bytes: ${out.count})")
21+
}
22+
return out.toByteBuffer()
23+
}
24+
}

packages/react-native-nitro-image/android/src/main/java/com/margelo/nitro/image/Bitmap+saveToFile.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,24 @@ fun ImageFormat.toBitmapFormat(): Bitmap.CompressFormat {
88
return when (this) {
99
ImageFormat.JPG -> Bitmap.CompressFormat.JPEG
1010
ImageFormat.PNG -> Bitmap.CompressFormat.PNG
11+
ImageFormat.HEIC -> {
12+
throw Error("Saving Images as HEIC is not yet supported on Android!")
13+
}
1114
}
1215
}
1316

1417
fun Bitmap.saveToFile(path: String, format: ImageFormat, quality: Int) {
18+
if (quality < 0 || quality > 100) {
19+
throw Error("Image quality has to be between 0 and 100! (Received: $quality)")
20+
}
1521
// 1. Make sure all parent directories exist
1622
File(path).parentFile?.mkdirs()
1723
// 2. Create a file output stream
1824
FileOutputStream(path).use { out ->
1925
val bitmapFormat = format.toBitmapFormat()
20-
this.compress(bitmapFormat, quality, out)
26+
val successful = this.compress(bitmapFormat, quality, out)
27+
if (!successful) {
28+
throw Error("Failed to compress ${width}x${height} Image to file (\"$path\")! (Format: $format, Quality: $quality)")
29+
}
2130
}
2231
}

packages/react-native-nitro-image/android/src/main/java/com/margelo/nitro/image/HybridImage.kt

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,9 @@ class HybridImage: HybridImageSpec {
5656
}
5757

5858
override fun toEncodedImageData(format: ImageFormat, quality: Double?): EncodedImageData {
59-
val quality = quality ?: 1.0
60-
val estimatedByteSize = when (format) {
61-
ImageFormat.JPG -> (width * height) / 2
62-
ImageFormat.PNG -> width * height
63-
}
64-
val outputStream = FastByteArrayOutputStream(estimatedByteSize.toInt())
65-
val successful = bitmap.compress(format.toBitmapFormat(), quality.toInt(), outputStream)
66-
if (!successful) {
67-
throw Error("Failed to compress the Bitmap into EncodedImageData! (Format: ${format.name}, " +
68-
"Quality: ${quality}, Written Bytes: ${outputStream.count})")
69-
}
70-
val byteBuffer = outputStream.toByteBuffer()
71-
val arrayBuffer = ArrayBuffer.wrap(byteBuffer)
59+
val quality = quality ?: 100.0
60+
val byteBuffer = bitmap.compressInMemory(format, quality.toInt())
61+
val arrayBuffer = ArrayBuffer.copy(byteBuffer)
7262
return EncodedImageData(arrayBuffer, width, height, format)
7363
}
7464
override fun toEncodedImageDataAsync(
@@ -125,16 +115,17 @@ class HybridImage: HybridImageSpec {
125115
format: ImageFormat,
126116
quality: Double?
127117
): Promise<Unit> {
128-
val quality = quality ?: 1.0
118+
val quality = quality ?: 100.0
129119
return Promise.async {
130120
bitmap.saveToFile(path, format, quality.toInt())
131121
}
132122
}
133123

134124
override fun saveToTemporaryFileAsync(format: ImageFormat, quality: Double?): Promise<String> {
125+
val quality = quality ?: 100.0
135126
return Promise.async {
136127
val tempFile = File.createTempFile("nitro_image_", format.name)
137-
this.saveToFileAsync(tempFile.path, format, quality)
128+
bitmap.saveToFile(tempFile.path, format, quality.toInt())
138129
return@async tempFile.path
139130
}
140131
}

packages/react-native-nitro-image/android/src/main/java/com/margelo/nitro/image/HybridImageFactory.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import android.annotation.SuppressLint
44
import android.graphics.Bitmap
55
import android.graphics.BitmapFactory
66
import androidx.annotation.Keep
7+
import androidx.core.graphics.createBitmap
78
import com.facebook.common.internal.DoNotStrip
8-
import com.facebook.react.bridge.ReactApplicationContext
99
import com.madebyevan.thumbhash.ThumbHash
1010
import com.margelo.nitro.NitroModules
1111
import com.margelo.nitro.core.ArrayBuffer
@@ -56,6 +56,9 @@ class HybridImageFactory: HybridImageFactorySpec() {
5656

5757
private fun loadFromEncodedBytes(bytes: ByteArray): HybridImageSpec {
5858
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
59+
if (bitmap == null) {
60+
throw Error("Failed to decode EncodedImageData to an Image! (Bytes: ${bytes.size})")
61+
}
5962
return HybridImage(bitmap)
6063
}
6164
override fun loadFromEncodedImageData(data: EncodedImageData): HybridImageSpec {
@@ -69,6 +72,9 @@ class HybridImageFactory: HybridImageFactorySpec() {
6972

7073
override fun loadFromFile(filePath: String): HybridImageSpec {
7174
val bitmap = BitmapFactory.decodeFile(filePath)
75+
if (bitmap == null) {
76+
throw Error("Failed to load Image from file! (Path: $filePath)")
77+
}
7278
return HybridImage(bitmap)
7379
}
7480

@@ -79,7 +85,7 @@ class HybridImageFactory: HybridImageFactorySpec() {
7985
private fun loadFromThumbHash(thumbHashBytes: ByteArray): HybridImage {
8086
val rgba = ThumbHash.thumbHashToRGBA(thumbHashBytes)
8187

82-
val bitmap = Bitmap.createBitmap(rgba.width, rgba.height, Bitmap.Config.ARGB_8888)
88+
val bitmap = createBitmap(rgba.width, rgba.height, Bitmap.Config.ARGB_8888)
8389
val buffer = ByteBuffer.wrap(rgba.rgba)
8490
bitmap.copyPixelsFromBuffer(buffer)
8591
return HybridImage(bitmap)

packages/react-native-nitro-image/android/src/main/java/com/margelo/nitro/image/HybridImageUtils.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.margelo.nitro.image
22

3+
import android.os.Build
34
import androidx.annotation.Keep
45
import com.facebook.common.internal.DoNotStrip
56
import com.margelo.nitro.core.ArrayBuffer
@@ -10,6 +11,19 @@ import kotlin.io.encoding.ExperimentalEncodingApi
1011
@DoNotStrip
1112
@Keep
1213
class HybridImageUtils: HybridImageUtilsSpec() {
14+
override val supportsHeicLoading: Boolean
15+
get() {
16+
// Since Android 10, HEIF/HEIC is standard.
17+
// https://source.android.com/docs/core/camera/heif
18+
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
19+
}
20+
override val supportsHeicWriting: Boolean
21+
get() {
22+
// Android does not support saving HEIF data yet
23+
return false
24+
}
25+
26+
1327
@OptIn(ExperimentalEncodingApi::class)
1428
override fun thumbHashToBase64String(thumbhash: ArrayBuffer): String {
1529
val buffer = thumbhash.toByteArray()

packages/react-native-nitro-image/ios/HybridImageFactory.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import Foundation
99
import NitroModules
10+
import UniformTypeIdentifiers
1011

1112
class HybridImageFactory: HybridImageFactorySpec {
1213
/**
@@ -55,19 +56,19 @@ class HybridImageFactory: HybridImageFactorySpec {
5556
/**
5657
* Load Image from the given raw ArrayBuffer data
5758
*/
58-
func loadFromRawPixelData(data: RawPixelData) throws -> any HybridImageSpec {
59+
func loadFromRawPixelData(data: RawPixelData, allowGpu _ : Bool?) throws -> any HybridImageSpec {
5960
let uiImage = try UIImage(fromRawPixelData: data)
6061
return HybridImage(uiImage: uiImage)
6162
}
6263

63-
func loadFromRawPixelDataAsync(data: RawPixelData) throws -> Promise<any HybridImageSpec> {
64+
func loadFromRawPixelDataAsync(data: RawPixelData, allowGpu: Bool?) throws -> Promise<any HybridImageSpec> {
6465
let dataCopy = data.buffer.isOwner ? data.buffer : ArrayBuffer.copy(of: data.buffer)
6566
let newData = RawPixelData(buffer: dataCopy,
6667
width: data.width,
6768
height: data.height,
6869
pixelFormat: data.pixelFormat)
6970
return Promise.async {
70-
return try self.loadFromRawPixelData(data: newData)
71+
return try self.loadFromRawPixelData(data: newData, allowGpu: allowGpu)
7172
}
7273
}
7374

packages/react-native-nitro-image/ios/HybridImageLoaderFactory.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class HybridImageLoaderFactory: HybridImageLoaderFactorySpec {
3131

3232
func createRawPixelDataImageLoader(data: RawPixelData) throws -> any HybridImageLoaderSpec {
3333
return HybridImageLoader(load: {
34-
try self.imageFactory.loadFromRawPixelDataAsync(data: data)
34+
try self.imageFactory.loadFromRawPixelDataAsync(data: data, allowGpu: false)
3535
})
3636
}
3737

packages/react-native-nitro-image/ios/HybridImageUtils.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,23 @@
77

88
import Foundation
99
import NitroModules
10+
import UniformTypeIdentifiers
1011

1112
class HybridImageUtils: HybridImageUtilsSpec {
13+
var supportsHeicLoading: Bool {
14+
// Check if the type is supported by the OS
15+
let types = CGImageDestinationCopyTypeIdentifiers() as! [String]
16+
return types.contains(UTType.heic.identifier)
17+
}
18+
var supportsHeicWriting: Bool {
19+
// HEIC .heicData() is only available on iOS 17
20+
if #available(iOS 17.0, *) {
21+
return true
22+
} else {
23+
return false
24+
}
25+
}
26+
1227
func thumbHashToBase64String(thumbhash: ArrayBuffer) throws -> String {
1328
let data = thumbhash.toData(copyIfNeeded: false)
1429
return data.base64EncodedString()

0 commit comments

Comments
 (0)