Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions example/__tests__/image-encoding.harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ import { Images } from 'react-native-nitro-image'
const makeImage = () =>
Images.createBlankImage(16, 16, false, { r: 0, g: 0, b: 1, a: 1 })

const makeDetailedImage = () => {
const size = 64
const bytes = new Uint8Array(size * size * 4)
for (let i = 0; i < size * size; i++) {
bytes[i * 4] = (i * 17) % 256
bytes[i * 4 + 1] = (i * 31) % 256
bytes[i * 4 + 2] = (i * 47) % 256
bytes[i * 4 + 3] = 255
}
return Images.loadFromRawPixelData({
buffer: bytes.buffer,
width: size,
height: size,
pixelFormat: 'RGBA',
})
}

const expectTemporaryPath = (path: string, extension: 'jpg' | 'png') => {
expect(path.length).toBeGreaterThan(0)
expect(path.startsWith('/')).toBe(true)
Expand Down Expand Up @@ -52,6 +69,45 @@ describe('Image - toEncodedImageData', () => {
expect(encoded.imageFormat).toBe('jpg')
expect(encoded.buffer.byteLength).toBeGreaterThan(0)
})

it('uses a 0...100 JPEG quality range with 100 as the default', () => {
const image = makeDetailedImage()
const lowest = image.toEncodedImageData('jpg', 0)
const highest = image.toEncodedImageData('jpg', 100)
const defaultQuality = image.toEncodedImageData('jpg')

expect(lowest.buffer.byteLength).toBeLessThan(highest.buffer.byteLength)
expect(Array.from(new Uint8Array(defaultQuality.buffer))).toEqual(
Array.from(new Uint8Array(highest.buffer)),
)
})

it('rounds fractional JPEG quality to the nearest integer', () => {
const image = makeDetailedImage()
const quality0 = image.toEncodedImageData('jpg', 0)
const quality0Point4 = image.toEncodedImageData('jpg', 0.4)
const quality99Point5 = image.toEncodedImageData('jpg', 99.5)
const quality100 = image.toEncodedImageData('jpg', 100)

expect(Array.from(new Uint8Array(quality0Point4.buffer))).toEqual(
Array.from(new Uint8Array(quality0.buffer)),
)
expect(Array.from(new Uint8Array(quality99Point5.buffer))).toEqual(
Array.from(new Uint8Array(quality100.buffer)),
)
})

it('rejects quality values outside 0...100', async () => {
const image = makeImage()
expect(() => image.toEncodedImageData('jpg', -0.5)).toThrow()
expect(() => image.toEncodedImageData('jpg', 100.5)).toThrow()
await expect(
image.toEncodedImageDataAsync('jpg', -0.5),
).rejects.toBeDefined()
await expect(
image.saveToTemporaryFileAsync('jpg', 100.5),
).rejects.toBeDefined()
})
})

describe('Images - loadFromEncodedImageData', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ import com.margelo.nitro.image.extensions.toMutable
import java.io.File
import java.nio.ByteBuffer
import kotlin.math.ceil
import kotlin.math.roundToInt

private fun resolveImageQuality(quality: Double?): Int {
val resolved = quality ?: 100.0
if (!resolved.isFinite() || resolved < 0.0 || resolved > 100.0) {
throw Error("Image quality has to be between 0 and 100! (Received: $resolved)")
}
return resolved.roundToInt()
}

@Suppress("ConvertSecondaryConstructorToPrimary")
@Keep
Expand Down Expand Up @@ -71,8 +80,8 @@ class HybridImage: HybridImageSpec {
}

override fun toEncodedImageData(format: ImageFormat, quality: Double?): EncodedImageData {
val quality = quality ?: 100.0
val byteBuffer = bitmap.compressInMemory(format, quality.toInt())
val resolvedQuality = resolveImageQuality(quality)
val byteBuffer = bitmap.compressInMemory(format, resolvedQuality)
val arrayBuffer = ArrayBuffer.copy(byteBuffer)
return EncodedImageData(arrayBuffer, width, height, format)
}
Expand Down Expand Up @@ -170,17 +179,17 @@ class HybridImage: HybridImageSpec {
format: ImageFormat,
quality: Double?
): Promise<Unit> {
val quality = quality ?: 100.0
return Promise.async {
bitmap.saveToFile(path.toFilePath(), format, quality.toInt())
val resolvedQuality = resolveImageQuality(quality)
bitmap.saveToFile(path.toFilePath(), format, resolvedQuality)
}
}

override fun saveToTemporaryFileAsync(format: ImageFormat, quality: Double?): Promise<String> {
val quality = quality ?: 100.0
return Promise.async {
val resolvedQuality = resolveImageQuality(quality)
val tempFile = File.createTempFile("nitro_image_", ".${format.name.lowercase()}")
bitmap.saveToFile(tempFile.path, format, quality.toInt())
bitmap.saveToFile(tempFile.path, format, resolvedQuality)
return@async tempFile.path
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@ import NitroModules
extension UIImage {
/**
* Convert/Compress this Image into the given `format`.
* `quality` specifies compression quality from 0(most)...100(least).
* `quality` specifies compression quality from 0 (worst) to 100 (best).
*/
func getData(in format: ImageFormat, quality: CGFloat) throws -> Data {
guard quality.isFinite, quality >= 0, quality <= 100 else {
throw RuntimeError.error(withMessage: "Image quality has to be between 0 and 100! (Received: \(quality))")
}
let resolvedQuality = quality.rounded()

switch format {
case .jpg:
guard quality >= 0 && quality <= 100 else {
throw RuntimeError.error(withMessage: "Image quality has to be between 0 and 100! (Received: \(quality))")
}
let qualityNormalized = quality / 100.0
let qualityNormalized = resolvedQuality / 100.0
guard let data = self.jpegData(compressionQuality: qualityNormalized) else {
throw RuntimeError.error(withMessage: "Failed to compress \(size.width)x\(size.height) Image to JPEG! (Quality: \(quality))")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ extension UIImage {
/**
* Returns encoded Image data of this Image (JPG, PNG, ...)
*/
func toEncodedImageData(format: ImageFormat, quality: Double = 1.0) throws -> EncodedImageData {
func toEncodedImageData(format: ImageFormat, quality: Double) throws -> EncodedImageData {
let data = try getData(in: format, quality: quality)
let arrayBuffer = try ArrayBuffer.copy(data: data)
return EncodedImageData(buffer: arrayBuffer,
Expand Down
2 changes: 1 addition & 1 deletion packages/react-native-nitro-image/src/NativeNitroImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {
* @example
* ```tsx
* function App() {
* const image = useImage('https://picsum.photos/seed/123/400')
* const { image } = useImage({ url: 'https://picsum.photos/seed/123/400' })
* return <NativeNitroImage image={image} style={{ width: 100, height: 100 }} />
* }
* ```
Expand Down
19 changes: 11 additions & 8 deletions packages/react-native-nitro-image/src/specs/Image.nitro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export interface Image
readonly height: number

/**
* Returns an {@linkcode ArrayBuffer} containing the raw pixel data of the Image.
* Returns {@linkcode RawPixelData} containing the raw pixel data of the Image in an {@linkcode ArrayBuffer}.
* @note The returned {@linkcode PixelFormat} describes the literal byte order in memory -
* always read it instead of assuming a fixed format. It is typically
* {@linkcode PixelFormat | 'RGBA'} on Android (the memory layout of `ARGB_8888` Bitmaps),
Expand All @@ -96,11 +96,12 @@ export interface Image
toRawPixelDataAsync(allowGpu?: boolean): Promise<RawPixelData>

/**
* Returns an {@linkcode ArrayBuffer} containing the encoded data of an Image in
* Returns {@linkcode EncodedImageData} containing the encoded data of an Image in
* the requested container {@linkcode format}.
* @note If the requested {@linkcode format} is {@linkcode ImageFormat | 'jpg'}, you can use
* {@linkcode quality} to compress the image. Quality ranges from 0(most)...100(least). In {@linkcode ImageFormat | 'png'}, the
* {@linkcode quality} to compress the image. Quality ranges from `0` (worst) to `100` (best), rounds to the nearest integer, and defaults to `100`. In {@linkcode ImageFormat | 'png'}, the
* {@linkcode quality} flag is ignored.
* @throws If {@linkcode quality} is outside the `0...100` range.
* @example
* ```ts
* const compressed = image.toEncodedImageData('jpg', 70)
Expand All @@ -127,7 +128,7 @@ export interface Image
* the newly created {@linkcode Image}.
*
* @param degrees The degrees to rotate the Image. May be any arbitrary number, and can be negative.
* @param allowFastFlagRotation When {@linkcode allowFastFlagRotation} is set to `true`, the implementation may choose to only change the orientation flag on the underying image instead of physicaly rotating the buffers. This may only work when {@linkcode degrees} is a multiple of `90`, and will only apply rotation when displaying the Image (via view transforms) or exporting it to a file (via EXIF flags). The actual buffer (e.g. obtained via {@linkcode toRawPixelData | toRawPixelData()}) may remain untouched.
* @param allowFastFlagRotation When {@linkcode allowFastFlagRotation} is set to `true`, the implementation may choose to only change the orientation flag on the underlying image instead of physically rotating the buffers. This may only work when {@linkcode degrees} is a multiple of `90`, and will only apply rotation when displaying the Image (via view transforms) or exporting it to a file (via EXIF flags). The actual buffer (e.g. obtained via {@linkcode toRawPixelData | toRawPixelData()}) may remain untouched.
* @example
* ```ts
* const upsideDown = image.rotate(180)
Expand Down Expand Up @@ -166,11 +167,12 @@ export interface Image
/**
* Saves this image in the given {@linkcode ImageFormat} to the given filesystem {@linkcode path}.
*
* @param path A filesystem path including filename and extension - for example: `/tmp/image.jpg`. This is not a URL, so omit the `file://` prefix. The file extension is not authorative and does not affect encoding/file format - but typically it should be the same extension as the {@linkcode ImageFormat} passed to the {@linkcode format} parameter.
* @param path A filesystem path including filename and extension - for example: `/tmp/image.jpg`. This is not a URL, so omit the `file://` prefix. The file extension is not authoritative and does not affect encoding/file format, but typically it should match the {@linkcode ImageFormat} passed to the {@linkcode format} parameter.
*
* @param format The {@linkcode ImageFormat} to use for encoding this Image to a file.
*
* @param quality The target Image quality to use, from `0` (worst quality/highest compression) to `100` (best quality/least compression). {@linkcode quality} is ignored for non-compressable {@linkcode ImageFormat}s, such as {@linkcode ImageFormat | 'png'}.
* @param quality The target Image quality to use, from `0` (worst quality/highest compression) to `100` (best quality/least compression), rounded to the nearest integer. Defaults to `100`. {@linkcode quality} is ignored for non-compressible {@linkcode ImageFormat}s, such as {@linkcode ImageFormat | 'png'}.
* @throws If {@linkcode quality} is outside the `0...100` range.
*
* @example
* ```ts
Expand All @@ -188,7 +190,8 @@ export interface Image
*
* @param format The {@linkcode ImageFormat} to use for encoding this Image to a file. The given {@linkcode ImageFormat} will also be used as the generated file's extension.
*
* @param quality The target Image quality to use, from `0` (worst quality/highest compression) to `100` (best quality/least compression). {@linkcode quality} is ignored for non-compressable {@linkcode ImageFormat}s, such as {@linkcode ImageFormat | 'png'}.
* @param quality The target Image quality to use, from `0` (worst quality/highest compression) to `100` (best quality/least compression), rounded to the nearest integer. Defaults to `100`. {@linkcode quality} is ignored for non-compressible {@linkcode ImageFormat}s, such as {@linkcode ImageFormat | 'png'}.
* @throws If {@linkcode quality} is outside the `0...100` range.
*
* @returns A filesystem path such as `/tmp/image.jpg`. This is not a URL, so the returned path does not have a `file://` prefix. If another API needs a file URL, prepend `file://`, for example `fetch('file://' + path)`.
*
Expand All @@ -206,7 +209,7 @@ export interface Image
/**
* Encodes this Image into a ThumbHash.
* To convert the returned ThumbHash to a string, use `thumbHashToBase64String(...)`.
* @note To keep this efficient, {@linkcode resize} this image to a small size (<100x100) first.
* @note Resize this image to `100x100` or smaller first. Android rejects larger Images.
* @example
* ```ts
* const small = image.resize(100, 100)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,20 +86,20 @@ export interface ImageFactory

/**
* Synchronously loads an {@linkcode Image} from the given {@linkcode RawPixelData}'s {@linkcode ArrayBuffer}.
* @param data The {@linkcode RawPixelData} object carrying the **raw** RGB image data and describing it's format.
* @param data The {@linkcode RawPixelData} object carrying the **raw** RGB image data and describing its format.
* @param allowGpu If `allowGpu` is set to `true` and the given {@linkcode data} is a GPU-buffer, the {@linkcode Image}
* might be wrapping the given GPU-buffer without performing a copy. By default, `allowGpu` is `false`
* @throws If the given {@linkcode RawPixelData} is not a valid RGB buffer representing an {@linkcode Image}.
* @note The given pixel data has to have pre-multiplied alpha, and be some kind of RGB format with 4-bytes-per-pixel.
* @note The given pixel data has to have pre-multiplied alpha, and be some kind of RGB format with 3- or 4-bytes-per-pixel.
*/
loadFromRawPixelData(data: RawPixelData, allowGpu?: boolean): Image
/**
* Asynchronously loads an {@linkcode Image} from the given {@linkcode RawPixelData}'s {@linkcode ArrayBuffer}.
* @param data The {@linkcode RawPixelData} object carrying the **raw** RGB image data and describing it's format.
* @param data The {@linkcode RawPixelData} object carrying the **raw** RGB image data and describing its format.
* @param allowGpu If `allowGpu` is set to `true` and the given {@linkcode data} is a GPU-buffer, the {@linkcode Image}
* might be wrapping the given GPU-buffer without performing a copy. By default, `allowGpu` is `false`
* @throws If the given {@linkcode RawPixelData} is not a valid RGB buffer representing an {@linkcode Image}.
* @note The given pixel data has to have pre-multiplied alpha, and be some kind of RGB format with 4-bytes-per-pixel.
* @note The given pixel data has to have pre-multiplied alpha, and be some kind of RGB format with 3- or 4-bytes-per-pixel.
*/
loadFromRawPixelDataAsync(
data: RawPixelData,
Expand All @@ -108,21 +108,21 @@ export interface ImageFactory

/**
* Synchronously loads an {@linkcode Image} from the given {@linkcode EncodedImageData}'s {@linkcode ArrayBuffer}.
* @param buffer The ArrayBuffer carrying the encoded Image data in any supported image format (JPG, PNG, ...)
* @param data The ArrayBuffer carrying the encoded Image data in any supported image format (JPG, PNG, ...)
* @throws If the given {@linkcode EncodedImageData} is not a valid representation of an {@linkcode Image}.
*/
loadFromEncodedImageData(data: EncodedImageData): Image
/**
* Asynchronously loads an {@linkcode Image} from the given {@linkcode EncodedImageData}'s {@linkcode ArrayBuffer}.
* @param buffer The ArrayBuffer carrying the encoded Image data in any supported image format (JPG, PNG, ...)
* @param data The ArrayBuffer carrying the encoded Image data in any supported image format (JPG, PNG, ...)
* @throws If the given {@linkcode EncodedImageData} is not a valid representation of an {@linkcode Image}.
*/
loadFromEncodedImageDataAsync(data: EncodedImageData): Promise<Image>

/**
* Synchronously decodes the given {@linkcode thumbhash} (and {@linkcode ArrayBuffer})
* Synchronously decodes the given {@linkcode thumbhash} (an {@linkcode ArrayBuffer})
* into an {@linkcode Image}.
* @param buffer The ArrayBuffer carrying the ThumbHash's data
* @param thumbhash The ArrayBuffer carrying the ThumbHash's data
* @throws If the given {@linkcode thumbhash} is not a valid ThumbHash.
*/
loadFromThumbHash(thumbhash: ArrayBuffer): Image
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { ImageLoader } from './ImageLoader.nitro'
* - `cover`: Scale the content to fill the size of the view. Some portion of the content may be clipped to fill the view’s bounds.
* - `contain`: Scale the content to fit the size of the view by maintaining the aspect ratio. Any remaining area of the view’s bounds is transparent.
* - `center`: Center the content in the view’s bounds, keeping the proportions the same.
* - `stretch`: Scale the content to fit the size of itself by changing the aspect ratio of the content if necessary.
* - `stretch`: Scale the content to fill the view's bounds, changing the content's aspect ratio if necessary.
*/
export type ResizeMode = 'cover' | 'contain' | 'center' | 'stretch'

Expand Down Expand Up @@ -51,7 +51,7 @@ export interface NativeNitroImageViewProps extends HybridViewProps {
* @default undefined
* @example
* ```tsx
* <NitroImage recyclingKey={url} />
* <NitroImage image={{ url }} recyclingKey={url} />
* ```
*/
recyclingKey?: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export interface AsyncImageLoadOptions {
priority?: AsyncImagePriority

/**
* Forces a cache refresh even if the URL is changed.
* Use this if you cannot make your URLs static.
* Forces a cache refresh even if the URL has not changed.
* Use this when the content can change without a cache-busting URL.
* @default false
*/
forceRefresh?: boolean
Expand Down
Loading