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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,15 @@ const webImage = await WebImages.loadFromURLAsync('https://picsum.photos/seed/12
const smaller = await webImage.cropAsync(100, 100, 50, 50)
```

#### Rotating

An `Image` can be rotated entirely in-memory, without ever writing to- or reading from- a file:

```ts
const webImage = await WebImages.loadFromURLAsync('https://picsum.photos/seed/123/400')
const upsideDown = await webImage.rotateAsync(180)
```

#### Render into another Image

An `Image` can be rendered into another `Image` entirely in-memory. This creates a third image (the result):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.margelo.nitro.image

import android.graphics.Bitmap

fun Bitmap.toCpuAccessible(): Bitmap {
if (this.config == Bitmap.Config.HARDWARE) {
// HARDWARE isn't CPU-accessible, so we convert to ARGB
return this.copy(Bitmap.Config.ARGB_8888, true)
}
return this
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.margelo.nitro.image

import android.graphics.Bitmap

fun Bitmap.toMutable(forceCopy: Boolean): Bitmap {
if (isMutable && !forceCopy) {
// It's already Mutable!
return this
}
var config = this.config ?: throw Error("Failed to get Bitmap's format! $this")
if (config == Bitmap.Config.HARDWARE) {
// HARDWARE Bitmaps are not mutable, so we need to change to ARGB
config = Bitmap.Config.ARGB_8888
}
return this.copy(config, true)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package com.margelo.nitro.image

import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Rect
import android.os.Build
import androidx.annotation.Keep
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
import com.facebook.proguard.annotations.DoNotStrip
import com.madebyevan.thumbhash.ThumbHash
Expand Down Expand Up @@ -70,6 +72,26 @@ class HybridImage: HybridImageSpec {
return Promise.async { toEncodedImageData(format, quality) }
}

override fun rotate(degrees: Double, allowFastFlagRotation: Boolean?): HybridImageSpec {
// 1. Make sure the Bitmap we want to draw is drawable (HARDWARE isn't)
val source = bitmap.toCpuAccessible()
// 2. Create a rotation Matrix
val matrix = Matrix()
matrix.setRotate(degrees.toFloat(), source.width / 2f, source.height / 2f)
// 3. Create a new blank Bitmap as our output
val destination = createBitmap(bitmap.width, bitmap.height)
// 4. Draw the Bitmap to our destination
Canvas(destination).apply {
drawBitmap(source, matrix, null)
}
// 5. Return it!
return HybridImage(destination)
}

override fun rotateAsync(degrees: Double, allowFastFlagRotation: Boolean?): Promise<HybridImageSpec> {
return Promise.async { rotate(degrees, allowFastFlagRotation) }
}

override fun resize(width: Double, height: Double): HybridImageSpec {
if (width < 0) {
throw Error("Width cannot be less than 0! (width: $width)")
Expand Down Expand Up @@ -153,12 +175,7 @@ class HybridImage: HybridImageSpec {
val newImage = image as? HybridImage ?: throw Error("The image ($image) is not a `HybridImage`!")

// 1. Copy this Bitmap into a new Bitmap
var config = bitmap.config ?: throw Error("Failed to get Image's format! $bitmap")
if (config == Bitmap.Config.HARDWARE) {
// 1.1. A HARDWARE (GPU) Bitmap is not modifyable, so we need to fall back to ARGB
config = Bitmap.Config.ARGB_8888
}
val copy = bitmap.copy(config, true)
val copy = bitmap.toMutable(true)
// 2. Create a Canvas to start drawing
Canvas(copy).also { canvas ->
// 3. Prepare the Bitmap we want to draw into our Canvas
Expand All @@ -167,16 +184,12 @@ class HybridImage: HybridImageSpec {
width.toInt(),
height.toInt())

var newBitmap = newImage.bitmap
if (newBitmap.config == Bitmap.Config.HARDWARE) {
// 3.3. If the Image we want to draw is a HARDWARE (GPU) Image,
// we need to copy it to a software image first.
newBitmap = newBitmap.copy(config, false)
}
// 4. Now draw!
canvas.drawBitmap(newBitmap, null, rect, null)
// 4. Make sure we can draw the Bitmap (HARDWARE isn't CPU accessible)
val drawable = newImage.bitmap.toCpuAccessible()
// 5. Now draw!
canvas.drawBitmap(drawable, null, rect, null)
}
// 5. Wrap the new Bitmap as a HybridImage and return
// 6. Wrap the new Bitmap as a HybridImage and return
return HybridImage(copy)
}
override fun renderIntoAsync(image: HybridImageSpec, x: Double, y: Double, width: Double, height: Double): Promise<HybridImageSpec> {
Expand Down
36 changes: 36 additions & 0 deletions packages/react-native-nitro-image/ios/NativeImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,42 @@ public extension NativeImage {
return try self.toEncodedImageData(format: format, quality: quality)
}
}

func rotate(degrees: Double, allowFastFlagRotation: Bool?) -> any HybridImageSpec {
if allowFastFlagRotation == true,
degrees.truncatingRemainder(dividingBy: 90) == 0,
let cgImage = uiImage.cgImage {
// Fast path: we can apply `orientation` instead
let steps = Int(degrees / 90.0) // can be negative
let newOrientation = uiImage.imageOrientation.rotated(byRightAngles: steps)
let rotated = UIImage(cgImage: cgImage, scale: uiImage.scale, orientation: newOrientation)
return HybridImage(uiImage: rotated)
} else {
// Slow path: we actually rotate using UIGraphicsImageRenderer
let renderer = UIGraphicsImageRenderer(size: uiImage.size)
let rotatedImage = renderer.image { context in
let width = uiImage.size.width
let height = uiImage.size.height
// 1. Move to the center of the image so our origin is the center
context.cgContext.translateBy(x: width / 2, y: height / 2)
// 2. Rotate by the given radians
let radians = degrees * .pi / 180
context.cgContext.rotate(by: radians)
// 3. Draw the Image offset by half the frame so we counter our center origin from step 1.
let rect = CGRect(x: -(width / 2),
y: -(height / 2),
width: width,
height: height)
uiImage.draw(in: rect)
}
return HybridImage(uiImage: rotatedImage)
}
}
func rotateAsync(degrees: Double, allowFastFlagRotation: Bool?) -> Promise<any HybridImageSpec> {
return Promise.async {
return self.rotate(degrees: degrees, allowFastFlagRotation: allowFastFlagRotation)
}
}

func resize(width: Double, height: Double) throws -> any HybridImageSpec {
guard width > 0 else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// UIImageOrientation+fromDegrees.swift
// react-native-nitro-image
//
// Created by Marc Rousavy on 11.06.25.
//

import UIKit
import NitroModules

extension UIImage.Orientation {
private func rotated90CW() -> UIImage.Orientation {
switch self {
case .up: return .right
case .right: return .down
case .down: return .left
case .left: return .up
case .upMirrored: return .rightMirrored
case .rightMirrored: return .downMirrored
case .downMirrored: return .leftMirrored
case .leftMirrored: return .upMirrored
@unknown default: return .right
}
}
func rotated(byRightAngles k: Int) -> UIImage.Orientation {
let t = ((k % 4) + 4) % 4
var o = self
for _ in 0..<t { o = o.rotated90CW() }
return o
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions packages/react-native-nitro-image/src/specs/Image.nitro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,23 @@ export interface Image
resize(width: number, height: number): Image;
resizeAsync(width: number, height: number): Promise<Image>;

/**
* Rotates this Image by the given {@linkcode degrees} and returns
* 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.
* @example
* ```ts
* const upsideDown = image.rotate(180)
* ```
*/
rotate(degrees: number, allowFastFlagRotation?: boolean): Image;
rotateAsync(
degrees: number,
allowFastFlagRotation?: boolean,
): Promise<Image>;

/**
* Crops this Image into a new image starting from the source image's {@linkcode startX} and {@linkcode startY} coordinates,
* up until the source image's {@linkcode endX} and {@linkcode endY} coordinates.
Expand Down