fix: cap camera snap payload size
This commit is contained in:
@@ -7,6 +7,7 @@ public enum JPEGTranscodeError: LocalizedError, Sendable {
|
||||
case decodeFailed
|
||||
case propertiesMissing
|
||||
case encodeFailed
|
||||
case sizeLimitExceeded(maxBytes: Int, actualBytes: Int)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
@@ -16,6 +17,8 @@ public enum JPEGTranscodeError: LocalizedError, Sendable {
|
||||
"Failed to read image properties"
|
||||
case .encodeFailed:
|
||||
"Failed to encode JPEG"
|
||||
case let .sizeLimitExceeded(maxBytes, actualBytes):
|
||||
"JPEG exceeds size limit (\(actualBytes) bytes > \(maxBytes) bytes)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +35,8 @@ public struct JPEGTranscoder: Sendable {
|
||||
public static func transcodeToJPEG(
|
||||
imageData: Data,
|
||||
maxWidthPx: Int?,
|
||||
quality: Double) throws -> (data: Data, widthPx: Int, heightPx: Int)
|
||||
quality: Double,
|
||||
maxBytes: Int? = nil) throws -> (data: Data, widthPx: Int, heightPx: Int)
|
||||
{
|
||||
guard let src = CGImageSourceCreateWithData(imageData as CFData, nil) else {
|
||||
throw JPEGTranscodeError.decodeFailed
|
||||
@@ -58,7 +62,7 @@ public struct JPEGTranscoder: Sendable {
|
||||
let orientedHeight = rotates90 ? pixelWidth : pixelHeight
|
||||
|
||||
let maxDim = max(orientedWidth, orientedHeight)
|
||||
let targetMaxPixelSize: Int = {
|
||||
var targetMaxPixelSize: Int = {
|
||||
guard let maxWidthPx, maxWidthPx > 0 else { return maxDim }
|
||||
guard orientedWidth > maxWidthPx else { return maxDim } // never upscale
|
||||
|
||||
@@ -66,28 +70,66 @@ public struct JPEGTranscoder: Sendable {
|
||||
return max(1, Int((Double(maxDim) * scale).rounded(.toNearestOrAwayFromZero)))
|
||||
}()
|
||||
|
||||
let thumbOpts: [CFString: Any] = [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: targetMaxPixelSize,
|
||||
kCGImageSourceShouldCacheImmediately: true,
|
||||
]
|
||||
func encode(maxPixelSize: Int, quality: Double) throws -> (data: Data, widthPx: Int, heightPx: Int) {
|
||||
let thumbOpts: [CFString: Any] = [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
|
||||
kCGImageSourceShouldCacheImmediately: true,
|
||||
]
|
||||
|
||||
guard let img = CGImageSourceCreateThumbnailAtIndex(src, 0, thumbOpts as CFDictionary) else {
|
||||
throw JPEGTranscodeError.decodeFailed
|
||||
guard let img = CGImageSourceCreateThumbnailAtIndex(src, 0, thumbOpts as CFDictionary) else {
|
||||
throw JPEGTranscodeError.decodeFailed
|
||||
}
|
||||
|
||||
let out = NSMutableData()
|
||||
guard let dest = CGImageDestinationCreateWithData(out, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
throw JPEGTranscodeError.encodeFailed
|
||||
}
|
||||
let q = self.clampQuality(quality)
|
||||
let encodeProps = [kCGImageDestinationLossyCompressionQuality: q] as CFDictionary
|
||||
CGImageDestinationAddImage(dest, img, encodeProps)
|
||||
guard CGImageDestinationFinalize(dest) else {
|
||||
throw JPEGTranscodeError.encodeFailed
|
||||
}
|
||||
|
||||
return (out as Data, img.width, img.height)
|
||||
}
|
||||
|
||||
let out = NSMutableData()
|
||||
guard let dest = CGImageDestinationCreateWithData(out, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
throw JPEGTranscodeError.encodeFailed
|
||||
}
|
||||
let q = self.clampQuality(quality)
|
||||
let encodeProps = [kCGImageDestinationLossyCompressionQuality: q] as CFDictionary
|
||||
CGImageDestinationAddImage(dest, img, encodeProps)
|
||||
guard CGImageDestinationFinalize(dest) else {
|
||||
throw JPEGTranscodeError.encodeFailed
|
||||
guard let maxBytes, maxBytes > 0 else {
|
||||
return try encode(maxPixelSize: targetMaxPixelSize, quality: quality)
|
||||
}
|
||||
|
||||
return (out as Data, img.width, img.height)
|
||||
let minQuality = max(0.2, self.clampQuality(quality) * 0.35)
|
||||
let minPixelSize = 256
|
||||
var best = try encode(maxPixelSize: targetMaxPixelSize, quality: quality)
|
||||
if best.data.count <= maxBytes {
|
||||
return best
|
||||
}
|
||||
|
||||
for _ in 0..<6 {
|
||||
var q = self.clampQuality(quality)
|
||||
for _ in 0..<6 {
|
||||
let candidate = try encode(maxPixelSize: targetMaxPixelSize, quality: q)
|
||||
best = candidate
|
||||
if candidate.data.count <= maxBytes {
|
||||
return candidate
|
||||
}
|
||||
if q <= minQuality { break }
|
||||
q = max(minQuality, q * 0.75)
|
||||
}
|
||||
|
||||
let nextPixelSize = max(Int(Double(targetMaxPixelSize) * 0.85), minPixelSize)
|
||||
if nextPixelSize == targetMaxPixelSize {
|
||||
break
|
||||
}
|
||||
targetMaxPixelSize = nextPixelSize
|
||||
}
|
||||
|
||||
if best.data.count > maxBytes {
|
||||
throw JPEGTranscodeError.sizeLimitExceeded(maxBytes: maxBytes, actualBytes: best.data.count)
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user