feat(camera): share jpeg transcoder + default maxWidth

This commit is contained in:
Peter Steinberger
2025-12-14 01:56:49 +00:00
parent e9eb9edc23
commit c3fa1fb736
7 changed files with 223 additions and 129 deletions

View File

@@ -27,5 +27,9 @@ let package = Package(
]),
.testTarget(
name: "ClawdisKitTests",
dependencies: ["ClawdisKit"]),
dependencies: ["ClawdisKit"],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]),
])

View File

@@ -0,0 +1,93 @@
import CoreGraphics
import Foundation
import ImageIO
import UniformTypeIdentifiers
public enum JPEGTranscodeError: LocalizedError, Sendable {
case decodeFailed
case propertiesMissing
case encodeFailed
public var errorDescription: String? {
switch self {
case .decodeFailed:
"Failed to decode image data"
case .propertiesMissing:
"Failed to read image properties"
case .encodeFailed:
"Failed to encode JPEG"
}
}
}
public struct JPEGTranscoder: Sendable {
public static func clampQuality(_ quality: Double) -> Double {
min(1.0, max(0.05, quality))
}
/// Re-encodes image data to JPEG, optionally downscaling so that the *oriented* pixel width is <= `maxWidthPx`.
///
/// - Important: This normalizes EXIF orientation (the output pixels are rotated if needed; orientation tag is not
/// relied on).
public static func transcodeToJPEG(
imageData: Data,
maxWidthPx: Int?,
quality: Double) throws -> (data: Data, widthPx: Int, heightPx: Int)
{
guard let src = CGImageSourceCreateWithData(imageData as CFData, nil) else {
throw JPEGTranscodeError.decodeFailed
}
guard
let props = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any],
let rawWidth = props[kCGImagePropertyPixelWidth] as? NSNumber,
let rawHeight = props[kCGImagePropertyPixelHeight] as? NSNumber
else {
throw JPEGTranscodeError.propertiesMissing
}
let pixelWidth = rawWidth.intValue
let pixelHeight = rawHeight.intValue
let orientation = (props[kCGImagePropertyOrientation] as? NSNumber)?.intValue ?? 1
guard pixelWidth > 0, pixelHeight > 0 else {
throw JPEGTranscodeError.propertiesMissing
}
let rotates90 = orientation == 5 || orientation == 6 || orientation == 7 || orientation == 8
let orientedWidth = rotates90 ? pixelHeight : pixelWidth
let orientedHeight = rotates90 ? pixelWidth : pixelHeight
let maxDim = max(orientedWidth, orientedHeight)
let targetMaxPixelSize: Int = {
guard let maxWidthPx, maxWidthPx > 0 else { return maxDim }
guard orientedWidth > maxWidthPx else { return maxDim } // never upscale
let scale = Double(maxWidthPx) / Double(orientedWidth)
return max(1, Int((Double(maxDim) * scale).rounded(.toNearestOrAwayFromZero)))
}()
let thumbOpts: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: targetMaxPixelSize,
kCGImageSourceShouldCacheImmediately: true,
]
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)
}
}

View File

@@ -1,28 +1,27 @@
import ClawdisKit
import XCTest
import Testing
final class BonjourEscapesTests: XCTestCase {
func testDecodePassThrough() {
XCTAssertEqual(BonjourEscapes.decode("hello"), "hello")
XCTAssertEqual(BonjourEscapes.decode(""), "")
@Suite struct BonjourEscapesTests {
@Test func decodePassThrough() {
#expect(BonjourEscapes.decode("hello") == "hello")
#expect(BonjourEscapes.decode("") == "")
}
func testDecodeSpaces() {
XCTAssertEqual(BonjourEscapes.decode("Clawdis\\032Gateway"), "Clawdis Gateway")
@Test func decodeSpaces() {
#expect(BonjourEscapes.decode("Clawdis\\032Gateway") == "Clawdis Gateway")
}
func testDecodeMultipleEscapes() {
XCTAssertEqual(
BonjourEscapes.decode("A\\038B\\047C\\032D"),
"A&B/C D")
@Test func decodeMultipleEscapes() {
#expect(BonjourEscapes.decode("A\\038B\\047C\\032D") == "A&B/C D")
}
func testDecodeIgnoresInvalidEscapeSequences() {
XCTAssertEqual(BonjourEscapes.decode("Hello\\03World"), "Hello\\03World")
XCTAssertEqual(BonjourEscapes.decode("Hello\\XYZWorld"), "Hello\\XYZWorld")
@Test func decodeIgnoresInvalidEscapeSequences() {
#expect(BonjourEscapes.decode("Hello\\03World") == "Hello\\03World")
#expect(BonjourEscapes.decode("Hello\\XYZWorld") == "Hello\\XYZWorld")
}
func testDecodeUsesDecimalUnicodeScalarValue() {
XCTAssertEqual(BonjourEscapes.decode("Hello\\065World"), "HelloAWorld")
@Test func decodeUsesDecimalUnicodeScalarValue() {
#expect(BonjourEscapes.decode("Hello\\065World") == "HelloAWorld")
}
}

View File

@@ -0,0 +1,73 @@
import ClawdisKit
import CoreGraphics
import ImageIO
import Testing
import UniformTypeIdentifiers
@Suite struct JPEGTranscoderTests {
private func makeSolidJPEG(width: Int, height: Int, orientation: Int? = nil) throws -> Data {
let cs = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
guard
let ctx = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: cs,
bitmapInfo: bitmapInfo)
else {
throw NSError(domain: "JPEGTranscoderTests", code: 1)
}
ctx.setFillColor(red: 1, green: 0, blue: 0, alpha: 1)
ctx.fill(CGRect(x: 0, y: 0, width: width, height: height))
guard let img = ctx.makeImage() else {
throw NSError(domain: "JPEGTranscoderTests", code: 5)
}
let out = NSMutableData()
guard let dest = CGImageDestinationCreateWithData(out, UTType.jpeg.identifier as CFString, 1, nil) else {
throw NSError(domain: "JPEGTranscoderTests", code: 2)
}
var props: [CFString: Any] = [
kCGImageDestinationLossyCompressionQuality: 1.0,
]
if let orientation {
props[kCGImagePropertyOrientation] = orientation
}
CGImageDestinationAddImage(dest, img, props as CFDictionary)
guard CGImageDestinationFinalize(dest) else {
throw NSError(domain: "JPEGTranscoderTests", code: 3)
}
return out as Data
}
@Test func downscalesToMaxWidthPx() throws {
let input = try makeSolidJPEG(width: 2000, height: 1000)
let out = try JPEGTranscoder.transcodeToJPEG(imageData: input, maxWidthPx: 1600, quality: 0.9)
#expect(out.widthPx == 1600)
#expect(abs(out.heightPx - 800) <= 1)
#expect(out.data.count > 0)
}
@Test func doesNotUpscaleWhenSmallerThanMaxWidthPx() throws {
let input = try makeSolidJPEG(width: 800, height: 600)
let out = try JPEGTranscoder.transcodeToJPEG(imageData: input, maxWidthPx: 1600, quality: 0.9)
#expect(out.widthPx == 800)
#expect(out.heightPx == 600)
}
@Test func normalizesOrientationAndUsesOrientedWidthForMaxWidthPx() throws {
// Encode a landscape image but mark it rotated 90° (orientation 6). Oriented width becomes 1000.
let input = try makeSolidJPEG(width: 2000, height: 1000, orientation: 6)
let out = try JPEGTranscoder.transcodeToJPEG(imageData: input, maxWidthPx: 1600, quality: 0.9)
#expect(out.widthPx == 1000)
#expect(out.heightPx == 2000)
}
}