Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -153,21 +153,37 @@ object ImageCompressor {
return stream
}

/**
* Decodes with downsampling via inSampleSize (the same technique
* autoCompressImage() already uses below) instead of loadImage()
* (BitmapFactory.decodeFile with no downsampling — a full decode of a
* high-resolution photo on a low-RAM device is a common OOM cause).
*/
fun loadImageDownsampled(imagePath: String?, maxWidth: Int, maxHeight: Int): Bitmap {
val uri = Uri.parse(imagePath)
val filePath = uri.path
val boundsOptions = BitmapFactory.Options()
boundsOptions.inJustDecodeBounds = true
BitmapFactory.decodeFile(filePath, boundsOptions)
boundsOptions.inSampleSize = calculateInSampleSize(boundsOptions, maxWidth, maxHeight)
boundsOptions.inJustDecodeBounds = false
return BitmapFactory.decodeFile(filePath, boundsOptions)
}

fun manualCompressImage(imagePath: String?, options: ImageCompressorOptions, reactContext: ReactApplicationContext?): String? {
val image = if (options.input === ImageCompressorOptions.InputType.base64) decodeImage(imagePath) else loadImage(imagePath)
val image = if (options.input === ImageCompressorOptions.InputType.base64) decodeImage(imagePath) else loadImageDownsampled(imagePath, options.maxWidth, options.maxHeight)
val resizedImage = resize(image, options.maxWidth, options.maxHeight)
val isBase64 = options.returnableOutputType === ImageCompressorOptions.ReturnableOutputType.base64
val uri = Uri.parse(imagePath)
val imagePathNew = uri.path
var scaledBitmap: Bitmap? = correctImageOrientation(resizedImage, imagePathNew)
val imageDataByteArrayOutputStream = compress(scaledBitmap, options.output, options.quality, options.disablePngTransparency)
val compressedImagePath = encodeImage(imageDataByteArrayOutputStream, isBase64, options.output.toString(), imagePath, reactContext)
if (isCompressedSizeLessThanActualFile(imagePath!!, compressedImagePath)) {
return compressedImagePath
} else {
MediaCache.deleteFile(compressedImagePath!!)
return slashifyFilePath(imagePath)
}
// Unlike autoCompressImage(), don't fall back to the original file when
// the compressed output isn't smaller — manualCompressImage() must
// always return a file in the requested output format (e.g. a caller
// converting HEIC/HEIF to JPEG needs that guarantee regardless of the
// resulting file size).
return encodeImage(imageDataByteArrayOutputStream, isBase64, options.output.toString(), imagePath, reactContext)
}

fun isCompressedSizeLessThanActualFile(sourceFileUrl: String,compressedFileUrl: String?): Boolean {
Expand Down
169 changes: 106 additions & 63 deletions ios/Image/ImageCompressor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Accelerate
import CoreGraphics
import Photos
import Foundation
import ImageIO
import MobileCoreServices


Expand Down Expand Up @@ -161,8 +162,16 @@ class ImageCompressor {
}
}

// Pixel data here has already been decoded/rotated to the "up"
// orientation (see decodeDownsampledImage below). Without forcing this
// tag, the merge loop above copies the ORIGINAL file's Orientation tag
// (commonly 3/6/8 for camera photos) onto an already-upright image —
// any viewer that respects EXIF orientation will rotate it a second
// time, incorrectly.
dataMetadata?[kCGImagePropertyOrientation] = 1

let outputFormat = isPNG(data) ? kUTTypePNG : kUTTypeJPEG

let destinationData = NSMutableData()
let destination = CGImageDestinationCreateWithData(destinationData, outputFormat, 1, nil)!
CGImageDestinationAddImage(destination, image.cgImage!, dataMetadata as CFDictionary?)
Expand Down Expand Up @@ -293,28 +302,105 @@ class ImageCompressor {
return fileUrl
}


static func manualCompressHandler(imagePath: String?, base64: String?, options: ImageCompressorOptions) -> String {
var exception: NSException?
var image: UIImage?
/// Opens a CGImageSource for the given path — same 3 input branches as the
/// old loadImage() (file://, data:, bare absolute path) but WITHOUT
/// decoding the image. This lets ImageIO downsample directly from the
/// source when generating a thumbnail, instead of us decoding the full
/// image into memory first.
static func makeImageSource(_ path: String) -> CGImageSource? {
if path.hasPrefix("file://") {
guard let fileURL = URL(string: path) else { return nil }
return CGImageSourceCreateWithURL(fileURL as CFURL, nil)
}
if path.hasPrefix("data:") {
guard let dataURL = URL(string: path), let data = try? Data(contentsOf: dataURL) else { return nil }
return CGImageSourceCreateWithData(data as CFData, nil)
}
return CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil)
}

switch options.input {
/// Decodes the image already downsampled AND orientation-corrected in a
/// single step via CGImageSourceCreateThumbnailAtIndex, instead of
/// UIImage(contentsOfFile:) followed by redrawing the FULL-resolution
/// bitmap into a new CGContext (the old path: peaks at ~2x the full
/// native-resolution RGBA buffer in memory, which is the root cause of
/// OOM crashes on lower-RAM devices when compressing large/HEIC photos).
/// kCGImageSourceCreateThumbnailWithTransform: true makes ImageIO apply
/// the EXIF orientation transform as part of decoding, so the returned
/// image is always orientation .up — scaleAndRotateImage() is no longer
/// needed on this path.
static func decodeDownsampledImage(imagePath: String?, base64: String?, input: InputType, maxWidth: Int, maxHeight: Int) -> UIImage? {
let imageSource: CGImageSource?

switch input {
case .base64:
if let _base64 = base64 {
image = ImageCompressor.decodeImage(_base64)
guard let base64Value = base64,
let data = Data(base64Encoded: base64Value, options: .ignoreUnknownCharacters) else {
return nil
}
imageSource = CGImageSourceCreateWithData(data as CFData, nil)
case .uri:
if let _imagePath = imagePath {
image = ImageCompressor.loadImage(_imagePath)
}
guard let path = imagePath else { return nil }
imageSource = ImageCompressor.makeImageSource(path)
}

guard let source = imageSource else { return nil }

// Read the native pixel size from metadata only (no decoding) so we
// never upscale small images — kCGImageSourceThumbnailMaxPixelSize's
// behavior when the requested size is larger than the source isn't
// consistently documented across iOS versions, so we clamp manually
// to be safe.
var nativeLongEdge = CGFloat(max(maxWidth, maxHeight))
if let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
let pixelWidth = properties[kCGImagePropertyPixelWidth] as? Int,
let pixelHeight = properties[kCGImagePropertyPixelHeight] as? Int {
nativeLongEdge = CGFloat(max(pixelWidth, pixelHeight))
}

if let _image = image {
image = ImageCompressor.scaleAndRotateImage(_image)
let requestedLongEdge = CGFloat(max(maxWidth, maxHeight))
let targetLongEdge = max(1, Int(min(requestedLongEdge, nativeLongEdge).rounded()))

let thumbnailOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: targetLongEdge,
kCGImageSourceShouldCacheImmediately: true,
]

guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions as CFDictionary) else {
return nil
}

return UIImage(cgImage: thumbnail)
}

/// kCGImageSourceThumbnailMaxPixelSize only accepts a single scalar value
/// (constrains the longer edge). If maxWidth != maxHeight (not exercised
/// by any current call site, but kept for behavioral safety), fit
/// precisely into the maxWidth x maxHeight box using the existing
/// manualResize() — safe memory-wise here since the input has already
/// been downsampled, not full-resolution.
static func fitExactBoxIfNeeded(_ image: UIImage, maxWidth: Int, maxHeight: Int) -> UIImage {
guard maxWidth != maxHeight else { return image }
return ImageCompressor.manualResize(image, maxWidth: maxWidth, maxHeight: maxHeight)
}

/// Shared decode + resize step used by both manual and auto compress.
static func decodeAndPrepareImage(imagePath: String?, base64: String?, options: ImageCompressorOptions) -> UIImage? {
guard let decoded = ImageCompressor.decodeDownsampledImage(imagePath: imagePath, base64: base64, input: options.input, maxWidth: options.maxWidth, maxHeight: options.maxHeight) else {
return nil
}
return ImageCompressor.fitExactBoxIfNeeded(decoded, maxWidth: options.maxWidth, maxHeight: options.maxHeight)
}

static func manualCompressHandler(imagePath: String?, base64: String?, options: ImageCompressorOptions) -> String {
var exception: NSException?

if let image = ImageCompressor.decodeAndPrepareImage(imagePath: imagePath, base64: base64, options: options) {
let outputExtension = ImageCompressorOptions.getOutputInString(options.output)
let resizedImage = ImageCompressor.manualResize(image!, maxWidth: options.maxWidth, maxHeight: options.maxHeight)
let isBase64 = options.returnableOutputType == .rbase64
return ImageCompressor.manualCompress(resizedImage, output: options.output.rawValue, quality: options.quality, outputExtension: outputExtension, isBase64: isBase64,disablePngTransparency: options.disablePngTransparency, actualImagePath: imagePath)
return ImageCompressor.manualCompress(image, output: options.output.rawValue, quality: options.quality, outputExtension: outputExtension, isBase64: isBase64, disablePngTransparency: options.disablePngTransparency, actualImagePath: imagePath)
} else {
exception = NSException(name: NSExceptionName(rawValue: "unsupported_value"), reason: "Unsupported value type.", userInfo: nil)
exception?.raise()
Expand All @@ -323,62 +409,19 @@ class ImageCompressor {
return ""
}


static func autoCompressHandler(imagePath: String?, base64: String?, options: ImageCompressorOptions) -> String {
var exception: NSException?
var image: UIImage?

switch options.input {
case .base64:
if let _base64 = base64 {
image = ImageCompressor.decodeImage(_base64)
}
case .uri:
if let _imagePath = imagePath {
image = ImageCompressor.loadImage(_imagePath)
}
}

if var image = image {
image = ImageCompressor.scaleAndRotateImage(image)

if let image = ImageCompressor.decodeAndPrepareImage(imagePath: imagePath, base64: base64, options: options) {
let outputExtension = ImageCompressorOptions.getOutputInString(options.output)

var actualHeight = image.size.height
var actualWidth = image.size.width
let maxHeight: CGFloat = CGFloat(options.maxHeight)
let maxWidth: CGFloat = CGFloat(options.maxWidth)
var imgRatio = actualWidth / actualHeight
let maxRatio = maxWidth / maxHeight
let compressionQuality: CGFloat = CGFloat(options.quality)

if actualHeight > maxHeight || actualWidth > maxWidth {
if imgRatio < maxRatio {
imgRatio = maxHeight / actualHeight
actualWidth = imgRatio * actualWidth
actualHeight = maxHeight
} else if imgRatio > maxRatio {
imgRatio = maxWidth / actualWidth
actualHeight = imgRatio * actualHeight
actualWidth = maxWidth
} else {
actualHeight = maxHeight
actualWidth = maxWidth
}
}

let rect = CGRect(x: 0.0, y: 0.0, width: actualWidth, height: actualHeight)
UIGraphicsBeginImageContext(rect.size)
image.draw(in: rect)
let isBase64 = options.returnableOutputType == .rbase64

if let img = UIGraphicsGetImageFromCurrentImageContext() {
return writeImage(img, output: options.output.rawValue, quality: Float(compressionQuality), outputExtension: outputExtension, isBase64: isBase64, disablePngTransparency: options.disablePngTransparency, isEnableAutoCompress: true, actualImagePath: imagePath)
}
return ImageCompressor.writeImage(image, output: options.output.rawValue, quality: options.quality, outputExtension: outputExtension, isBase64: isBase64, disablePngTransparency: options.disablePngTransparency, isEnableAutoCompress: true, actualImagePath: imagePath)
} else {
exception = NSException(name: NSExceptionName(rawValue: "unsupported_value"), reason: "Unsupported value type.", userInfo: nil)
exception?.raise()
}

return ""
}

Expand Down