diff --git a/bitchat/Features/media/ImageUtils.swift b/bitchat/Features/media/ImageUtils.swift index a6eb25d1cf..8c83007a78 100644 --- a/bitchat/Features/media/ImageUtils.swift +++ b/bitchat/Features/media/ImageUtils.swift @@ -1,3 +1,4 @@ +import BitFoundation import Foundation import ImageIO import UniformTypeIdentifiers @@ -14,10 +15,14 @@ enum ImageUtilsError: Error { enum ImageUtils { private static let compressionQuality: CGFloat = 0.82 - private static let targetImageBytes: Int = 45_000 + /// Encode against the protocol image budget, not a second magic squeeze. + /// Voice notes already travel at 512 KiB; photos should too. Android + /// encodes at 512px / q=85 with no 45 KB crush, so matching that budget + /// is what keeps a photo readable on the same radio. + private static let targetImageBytes: Int = FileTransferLimits.maxImageBytes private static let maxSourceImageBytes: Int = 10 * 1024 * 1024 - static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL { + static func processImage(at url: URL, maxDimension: CGFloat = 512, outputDirectory: URL? = nil) throws -> URL { try validateImageSource(at: url) let data = try Data(contentsOf: url) @@ -47,7 +52,7 @@ enum ImageUtils { } #if os(iOS) - static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL { + static func processImage(_ image: UIImage, maxDimension: CGFloat = 512, outputDirectory: URL? = nil) throws -> URL { return try autoreleasepool { // Scale the image first let scaled = scaledImage(image, maxDimension: maxDimension) @@ -82,11 +87,12 @@ enum ImageUtils { private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage { let size = image.size let maxSide = max(size.width, size.height) - guard maxSide > maxDimension else { return image } - let scale = maxDimension / maxSide + let scale = maxSide > maxDimension ? maxDimension / maxSide : 1 let newSize = CGSize(width: size.width * scale, height: size.height * scale) - // Draw into a new context to get a clean CGImage without metadata + // Always redraw so EXIF orientation is baked in even when the + // pixel size already fits. Returning the original left + // `image.cgImage` rotated for camera stills under 512px. UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0) image.draw(in: CGRect(origin: .zero, size: newSize)) let rendered = UIGraphicsGetImageFromCurrentImageContext() @@ -115,7 +121,7 @@ enum ImageUtils { return data as Data } #else - static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL { + static func processImage(_ image: NSImage, maxDimension: CGFloat = 512, outputDirectory: URL? = nil) throws -> URL { return try autoreleasepool { let scaled = scaledImage(image, maxDimension: maxDimension) guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else { @@ -160,8 +166,7 @@ enum ImageUtils { private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage { let size = image.size let maxSide = max(size.width, size.height) - guard maxSide > maxDimension else { return image } - let scale = maxDimension / maxSide + let scale = maxSide > maxDimension ? maxDimension / maxSide : 1 let newSize = NSSize(width: size.width * scale, height: size.height * scale) let scaledImage = NSImage(size: newSize) scaledImage.lockFocus() diff --git a/bitchatTests/Features/ImageUtilsTests.swift b/bitchatTests/Features/ImageUtilsTests.swift index a130b4eaca..265095e491 100644 --- a/bitchatTests/Features/ImageUtilsTests.swift +++ b/bitchatTests/Features/ImageUtilsTests.swift @@ -1,5 +1,7 @@ import Testing import Foundation +import ImageIO +import BitFoundation #if os(iOS) import UIKit #else @@ -22,6 +24,24 @@ private func makePlatformImage(size: CGSize) -> UIImage { context.fill(CGRect(origin: .zero, size: size)) } } + +private func makeNoisyPlatformImage(size: CGSize) -> UIImage { + UIGraphicsImageRenderer(size: size).image { _ in + for i in 0..<600 { + UIColor( + hue: CGFloat(i % 47) / 47, + saturation: 1, + brightness: CGFloat((i * 13) % 100) / 100, + alpha: 1 + ).setStroke() + let path = UIBezierPath() + path.move(to: CGPoint(x: CGFloat(i * 3 % Int(size.width)), y: 0)) + path.addLine(to: CGPoint(x: 0, y: CGFloat(i * 5 % Int(size.height)))) + path.lineWidth = 2 + path.stroke() + } + } +} #else private func makePlatformImage(size: CGSize) -> NSImage { let image = NSImage(size: size) @@ -31,8 +51,38 @@ private func makePlatformImage(size: CGSize) -> NSImage { image.unlockFocus() return image } + +private func makeNoisyPlatformImage(size: CGSize) -> NSImage { + let image = NSImage(size: size) + image.lockFocus() + for i in 0..<600 { + NSColor( + hue: CGFloat(i % 47) / 47, + saturation: 1, + brightness: CGFloat((i * 13) % 100) / 100, + alpha: 1 + ).setStroke() + let path = NSBezierPath() + path.move(to: CGPoint(x: CGFloat(i * 3 % Int(size.width)), y: 0)) + path.line(to: CGPoint(x: 0, y: CGFloat(i * 5 % Int(size.height)))) + path.lineWidth = 2 + path.stroke() + } + image.unlockFocus() + return image +} #endif +private func jpegPixelSize(_ data: Data) -> CGSize { + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = props[kCGImagePropertyPixelWidth] as? Int, + let height = props[kCGImagePropertyPixelHeight] as? Int else { + return .zero + } + return CGSize(width: width, height: height) +} + struct ImageUtilsTests { @Test func processImage_rejectsOversizedSourceFile() throws { @@ -72,6 +122,23 @@ struct ImageUtilsTests { #expect(data.count > 0) } + @Test + func processImage_usesThe512KiBBudgetInsteadOfCrushingTo45KB() throws { + let image = makeNoisyPlatformImage(size: CGSize(width: 1600, height: 1200)) + let outputDirectory = makeTemporaryDirectoryURL("image-budget-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: outputDirectory) } + + let outputURL = try ImageUtils.processImage(image, outputDirectory: outputDirectory) + let data = try Data(contentsOf: outputURL) + let pixelSize = jpegPixelSize(data) + + #expect(data.count > 45_000) + #expect(data.count <= FileTransferLimits.maxImageBytes) + #expect(pixelSize.width <= 512) + #expect(pixelSize.height <= 512) + #expect(max(pixelSize.width, pixelSize.height) == 512) + } + @Test func processImage_usesUniqueOutputURLs() throws { let image = makePlatformImage(size: CGSize(width: 64, height: 64))