mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
Wi-Fi bulk: raise image-prep budget so real photos clear the 64 KiB AWDL threshold
Image prep downscaled every photo to 448 px and force-compressed to a 45 KB byte budget, so a typical camera photo landed ~40 KB — below TransportConfig.wifiBulkMinPayloadBytes (64 KiB). WifiBulkPolicy.shouldOffer requires payloadBytes > 64 KiB, so the Wi-Fi bulk (AWDL) data plane never triggered in production even when it worked on-device: real photos always fell back to BLE fragmentation. Raise the prep budget so genuinely detailed photos land well above 64 KiB while staying under the 512 KiB FileTransferLimits.maxImageBytes hard cap: - defaultMaxDimension 448 -> 1024 px - compressionQuality 0.82 -> 0.85 - targetImageBytes 45 KB -> 200 KB (a ceiling, not a target to hit) Measured on a representative photo-like image: ~40 KB before -> ~190 KB after, crossing the 64 KiB offer threshold while remaining ~2.6x under the hard cap. Because the raised dimension makes near-incompressible inputs (e.g. full- frame noise) able to exceed maxImageBytes at the quality floor, prep now downscales-and-retries until the payload fits the hard cap, so a send can never fail with imageTooLarge on pathological input (it couldn't at 448 px). Also de-dupes the previously copy-pasted per-platform encodeJPEG into one shared helper. Residual limitation: prep is not recipient-capability-aware. Images are prepared at this fidelity regardless of whether the recipient supports Wi-Fi bulk, so BLE-only peers and public broadcasts now carry ~190 KB images too (still within the existing 512 KiB image cap). Making prep choose fidelity per-recipient would need capability plumbing into the prep pipeline and doesn't help public broadcasts, so it's deferred. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
@@ -13,11 +14,28 @@ enum ImageUtilsError: Error {
|
||||
}
|
||||
|
||||
enum ImageUtils {
|
||||
private static let compressionQuality: CGFloat = 0.82
|
||||
private static let targetImageBytes: Int = 45_000
|
||||
private static let compressionQuality: CGFloat = 0.85
|
||||
// Upper bound for the compressed JPEG. This is only a ceiling: the encoder
|
||||
// keeps whatever a photo naturally weighs at `defaultMaxDimension` and
|
||||
// `compressionQuality`, and only steps quality down when a payload would
|
||||
// exceed this budget. It stays well under `FileTransferLimits.maxImageBytes`
|
||||
// (512 KiB) so the BLE path never overruns its cap.
|
||||
//
|
||||
// Wi-Fi bulk relevance: the old 45 KB / 448 px budget crushed every photo
|
||||
// to ~40 KB — below `TransportConfig.wifiBulkMinPayloadBytes` (64 KiB) — so
|
||||
// `WifiBulkPolicy.shouldOffer` never fired and the AWDL data plane was dead
|
||||
// in production. A genuinely detailed photo at `defaultMaxDimension` now
|
||||
// weighs well over 64 KiB, so it becomes Wi-Fi-bulk eligible to a capable
|
||||
// direct peer while still riding BLE fragmentation for everyone else.
|
||||
private static let targetImageBytes: Int = 200_000
|
||||
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
|
||||
// Longest-side ceiling for shared photos. 448 px was thumbnail-tier and
|
||||
// (together with the tiny byte budget) forced every photo below the Wi-Fi
|
||||
// bulk threshold. 1024 px keeps a shared photo legible and lets detailed
|
||||
// images clear 64 KiB, without approaching the 512 KiB hard cap.
|
||||
static let defaultMaxDimension: CGFloat = 1024
|
||||
|
||||
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||
static func processImage(at url: URL, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
|
||||
try validateImageSource(at: url)
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
@@ -47,34 +65,33 @@ 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 = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
// Scale the image first
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
|
||||
var dimension = maxDimension
|
||||
var jpegData: Data?
|
||||
// Downscale-and-compress until the payload fits the hard image cap.
|
||||
// A normal photo converges on the first pass; this loop only kicks
|
||||
// in for near-incompressible inputs (e.g. full-frame noise) that
|
||||
// would otherwise overrun `maxImageBytes` at the raised dimension.
|
||||
while true {
|
||||
let scaled = scaledImage(image, maxDimension: dimension)
|
||||
// Get CGImage from UIImage - this is the key to stripping metadata
|
||||
guard let cgImage = scaled.cgImage else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
|
||||
// Use CGImageDestination to encode without metadata (same as macOS)
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||
guard let data = compressToBudget(cgImage) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
|
||||
// Compress to target size
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
jpegData = data
|
||||
if data.count <= FileTransferLimits.maxImageBytes || dimension <= minRetryDimension {
|
||||
break
|
||||
}
|
||||
dimension = (dimension * dimensionRetryFactor).rounded(.down)
|
||||
}
|
||||
guard let finalData = jpegData else { throw ImageUtilsError.encodingFailed }
|
||||
|
||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
try finalData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
}
|
||||
@@ -93,31 +110,16 @@ enum ImageUtils {
|
||||
UIGraphicsEndImageContext()
|
||||
return rendered ?? image
|
||||
}
|
||||
|
||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||
return nil
|
||||
}
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
return nil
|
||||
}
|
||||
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||
// By only specifying compression quality and no metadata keys,
|
||||
// we ensure a clean JPEG with no privacy-leaking information
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageDestinationLossyCompressionQuality: quality
|
||||
]
|
||||
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
return nil
|
||||
}
|
||||
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 = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
var dimension = maxDimension
|
||||
var jpegData: Data?
|
||||
// See the iOS path: normal photos converge immediately; the loop
|
||||
// only shrinks further for near-incompressible inputs so the
|
||||
// output never overruns `maxImageBytes`.
|
||||
while true {
|
||||
let scaled = scaledImage(image, maxDimension: dimension)
|
||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
@@ -139,20 +141,18 @@ enum ImageUtils {
|
||||
guard let cgImage = context.makeImage() else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||
guard let data = compressToBudget(cgImage) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
jpegData = data
|
||||
if data.count <= FileTransferLimits.maxImageBytes || dimension <= minRetryDimension {
|
||||
break
|
||||
}
|
||||
dimension = (dimension * dimensionRetryFactor).rounded(.down)
|
||||
}
|
||||
guard let finalData = jpegData else { throw ImageUtilsError.encodingFailed }
|
||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
try finalData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
}
|
||||
@@ -172,6 +172,31 @@ enum ImageUtils {
|
||||
scaledImage.unlockFocus()
|
||||
return scaledImage
|
||||
}
|
||||
#endif
|
||||
|
||||
// When even the quality floor can't get an image under the byte budget,
|
||||
// shrink the longest side by this factor and re-encode. Bounded below so
|
||||
// the retry loop always terminates.
|
||||
private static let dimensionRetryFactor: CGFloat = 0.75
|
||||
private static let minRetryDimension: CGFloat = 256
|
||||
|
||||
/// Encodes `cgImage` to JPEG, stepping quality down toward
|
||||
/// `targetImageBytes`. Shared by both platforms.
|
||||
private static func compressToBudget(_ cgImage: CGImage) -> Data? {
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||
return nil
|
||||
}
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
}
|
||||
}
|
||||
return jpegData
|
||||
}
|
||||
|
||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||
@@ -193,7 +218,6 @@ enum ImageUtils {
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
||||
let formatter = DateFormatter()
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import BitFoundation
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import Testing
|
||||
import UniformTypeIdentifiers
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
@@ -58,6 +61,25 @@ struct ChatMediaPreparationTests {
|
||||
#expect(prepared.packet.fileSize == UInt64(prepared.packet.content.count))
|
||||
#expect(prepared.packet.encode() != nil)
|
||||
}
|
||||
|
||||
/// A genuinely detailed photo must prepare to more than
|
||||
/// `TransportConfig.wifiBulkMinPayloadBytes` (64 KiB); otherwise the Wi-Fi
|
||||
/// bulk (AWDL) data plane is never offered in production because
|
||||
/// `WifiBulkPolicy.shouldOffer` requires `payloadBytes > 64 KiB`.
|
||||
/// Regression guard for the ~40 KB over-compression gap (PR #1385).
|
||||
@Test
|
||||
func prepareImagePacket_detailedImageExceedsWifiBulkThreshold() throws {
|
||||
let sourceURL = try makeDetailedImageURL(dimension: 1200)
|
||||
defer { try? FileManager.default.removeItem(at: sourceURL) }
|
||||
|
||||
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
|
||||
defer { try? FileManager.default.removeItem(at: prepared.outputURL) }
|
||||
|
||||
// Comfortably above the 64 KiB Wi-Fi bulk offer threshold...
|
||||
#expect(prepared.packet.content.count > TransportConfig.wifiBulkMinPayloadBytes)
|
||||
// ...and still within the hard image cap for the BLE path.
|
||||
#expect(prepared.packet.content.count <= FileTransferLimits.maxImageBytes)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTemporaryImageURL() throws -> URL {
|
||||
@@ -87,6 +109,50 @@ private func makeTemporaryImageURL() throws -> URL {
|
||||
return url
|
||||
}
|
||||
|
||||
/// Builds a random-noise PNG. Noise is incompressible, so the JPEG the prep
|
||||
/// pipeline produces stays large — a faithful stand-in for a detailed photo,
|
||||
/// unlike a flat solid-color image which would compress to a few KB regardless
|
||||
/// of dimension.
|
||||
private func makeDetailedImageURL(dimension: Int) throws -> URL {
|
||||
let width = dimension
|
||||
let height = dimension
|
||||
let bytesPerPixel = 4
|
||||
let bytesPerRow = width * bytesPerPixel
|
||||
|
||||
var pixels = [UInt8](repeating: 0, count: bytesPerRow * height)
|
||||
for index in pixels.indices {
|
||||
pixels[index] = UInt8.random(in: 0...255)
|
||||
}
|
||||
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||
guard let context = CGContext(
|
||||
data: &pixels,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: bytesPerRow,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
), let cgImage = context.makeImage() else {
|
||||
throw ChatMediaPreparationTestError.imageEncodingFailed
|
||||
}
|
||||
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("detailed-\(UUID().uuidString).png")
|
||||
guard let destination = CGImageDestinationCreateWithURL(
|
||||
url as CFURL,
|
||||
UTType.png.identifier as CFString,
|
||||
1,
|
||||
nil
|
||||
) else {
|
||||
throw ChatMediaPreparationTestError.imageEncodingFailed
|
||||
}
|
||||
CGImageDestinationAddImage(destination, cgImage, nil)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
throw ChatMediaPreparationTestError.imageEncodingFailed
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private enum ChatMediaPreparationTestError: Error {
|
||||
case imageEncodingFailed
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user