From 2bc6c969435864af11e1b81b6a0163ae773c7395 Mon Sep 17 00:00:00 2001 From: QUANG Dang Date: Mon, 20 Jul 2026 15:43:38 +0700 Subject: [PATCH 1/4] fix(image): decode+downsample in one step on iOS/Android to avoid OOM on large photos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both manualCompressHandler and autoCompressHandler on iOS decode the source image at its full native resolution (UIImage(contentsOfFile:) /UIImage(data:)) before scaleAndRotateImage() redraws it into a second full-resolution CGContext for orientation, and only THEN resize. For a 12MP HEIC photo that's ~2x a ~49MB RGBA buffer in flight per image, before any downsampling happens. Apps that compress several images concurrently (e.g. a small upload queue) can hit iOS's jetsam limit on lower-RAM devices (reproduced crashing on iPhone 11/13, 4GB RAM) purely from this decode step — before a single byte of the resize/compress logic runs. Fix: decode via CGImageSourceCreateThumbnailAtIndex with kCGImageSourceThumbnailMaxPixelSize + kCGImageSourceCreateThumbnailWithTransform, which downsamples AND applies EXIF orientation in a single native decode, never materializing the full-resolution bitmap. Native pixel size is read from metadata first (no decode) to clamp the target size and avoid upscaling. manualCompressHandler/autoCompressHandler now share this decode step; loadImage/scaleAndRotateImage/manualResize are left in place as a safety-net path for the (currently unused) maxWidth != maxHeight case. Also fixes a latent bug in copyExifInfo(): it copies the ORIGINAL file's EXIF Orientation tag onto the newly-encoded (already-upright) image whenever the destination doesn't already have that tag, which un-rotates images a second time in any viewer that respects EXIF orientation. This was masked before because nothing exercised manualCompressHandler with a rotated source through this exact path in our testing, but it's real regardless of the decode change above. Android: manualCompressImage() had the same full-decode-before-resize issue that autoCompressImage() already avoids via BitmapFactory's inSampleSize downsampling — reuse that same technique. Also removed manualCompressImage()'s fallback to the original (untouched) file when the compressed output isn't smaller, since callers using 'manual' specifically to force a format conversion (e.g. HEIC/HEIF -> JPEG) need a guarantee that the returned file is actually in the requested format, regardless of resulting size. autoCompressImage() keeps that size-comparison fallback as before. Verified by patching react-native-compressor@2.0.2 in a downstream app via patch-package: compiles cleanly on both platforms (xcodebuild -scheme react-native-compressor, and ./gradlew :react-native-compressor:compileDebugKotlin), and resolves the OOM crash in that app's own upload flow. --- .../Image/ImageCompressor.kt | 32 +++- ios/Image/ImageCompressor.swift | 169 +++++++++++------- 2 files changed, 130 insertions(+), 71 deletions(-) diff --git a/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt b/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt index f5a2241..2c2c9f1 100644 --- a/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt +++ b/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt @@ -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 { diff --git a/ios/Image/ImageCompressor.swift b/ios/Image/ImageCompressor.swift index 2bcc790..0646130 100644 --- a/ios/Image/ImageCompressor.swift +++ b/ios/Image/ImageCompressor.swift @@ -2,6 +2,7 @@ import Accelerate import CoreGraphics import Photos import Foundation +import ImageIO import MobileCoreServices @@ -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?) @@ -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() @@ -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 "" } From 742c81eb781c8cb299b215338b0da86f47092b26 Mon Sep 17 00:00:00 2001 From: dakiquang Date: Sun, 1 Nov 2026 11:53:08 +0700 Subject: [PATCH 2/4] fix(image): fit both bounds without upscaling and harden decode paths Address the review on #415. iOS: derive the thumbnail size from the native size and EXIF orientation so the output fits inside maxWidth and maxHeight without upscaling, in a single decode. Resolve file URIs through one fileURL() helper so unencoded paths don't fail or crash, and accept file:/ URIs again. Drop the decode and resize helpers this leaves unused. Android: downsample base64 input too, fail with a clear error when a source can't be decoded, and fit manual output inside both bounds without upscaling, after applying EXIF rotation. --- TRIAGE.md | 2 + .../Image/ImageCompressor.kt | 57 ++--- harness/native-compressor.harness.ts | 12 + ios/Image/ImageCompressor.swift | 227 +++--------------- 4 files changed, 65 insertions(+), 233 deletions(-) diff --git a/TRIAGE.md b/TRIAGE.md index f18ad50..4fe4bd5 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -13,6 +13,7 @@ Legend: | Issue | Triage | Notes | | --- | --- | --- | +| #414 | real, fixed here | `Image.compress` decoded at full native resolution before resizing, which OOM-crashes lower-RAM devices on batches of large photos. iOS now decodes downsampled and upright in one ImageIO step; Android manual mode (file and base64) uses `inSampleSize`. Both fit inside `maxWidth`/`maxHeight` without upscaling. | | #428 | real, fixed here | Android `Video.compress` rejected with the bare `CodecException` message, which is usually empty, so every codec failure read the same. This branch appends `CodecException.diagnosticInfo` (e.g. `android.media.MediaCodec.error_neg_12` = `NO_MEMORY`) to the rejection message. | | #423 | real, fixed here | Android `Video.compress` rejects with `NO_MEMORY` on Samsung Exynos encoders (`c2.exynos.h264.encoder`) at higher resolutions (e.g. 1080p manual mode). #399 set `KEY_PRIORITY = 0` + `KEY_OPERATING_RATE = Short.MAX_VALUE`; Exynos accepts them in `configure()` but fails `start()` ("real-time(32767 fps) is not supported"), which the `prepareEncoder` fallback doesn't cover. Small `auto` outputs (360×640) still fit the realtime budget. This branch removes both keys. Verified on a Galaxy A56 (Exynos 1580): 1080p manual fails with the keys and succeeds without them (94.1 MB → 32.0 MB). | | #406 | real, fixed here | Nitro module not linked in Android **release** builds (works in debug). R8 strips the `HybridCompressor` implementation, which is instantiated from C++/JNI by class name (`NitroCompressorOnLoad.cpp` `kJavaDescriptor`) and was the only Nitro class lacking keep protection — the generated spec has `@DoNotStrip @Keep`, but the hand-written impl didn't. This branch annotates `HybridCompressor` with `@DoNotStrip @Keep` (the same convention nitrogen and other Nitro modules like react-native-mmkv use), so no consumer ProGuard rules are needed. Verified with a minified release build: without the annotation R8 lists the class in `usage.txt` (removed); with it, the class + no-arg constructor are kept in `mapping.txt`. | @@ -102,3 +103,4 @@ These should be closed upstream unless a current repro still exists on the lates - Android: rewrite `NitroPromiseAdapter` in Java so the Nitro module compiles across all React Native versions regardless of `Promise.reject`'s `code` nullability (#404) - Android: drop `KEY_PRIORITY`/`KEY_OPERATING_RATE` from the encoder format so Exynos H.264 encoders don't fail `start()` with `NO_MEMORY` (#423) - Android: name the codec error (`CodecException.diagnosticInfo`) when video compression fails (#428) +- Android/iOS: downsample while decoding in `Image.compress` to avoid OOM on large photos, and fit manual output inside both bounds without upscaling (#414) diff --git a/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt b/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt index 2c2c9f1..cbfdaf7 100644 --- a/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt +++ b/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt @@ -38,25 +38,8 @@ object ImageCompressor { fun findActualSize(image: Bitmap, maxWidth: Int, maxHeight: Int): ImageSize { val width = image.width.toFloat() val height = image.height.toFloat() - if (width > height) { - val newHeight = Math.round(height / (width / maxWidth)) - val scale = newHeight / height - return ImageSize(maxWidth, newHeight, scale) - } - val newWidth = Math.round(width / (height / maxHeight)) - val scale = newWidth / width - return ImageSize(newWidth, maxHeight, scale) - } - - fun decodeImage(value: String?): Bitmap { - val data = Base64.decode(value, Base64.DEFAULT) - return BitmapFactory.decodeByteArray(data, 0, data.size) - } - - fun loadImage(value: String?): Bitmap { - val uri = Uri.parse(value) - val filePath = uri.path - return BitmapFactory.decodeFile(filePath) + val scale = minOf(1f, maxWidth / width, maxHeight / height) + return ImageSize(Math.round(width * scale), Math.round(height * scale), scale) } /** @@ -155,28 +138,36 @@ object ImageCompressor { /** * 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). + * autoCompressImage() already uses below) instead of a full decode, which + * for a high-resolution photo on a low-RAM device is a common OOM cause. + * BitmapFactory returns null rather than throwing on undecodable input. */ + private fun decodeDownsampled(maxWidth: Int, maxHeight: Int, decode: (BitmapFactory.Options) -> Bitmap?): Bitmap { + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + decode(options) + options.inSampleSize = calculateInSampleSize(options, maxWidth, maxHeight) + options.inJustDecodeBounds = false + return decode(options) ?: throw IllegalArgumentException("Unable to decode image") + } + + fun decodeImageDownsampled(value: String?, maxWidth: Int, maxHeight: Int): Bitmap { + val data = Base64.decode(value, Base64.DEFAULT) + return decodeDownsampled(maxWidth, maxHeight) { BitmapFactory.decodeByteArray(data, 0, data.size, it) } + } + 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) + val filePath = Uri.parse(imagePath).path + return decodeDownsampled(maxWidth, maxHeight) { BitmapFactory.decodeFile(filePath, it) } } fun manualCompressImage(imagePath: String?, options: ImageCompressorOptions, reactContext: ReactApplicationContext?): String? { - 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 image = if (options.input === ImageCompressorOptions.InputType.base64) decodeImageDownsampled(imagePath, options.maxWidth, options.maxHeight) else loadImageDownsampled(imagePath, 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) + // Rotate first: a 90° EXIF rotation swaps which edge maxWidth/maxHeight apply to. + val scaledBitmap = resize(correctImageOrientation(image, imagePathNew)!!, options.maxWidth, options.maxHeight) val imageDataByteArrayOutputStream = compress(scaledBitmap, options.output, options.quality, options.disablePngTransparency) // Unlike autoCompressImage(), don't fall back to the original file when // the compressed output isn't smaller — manualCompressImage() must diff --git a/harness/native-compressor.harness.ts b/harness/native-compressor.harness.ts index 32e39a3..6f55de1 100644 --- a/harness/native-compressor.harness.ts +++ b/harness/native-compressor.harness.ts @@ -142,6 +142,18 @@ describe('react-native-compressor native harness', () => { await expect(getFileSize(output)).resolves.toMatch(/^\d+$/); }); + it('fits inside both bounds without upscaling a small image', async () => { + const output = await Image.compress(imageUri, { + compressionMethod: 'manual', + maxWidth: 1000, + maxHeight: 500, + }); + + const metadata = await getImageMetaData(output); + expect(metadata.ImageWidth).toBe(16); + expect(metadata.ImageHeight).toBe(16); + }); + it('reads metadata from a real video fixture through the exported metadata API', async () => { const metadata = await getVideoMetaData(videoUri); expect(metadata.width).toBeGreaterThan(0); diff --git a/ios/Image/ImageCompressor.swift b/ios/Image/ImageCompressor.swift index 0646130..3b70edb 100644 --- a/ios/Image/ImageCompressor.swift +++ b/ios/Image/ImageCompressor.swift @@ -1,4 +1,3 @@ -import Accelerate import CoreGraphics import Photos import Foundation @@ -14,106 +13,11 @@ private enum CompressorResizeMode { class ImageCompressor { - static func findTargetSize(_ image: UIImage, maxWidth: Int, maxHeight: Int) -> CGSize { - let width = image.size.width - let height = image.size.height - - if width > height { - let newHeight = height / (width / CGFloat(maxWidth)) - return CGSize(width: CGFloat(maxWidth), height: newHeight) - } - - let newWidth = width / (height / CGFloat(maxHeight)) - return CGSize(width: newWidth, height: CGFloat(maxHeight)) - } - - - static func decodeImage(_ value: String) -> UIImage? { - if let data = Data(base64Encoded: value, options: .ignoreUnknownCharacters) { - return UIImage(data: data) - } - return nil - } - - - static func loadImage(_ path: String) -> UIImage? { - var image: UIImage? - - if path.hasPrefix("data:") || path.hasPrefix("file:") { - if let imageUrl = URL(string: path), let imageData = try? Data(contentsOf: imageUrl) { - image = UIImage(data: imageData) - } - } else { - image = UIImage(contentsOfFile: path) - } - - return image - } - - - static func manualResize(_ image: UIImage, maxWidth: Int, maxHeight: Int) -> UIImage { - let targetSize = findTargetSize(image, maxWidth: maxWidth, maxHeight: maxHeight) - - if let cgImage = image.cgImage { - let sourceWidth = Int(image.size.width) - let sourceHeight = Int(image.size.height) - let targetWidth = Int(targetSize.width) - let targetHeight = Int(targetSize.height) - let bytesPerPixel = 4 - let sourceBytesPerRow = sourceWidth * bytesPerPixel - let targetBytesPerRow = targetWidth * bytesPerPixel - let bitsPerComponent = 8 - - let colorSpace = CGColorSpaceCreateDeviceRGB() - - var sourceData = [UInt8](repeating: 0, count: sourceHeight * sourceBytesPerRow) - let context = CGContext(data: &sourceData, - width: Int(sourceWidth), - height: Int(sourceHeight), - bitsPerComponent: bitsPerComponent, - bytesPerRow: sourceBytesPerRow, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Big.rawValue) - - context?.draw(cgImage, in: CGRect(x: 0, y: 0, width: sourceWidth, height: sourceHeight)) - - - var targetData = [UInt8](repeating: 0, count: targetWidth * targetHeight * bytesPerPixel) - - var srcBuffer = vImage_Buffer(data: &sourceData, height: vImagePixelCount(sourceHeight), width: vImagePixelCount(sourceWidth), rowBytes: sourceBytesPerRow) - var targetBuffer = vImage_Buffer(data: &targetData, height: vImagePixelCount(targetHeight), width: vImagePixelCount(targetWidth), rowBytes: targetBytesPerRow) - - let error = vImageScale_ARGB8888(&srcBuffer, &targetBuffer, nil, vImage_Flags(kvImageHighQualityResampling)) - - if error != kvImageNoError { - free(&targetData) - let exception = NSException(name: NSExceptionName(rawValue: "drawing_error"), reason: "Problem while rendering your image", userInfo: nil) - exception.raise() - } - - let targetContext = CGContext(data: &targetData, - width: targetWidth, - height: targetHeight, - bitsPerComponent: bitsPerComponent, - bytesPerRow: targetBytesPerRow, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Big.rawValue) - - let targetRef = targetContext?.makeImage() - let resizedImage = UIImage(cgImage: targetRef!) - - - return resizedImage - } - return UIImage() - } - static func isCompressedSizeLessThanActualFile(sourceFileUrl: String?, sourceData: Data?, compressedFileUrl: String)-> Bool { var sourcefileSize: Int = 0 if let _sourceFileUrl = sourceFileUrl { - let sourceVideoURL = URL(string: _sourceFileUrl) - sourcefileSize = Utils.getfileSizeInBytes(forURL: sourceVideoURL!) + sourcefileSize = Utils.getfileSizeInBytes(forURL: ImageCompressor.fileURL(_sourceFileUrl)) } if let _sourceData = sourceData { @@ -135,11 +39,7 @@ class ImageCompressor { } static func copyExifInfo(actualImagePath: String, image: UIImage, data: Data) -> Data { - let fileURL = URL(string: actualImagePath)! - let filePath = fileURL.path - - let url = URL(fileURLWithPath: filePath) - let source = CGImageSourceCreateWithURL(url as CFURL, nil)! + let source = CGImageSourceCreateWithURL(ImageCompressor.fileURL(actualImagePath) as CFURL, nil)! let metadata = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] let dataProvider = CGDataProvider(data: data as CFData) @@ -238,63 +138,6 @@ class ImageCompressor { } - static func scaleAndRotateImage(_ image: UIImage) -> UIImage { - if image.imageOrientation == .up { - return image - } - - var transform = CGAffineTransform.identity - - switch image.imageOrientation { - case .down, .downMirrored: - transform = transform.translatedBy(x: image.size.width, y: image.size.height) - transform = transform.rotated(by: CGFloat.pi) - case .left, .leftMirrored: - transform = transform.translatedBy(x: image.size.width, y: 0) - transform = transform.rotated(by: CGFloat.pi / 2) - case .right, .rightMirrored: - transform = transform.translatedBy(x: 0, y: image.size.height) - transform = transform.rotated(by: -CGFloat.pi / 2) - default: - break - } - - switch image.imageOrientation { - case .upMirrored, .downMirrored: - transform = transform.translatedBy(x: image.size.width, y: 0) - transform = transform.scaledBy(x: -1, y: 1) - case .leftMirrored, .rightMirrored: - transform = transform.translatedBy(x: image.size.height, y: 0) - transform = transform.scaledBy(x: -1, y: 1) - default: - break - } - - if let cgImage = image.cgImage, let colorSpace = cgImage.colorSpace { - guard let context = CGContext(data: nil, width: Int(image.size.width), height: Int(image.size.height), - bitsPerComponent: cgImage.bitsPerComponent, bytesPerRow: 0, - space: colorSpace, bitmapInfo: cgImage.bitmapInfo.rawValue) else { - return image - } - - context.concatenate(transform) - - switch image.imageOrientation { - case .left, .leftMirrored, .right, .rightMirrored: - context.draw(cgImage, in: CGRect(x: 0, y: 0, width: image.size.height, height: image.size.width)) - default: - context.draw(cgImage, in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height)) - } - - if let rotatedCGImage = context.makeImage() { - return UIImage(cgImage: rotatedCGImage) - } - } - - return image - } - - static func makeValidUri(_ filePath: String) -> String { let fileWithUrl = URL(fileURLWithPath: filePath) let absoluteUrl = fileWithUrl.deletingLastPathComponent() @@ -302,21 +145,25 @@ class ImageCompressor { return fileUrl } - /// 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 + /// Builds a file URL from a file: URI or a bare absolute path. URL(string:) + /// returns nil on iOS < 17 for a URI that isn't percent-encoded (e.g. it + /// contains spaces), so fall back to the path with its scheme stripped. + static func fileURL(_ path: String) -> URL { + guard path.hasPrefix("file:") else { return URL(fileURLWithPath: path) } + if let url = URL(string: path) { return url } + return URL(fileURLWithPath: path.replacingOccurrences(of: "^file:/*", with: "/", options: .regularExpression)) + } + + /// Opens a CGImageSource for the given path — file:, data: or a bare + /// absolute path, 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) + return CGImageSourceCreateWithURL(ImageCompressor.fileURL(path) as CFURL, nil) } /// Decodes the image already downsampled AND orientation-corrected in a @@ -327,8 +174,7 @@ class ImageCompressor { /// 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. + /// image is always orientation .up. static func decodeDownsampledImage(imagePath: String?, base64: String?, input: InputType, maxWidth: Int, maxHeight: Int) -> UIImage? { let imageSource: CGImageSource? @@ -346,21 +192,21 @@ class ImageCompressor { 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)) + // kCGImageSourceThumbnailMaxPixelSize takes a single value for the longer + // edge, so derive it from the native size (metadata only, no decoding): + // fit inside both maxWidth and maxHeight, and never upscale. + var targetLongEdge = min(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)) + // EXIF orientations 5-8 swap width and height once applied. + let isTransposed = (properties[kCGImagePropertyOrientation] as? Int ?? 1) >= 5 + let width = CGFloat(isTransposed ? pixelHeight : pixelWidth) + let height = CGFloat(isTransposed ? pixelWidth : pixelHeight) + let scale = min(1, CGFloat(maxWidth) / width, CGFloat(maxHeight) / height) + targetLongEdge = max(1, Int((max(width, height) * scale).rounded())) } - let requestedLongEdge = CGFloat(max(maxWidth, maxHeight)) - let targetLongEdge = max(1, Int(min(requestedLongEdge, nativeLongEdge).rounded())) - let thumbnailOptions: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceCreateThumbnailWithTransform: true, @@ -375,29 +221,10 @@ class ImageCompressor { 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) { + if let image = ImageCompressor.decodeDownsampledImage(imagePath: imagePath, base64: base64, input: options.input, maxWidth: options.maxWidth, maxHeight: options.maxHeight) { let outputExtension = ImageCompressorOptions.getOutputInString(options.output) let isBase64 = options.returnableOutputType == .rbase64 return ImageCompressor.manualCompress(image, output: options.output.rawValue, quality: options.quality, outputExtension: outputExtension, isBase64: isBase64, disablePngTransparency: options.disablePngTransparency, actualImagePath: imagePath) @@ -413,7 +240,7 @@ class ImageCompressor { static func autoCompressHandler(imagePath: String?, base64: String?, options: ImageCompressorOptions) -> String { var exception: NSException? - if let image = ImageCompressor.decodeAndPrepareImage(imagePath: imagePath, base64: base64, options: options) { + if let image = ImageCompressor.decodeDownsampledImage(imagePath: imagePath, base64: base64, input: options.input, maxWidth: options.maxWidth, maxHeight: options.maxHeight) { let outputExtension = ImageCompressorOptions.getOutputInString(options.output) let isBase64 = options.returnableOutputType == .rbase64 return ImageCompressor.writeImage(image, output: options.output.rawValue, quality: options.quality, outputExtension: outputExtension, isBase64: isBase64, disablePngTransparency: options.disablePngTransparency, isEnableAutoCompress: true, actualImagePath: imagePath) From 80d52d16211c499b26f6fc5003ef058e49c6e1c0 Mon Sep 17 00:00:00 2001 From: dakiquang Date: Wed, 4 Nov 2026 15:14:36 +0700 Subject: [PATCH 3/4] fix(android): keep enough decoded pixels to reach the fitted size calculateInSampleSize() rounds its ratios and can sample below the size that fits the bounds: a 2000x2000 source in the default 1280x1280 box decoded to 1000x1000, and the no-upscaling resize kept it there. Manual mode now picks the largest power-of-2 sample size that still decodes at least the fitted size, measured on the orientation-corrected source, so resize() only scales down. Auto mode keeps calculateInSampleSize(). --- .../Image/ImageCompressor.kt | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt b/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt index cbfdaf7..6c3553d 100644 --- a/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt +++ b/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt @@ -146,11 +146,39 @@ object ImageCompressor { val options = BitmapFactory.Options() options.inJustDecodeBounds = true decode(options) - options.inSampleSize = calculateInSampleSize(options, maxWidth, maxHeight) + options.inSampleSize = sampleSizeToFit(options.outWidth, options.outHeight, maxWidth, maxHeight) options.inJustDecodeBounds = false return decode(options) ?: throw IllegalArgumentException("Unable to decode image") } + /** + * Largest power-of-2 inSampleSize (BitmapFactory rounds down to one anyway) + * that still decodes at least the size fitted inside maxWidth x maxHeight, + * so resize() only ever scales down. calculateInSampleSize() rounds its + * ratios and can undershoot, e.g. 2000x2000 into 1280x1280 decodes 1000x1000. + */ + private fun sampleSizeToFit(width: Int, height: Int, maxWidth: Int, maxHeight: Int): Int { + val scale = minOf(maxWidth / width.toFloat(), maxHeight / height.toFloat()) + var inSampleSize = 1 + while (inSampleSize * 2 * scale <= 1f) { + inSampleSize *= 2 + } + return inSampleSize + } + + private fun isTransposed(filePath: String?): Boolean { + if (filePath == null) return false + return try { + when (ExifInterface(filePath).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) { + ExifInterface.ORIENTATION_TRANSPOSE, ExifInterface.ORIENTATION_ROTATE_90, + ExifInterface.ORIENTATION_TRANSVERSE, ExifInterface.ORIENTATION_ROTATE_270 -> true + else -> false + } + } catch (e: IOException) { + false + } + } + fun decodeImageDownsampled(value: String?, maxWidth: Int, maxHeight: Int): Bitmap { val data = Base64.decode(value, Base64.DEFAULT) return decodeDownsampled(maxWidth, maxHeight) { BitmapFactory.decodeByteArray(data, 0, data.size, it) } @@ -158,7 +186,10 @@ object ImageCompressor { fun loadImageDownsampled(imagePath: String?, maxWidth: Int, maxHeight: Int): Bitmap { val filePath = Uri.parse(imagePath).path - return decodeDownsampled(maxWidth, maxHeight) { BitmapFactory.decodeFile(filePath, it) } + // The bounds pass measures the stored, unrotated size; resize() runs after + // EXIF rotation, so a 90° rotation swaps which edge each limit applies to. + val rotated = isTransposed(filePath) + return decodeDownsampled(if (rotated) maxHeight else maxWidth, if (rotated) maxWidth else maxHeight) { BitmapFactory.decodeFile(filePath, it) } } fun manualCompressImage(imagePath: String?, options: ImageCompressorOptions, reactContext: ReactApplicationContext?): String? { From 5a485238b67e31c28afcbfb2fad9299c816f4483 Mon Sep 17 00:00:00 2001 From: dakiquang Date: Sat, 7 Nov 2026 16:49:40 +0700 Subject: [PATCH 4/4] fix(android): reject non-positive bounds before sampling maxWidth or maxHeight of 0 made sampleSizeToFit()'s scale 0, so the loop doubled inSampleSize until it overflowed and never returned, leaving the compression promise pending. Manual mode now rejects non-positive bounds with a clear error. --- .../java/com/reactnativecompressor/Image/ImageCompressor.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt b/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt index 6c3553d..85a75e5 100644 --- a/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt +++ b/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt @@ -143,6 +143,8 @@ object ImageCompressor { * BitmapFactory returns null rather than throwing on undecodable input. */ private fun decodeDownsampled(maxWidth: Int, maxHeight: Int, decode: (BitmapFactory.Options) -> Bitmap?): Bitmap { + // A zero bound makes sampleSizeToFit()'s scale 0, so its loop would never end. + require(maxWidth > 0 && maxHeight > 0) { "maxWidth and maxHeight must be greater than 0" } val options = BitmapFactory.Options() options.inJustDecodeBounds = true decode(options)