Description
On iOS, every numeric option passed to Image.compress() is silently ignored and replaced by the internal default (maxWidth/maxHeight → 1280, quality → 0.8). Values passed from JS have no effect at all, and there is no warning or error.
Android is unaffected. Video compression is unaffected.
Reproduction
import { Image } from 'react-native-compressor';
const uri = await Image.compress(sourceUri, {
compressionMethod: 'manual',
maxWidth: 1920,
maxHeight: 1920,
quality: 0.8,
returnableOutputType: 'uri',
});
With a 2922×3896 source:
- expected output: 1440×1920
- actual output: 960×1280 — i.e. bounded by
1280, the internal default
Passing maxWidth: 200 / maxHeight: 200 produces the same 1280-bounded result, which is how I first noticed it (a blurhash thumbnail step was ~40× more expensive than intended).
Root cause
ios/Image/ImageCompressorOptions.swift:
case "maxWidth":
options.maxWidth = (value as? Int) ?? 1280
case "maxHeight":
options.maxHeight = (value as? Int) ?? 1280
case "progressDivider":
options.progressDivider = (value as? Int) ?? 0
case "quality":
options.quality = (value as? Float) ?? 0.8
Nitro represents JS numbers as Double — AnyValue.number(Double), and toAny() returns that Double unchanged. HybridCompressor.dictionary(from:) then runs it through normalize() and an as NSDictionary round-trip, but neither of those boxes the value into NSNumber; it stays a Swift Double.
A conditional downcast from Any holding a Double to Int or Float always fails in Swift (no implicit numeric narrowing, and no bridging path from a raw Swift Double), so all four cases fall through to their ?? defaults.
Verified with a standalone Swift snippet that replicates dictionary(from:):
let fromAnyMap: [String: Any] = ["maxWidth": Double(1920), "quality": Double(0.8)]
let roundTripped = ((fromAnyMap as NSDictionary) as? [String: Any])!
// maxWidth: type=Double
// as? Int -> nil
// as? Float -> nil
// (as? NSNumber)?.intValue -> Optional(1920)
Video is unaffected because ios/Video/VideoMain.swift reads through NSNumber:
let maxSize = (options["maxSize"] as? NSNumber)?.floatValue ?? Float(1920)
let bitRate = (options["bitrate"] as? NSNumber)?.intValue
Double as? NSNumber succeeds, so that path behaves correctly. The two files just use different conventions.
Android is unaffected because ImageCompressorOptions.kt uses map.getInt(key) / map.getDouble(key).toFloat(), which coerce properly. The practical effect is that the same JS call produces different output dimensions on iOS and Android.
Affected options
maxWidth, maxHeight, quality, progressDivider — iOS only.
Suggested fix
Read through NSNumber, matching what the video side already does:
case "maxWidth":
options.maxWidth = (value as? NSNumber)?.intValue ?? 1280
case "maxHeight":
options.maxHeight = (value as? NSNumber)?.intValue ?? 1280
case "progressDivider":
options.progressDivider = (value as? NSNumber)?.intValue ?? 0
case "quality":
options.quality = (value as? NSNumber)?.floatValue ?? 0.8
I verified this handles all the cases:
Double from Nitro → correct value
Int / Float, if a caller ever passes one → still correct (backward compatible)
- key absent → existing default preserved
One detail: quality must use .floatValue, not .intValue — 0.8 would truncate to 0.
I'm running this as a patch-package patch locally and it resolves the issue. Happy to open a PR if that's useful.
Environment
|
|
| react-native-compressor |
2.0.3 |
| react-native-nitro-modules |
0.35.3 |
| React Native |
0.84.1 (new architecture) |
| Platform |
iOS |
Description
On iOS, every numeric option passed to
Image.compress()is silently ignored and replaced by the internal default (maxWidth/maxHeight→1280,quality→0.8). Values passed from JS have no effect at all, and there is no warning or error.Android is unaffected. Video compression is unaffected.
Reproduction
With a 2922×3896 source:
1280, the internal defaultPassing
maxWidth: 200/maxHeight: 200produces the same 1280-bounded result, which is how I first noticed it (a blurhash thumbnail step was ~40× more expensive than intended).Root cause
ios/Image/ImageCompressorOptions.swift:Nitro represents JS numbers as
Double—AnyValue.number(Double), andtoAny()returns thatDoubleunchanged.HybridCompressor.dictionary(from:)then runs it throughnormalize()and anas NSDictionaryround-trip, but neither of those boxes the value intoNSNumber; it stays a SwiftDouble.A conditional downcast from
Anyholding aDoubletoIntorFloatalways fails in Swift (no implicit numeric narrowing, and no bridging path from a raw SwiftDouble), so all four cases fall through to their??defaults.Verified with a standalone Swift snippet that replicates
dictionary(from:):Video is unaffected because
ios/Video/VideoMain.swiftreads throughNSNumber:Double as? NSNumbersucceeds, so that path behaves correctly. The two files just use different conventions.Android is unaffected because
ImageCompressorOptions.ktusesmap.getInt(key)/map.getDouble(key).toFloat(), which coerce properly. The practical effect is that the same JS call produces different output dimensions on iOS and Android.Affected options
maxWidth,maxHeight,quality,progressDivider— iOS only.Suggested fix
Read through
NSNumber, matching what the video side already does:I verified this handles all the cases:
Doublefrom Nitro → correct valueInt/Float, if a caller ever passes one → still correct (backward compatible)One detail:
qualitymust use.floatValue, not.intValue—0.8would truncate to0.I'm running this as a
patch-packagepatch locally and it resolves the issue. Happy to open a PR if that's useful.Environment