Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions bitchat/Features/media/ImageUtils.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import BitFoundation
import Foundation
import ImageIO
import UniformTypeIdentifiers
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
67 changes: 67 additions & 0 deletions bitchatTests/Features/ImageUtilsTests.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import Testing
import Foundation
import ImageIO
import BitFoundation
#if os(iOS)
import UIKit
#else
Expand All @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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))
Expand Down
Loading