| 1 | import Foundation |
| 2 | import ImageIO |
| 3 | import UniformTypeIdentifiers |
| 4 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| 5 | import AppKit |
| 6 | #else |
| 7 | import UIKit |
| 8 | #endif |
| 9 | |
| 10 | // The native twin of shell.js processImage: cap the long edge at 2048, re-encode |
| 11 | // as JPEG 0.85. The re-encode is the privacy step — a fresh CGImageDestination |
| 12 | // with no copied properties writes NO EXIF/GPS, exactly like the web's canvas |
| 13 | // round-trip. Pure ImageIO/CoreGraphics, so it runs on both platforms. |
| 14 | public let maxImageDim = 2048.0 // shell.js MAX_IMAGE_DIM |
| 15 | public let maxUploadBytes = 25 << 20 // shell.js MAX_UPLOAD (matches main.go) |
| 16 | public let maxVideoUploadBytes = 50 << 20 // shell.js MAX_VIDEO_UPLOAD |
| 17 | |
| 18 | /// Downscale + re-encode an image for upload. Returns the JPEG bytes and the |
| 19 | /// final pixel dimensions (which ride inside the encrypted payload so the feed |
| 20 | /// can reserve the box before the blob loads), or nil for undecodable data. |
| 21 | public func processImage(_ data: Data, maxDim: Double = maxImageDim) -> (bytes: Data, w: Int, h: Int)? { |
| 22 | guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } |
| 23 | // kCGImageSourceCreateThumbnailWithTransform bakes in the EXIF orientation, |
| 24 | // like the browser's createImageBitmap does, so portrait photos stay upright |
| 25 | // once the metadata is stripped. |
| 26 | let options: [CFString: Any] = [ |
| 27 | kCGImageSourceCreateThumbnailFromImageAlways: true, |
| 28 | kCGImageSourceCreateThumbnailWithTransform: true, |
| 29 | kCGImageSourceThumbnailMaxPixelSize: maxDim, |
| 30 | ] |
| 31 | guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary), |
| 32 | let bytes = jpegData(from: image) else { return nil } |
| 33 | return (bytes, image.width, image.height) |
| 34 | } |
| 35 | |
| 36 | /// JPEG-encode a CGImage with a fresh destination (no metadata copied) — the |
| 37 | /// shared tail of processImage and the video poster extraction. |
| 38 | public func jpegData(from image: CGImage, quality: Double = 0.85) -> Data? { |
| 39 | let out = NSMutableData() |
| 40 | guard let dest = CGImageDestinationCreateWithData(out, UTType.jpeg.identifier as CFString, 1, nil) else { return nil } |
| 41 | CGImageDestinationAddImage(dest, image, [kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary) |
| 42 | guard CGImageDestinationFinalize(dest) else { return nil } |
| 43 | return out as Data |
| 44 | } |
| 45 | |
| 46 | // ---- display-side decode ---------------------------------------------------- |
| 47 | |
| 48 | /// Long-edge pixel caps for the two display classes a decoded blob can land |
| 49 | /// in: a feed photo/poster box tops out at 320pt (~960px @3x), an avatar |
| 50 | /// circle at 54pt. Decoding to the class instead of the full 2048px upload |
| 51 | /// cuts a photo's bitmap from ~13 MB to ~3 MB — and cuts the decode time the |
| 52 | /// same way. |
| 53 | public let feedImageMaxPixel = 1024 |
| 54 | public let avatarImageMaxPixel = 256 |
| 55 | |
| 56 | /// Decode image bytes into a bitmap that is ready to draw. UIImage(data:) / |
| 57 | /// NSImage(data:) don't decode — they defer the JPEG→bitmap work to the first |
| 58 | /// draw, which lands on the MAIN thread as the row scrolls in (the 1-2s |
| 59 | /// can't-scroll freeze on image-heavy chats). Call this off-main instead: |
| 60 | /// kCGImageSourceShouldCacheImmediately forces the decode here and now, on the |
| 61 | /// caller's thread, downsampled to `maxPixel` on the long edge. |
| 62 | /// |
| 63 | /// Pass nil for a full-fidelity decode (the lightbox's upgrade pass, Mac |
| 64 | /// animated GIFs that need NSImage's multi-frame rep) — still forced eagerly |
| 65 | /// on iOS via preparingForDisplay so no path defers to first draw. |
| 66 | public func decodeImageForDisplay(_ data: Data, maxPixel: Int?) -> PlatformImage? { |
| 67 | guard let maxPixel else { |
| 68 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| 69 | return NSImage(data: data) |
| 70 | #else |
| 71 | guard let img = UIImage(data: data) else { return nil } |
| 72 | return img.preparingForDisplay() ?? img |
| 73 | #endif |
| 74 | } |
| 75 | guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } |
| 76 | let options: [CFString: Any] = [ |
| 77 | kCGImageSourceCreateThumbnailFromImageAlways: true, |
| 78 | kCGImageSourceCreateThumbnailWithTransform: true, // bake in EXIF orientation |
| 79 | kCGImageSourceShouldCacheImmediately: true, // decode NOW, on this thread |
| 80 | kCGImageSourceThumbnailMaxPixelSize: maxPixel, |
| 81 | ] |
| 82 | guard let cg = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { |
| 83 | // ImageIO couldn't thumbnail it (rare) — the platform decoder gets a try. |
| 84 | return PlatformImage(data: data) |
| 85 | } |
| 86 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| 87 | return NSImage(cgImage: cg, size: NSSize(width: cg.width, height: cg.height)) |
| 88 | #else |
| 89 | return UIImage(cgImage: cg) |
| 90 | #endif |
| 91 | } |
| 92 | |