diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 6bb9c181e..0648cd92d 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -1097,6 +1097,7 @@ BA3759972ABCCF360018D73B /* CameraPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759962ABCCF360018D73B /* CameraPreview.swift */; }; BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA693073295D649800ADDB87 /* UserSettingsStore.swift */; }; BAB68BED29543FA3007BA466 /* SelectWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */; }; + C97CD495610A53053DF15EB6 /* ThumbHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = B195F00DB0E42DA6D171C102 /* ThumbHash.swift */; }; D2277EEA2A089BD5006C3807 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2277EE92A089BD5006C3807 /* Router.swift */; }; D5C1AFBF2E5DF7E60092F72F /* ContactCardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */; }; D5C1AFC02E5DF7E60092F72F /* ContactCardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */; }; @@ -1491,7 +1492,7 @@ D73E5EFB2C6A97F4007EB227 /* ProfilePicturesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C30AC7F29A6A53F00E2BD5A /* ProfilePicturesView.swift */; }; D73E5EFC2C6A97F4007EB227 /* DamusAppNotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */; }; D73E5EFD2C6A97F4007EB227 /* InnerTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE0E2B529A3ED5500DB4CA2 /* InnerTimelineView.swift */; }; - D73E5EFE2C6A97F4007EB227 /* (null) in Sources */ = {isa = PBXBuildFile; }; + D73E5EFE2C6A97F4007EB227 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; D73E5EFF2C6A97F4007EB227 /* ZapsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE879572996C45300F758CC /* ZapsView.swift */; }; D73E5F002C6A97F4007EB227 /* CustomizeZapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C9F18E129AA9B6C008C55EC /* CustomizeZapView.swift */; }; D73E5F012C6A97F4007EB227 /* ZapTypePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA3FA0F29F593D000FDB3C3 /* ZapTypePicker.swift */; }; @@ -1904,7 +1905,9 @@ E0E024112B7C19C20075735D /* TranslationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E024102B7C19C20075735D /* TranslationTests.swift */; }; E0EE9DD42B8E5FEA00F3002D /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0EE9DD32B8E5FEA00F3002D /* ImageProcessing.swift */; }; E4FA1C032A24BB7F00482697 /* SearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */; }; + E90EA444DF1ECF6E2AC5C569 /* ThumbHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = B195F00DB0E42DA6D171C102 /* ThumbHash.swift */; }; E990020F2955F837003BBC5A /* EditMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E990020E2955F837003BBC5A /* EditMetadataView.swift */; }; + EF60FBD3ADD51A9BA4C5707A /* ThumbHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = B195F00DB0E42DA6D171C102 /* ThumbHash.swift */; }; F71694EA2A662232001F4053 /* OnboardingSuggestionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694E92A662232001F4053 /* OnboardingSuggestionsView.swift */; }; F71694EC2A662292001F4053 /* SuggestedUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694EB2A662292001F4053 /* SuggestedUsersViewModel.swift */; }; F71694F22A67314D001F4053 /* SuggestedUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694F12A67314D001F4053 /* SuggestedUserView.swift */; }; @@ -2682,6 +2685,7 @@ 9C83F89229A937B900136C08 /* TextViewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextViewWrapper.swift; sourceTree = ""; }; 9CA876E129A00CE90003B9A3 /* AttachMediaUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachMediaUtility.swift; sourceTree = ""; }; ADFE73542AD4793100EC7326 /* QRScanNSECView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QRScanNSECView.swift; sourceTree = ""; }; + B195F00DB0E42DA6D171C102 /* ThumbHash.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ThumbHash.swift; sourceTree = ""; }; B501062C2B363036003874F5 /* AuthIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthIntegrationTests.swift; sourceTree = ""; usesTabs = 0; }; B51C1CE82B55A60A00E312A9 /* AddMuteItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddMuteItemView.swift; sourceTree = ""; }; B51C1CE92B55A60A00E312A9 /* MuteDurationMenu.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MuteDurationMenu.swift; sourceTree = ""; }; @@ -4077,6 +4081,15 @@ path = Images; sourceTree = ""; }; + 55FDC8D4999715F52C16C399 /* ThumbHash */ = { + isa = PBXGroup; + children = ( + B195F00DB0E42DA6D171C102 /* ThumbHash.swift */, + ); + name = ThumbHash; + path = ThumbHash; + sourceTree = ""; + }; 5C78A7752E22F84A00CF177D /* Core */ = { isa = PBXGroup; children = ( @@ -4546,6 +4559,7 @@ D7FA46E42DBDAA75002C9BB0 /* ImageCacheMigrations.swift */, 50C3E0892AA8E3F7006A4BC0 /* AVPlayer+Additions.swift */, 7C60CAEE298471A1009C80D6 /* CoreSVG.swift */, + 55FDC8D4999715F52C16C399 /* ThumbHash */, ); path = Media; sourceTree = ""; @@ -5556,7 +5570,7 @@ ); mainGroup = 4CE6DEDA27F7A08100C66700; packageReferences = ( - 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */, + 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1.swift" */, 4C06670228FC7EC500038D2A /* XCRemoteSwiftPackageReference "Kingfisher" */, 4CCF9AB02A1FE80B00E03CFB /* XCRemoteSwiftPackageReference "GSPlayer" */, 4C27C9302A64766F007DBC75 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, @@ -6236,6 +6250,7 @@ 4C9B0DF32A65C46800CBDA21 /* ProfileEditButton.swift in Sources */, 4C32B95F2A9AD44700DC3548 /* Enum.swift in Sources */, 4C2859622A12A7F0004746F7 /* GoldSupportGradient.swift in Sources */, + EF60FBD3ADD51A9BA4C5707A /* ThumbHash.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -6854,6 +6869,7 @@ 82D6FC7B2CD99F7900C925F4 /* TestData.swift in Sources */, 82D6FC7C2CD99F7900C925F4 /* ContentParsing.swift in Sources */, 82D6FC7D2CD99F7900C925F4 /* NotificationFormatter.swift in Sources */, + C97CD495610A53053DF15EB6 /* ThumbHash.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -7143,7 +7159,7 @@ D73E5EFB2C6A97F4007EB227 /* ProfilePicturesView.swift in Sources */, D73E5EFC2C6A97F4007EB227 /* DamusAppNotificationView.swift in Sources */, D73E5EFD2C6A97F4007EB227 /* InnerTimelineView.swift in Sources */, - D73E5EFE2C6A97F4007EB227 /* (null) in Sources */, + D73E5EFE2C6A97F4007EB227 /* BuildFile in Sources */, D7EB00B02CD59C8D00660C07 /* PresentFullScreenItemNotify.swift in Sources */, D73E5EFF2C6A97F4007EB227 /* ZapsView.swift in Sources */, D73E5F002C6A97F4007EB227 /* CustomizeZapView.swift in Sources */, @@ -7409,6 +7425,7 @@ D703D75B2C670A7F00A400EA /* Contacts.swift in Sources */, D703D7812C670C2B00A400EA /* Bech32.swift in Sources */, D73E5E1E2C6A9694007EB227 /* RelayFilters.swift in Sources */, + E90EA444DF1ECF6E2AC5C569 /* ThumbHash.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -8344,7 +8361,7 @@ kind = branch; }; }; - 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */ = { + 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1.swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/jb55/secp256k1.swift"; requirement = { @@ -8440,12 +8457,12 @@ }; 4C649880286E0EE300EAE2B3 /* secp256k1 */ = { isa = XCSwiftPackageProductDependency; - package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */; + package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1.swift" */; productName = secp256k1; }; 82D6FC802CD99FC500C925F4 /* secp256k1 */ = { isa = XCSwiftPackageProductDependency; - package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */; + package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1.swift" */; productName = secp256k1; }; 82D6FC832CD9A48500C925F4 /* Kingfisher */ = { @@ -8470,7 +8487,7 @@ }; D703D7482C6709B100A400EA /* secp256k1 */ = { isa = XCSwiftPackageProductDependency; - package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */; + package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1.swift" */; productName = secp256k1; }; D703D7AC2C670FA700A400EA /* MarkdownUI */ = { @@ -8515,7 +8532,7 @@ }; D789D11F2AFEFBF20083A7AB /* secp256k1 */ = { isa = XCSwiftPackageProductDependency; - package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */; + package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1.swift" */; productName = secp256k1; }; D78DB8582C1CE9CA00F0AB12 /* SwipeActions */ = { diff --git a/damus/Features/Posting/Views/PostView.swift b/damus/Features/Posting/Views/PostView.swift index 70789fdea..f9e6a0dd5 100644 --- a/damus/Features/Posting/Views/PostView.swift +++ b/damus/Features/Posting/Views/PostView.swift @@ -343,13 +343,17 @@ struct PostView: View { func handle_upload(media: MediaUpload) async -> Bool { mediaUploadUnderProgress = media let uploader = damus_state.settings.default_media_uploader - + let img = getImage(media: media) print("img size w:\(img.size.width) h:\(img.size.height)") - - async let blurhash = calculate_blurhash(img: img) + + // Calculate both hashes concurrently with upload for better performance + // - thumbhash: better quality, aspect ratio, alpha (newer clients) + // - blurhash: backwards compatibility with existing clients + async let thumbhashTask = calculate_thumbhash(img: img) + async let blurhashTask = calculate_blurhash(img: img) let res = await image_upload.start(media: media, uploader: uploader, mediaType: .normal, keypair: damus_state.keypair) - + mediaUploadUnderProgress = nil switch res { case .success(let url): @@ -357,12 +361,13 @@ struct PostView: View { self.error = "Error uploading image :(" return false } - let blurhash = await blurhash - let meta = blurhash.map { bh in calculate_image_metadata(url: url, img: img, blurhash: bh) } + let thumbhash = await thumbhashTask + let blurhash = await blurhashTask + let meta = calculate_image_metadata(url: url, img: img, thumbhash: thumbhash, blurhash: blurhash) let uploadedMedia = UploadedMedia(localURL: media.localURL, uploadedURL: url, metadata: meta) uploadedMedias.append(uploadedMedia) return true - + case .failed(let error): if let error { self.error = error.localizedDescription diff --git a/damus/Shared/Media/Images/ImageMetadata.swift b/damus/Shared/Media/Images/ImageMetadata.swift index a3cce5ddc..682be8d23 100644 --- a/damus/Shared/Media/Images/ImageMetadata.swift +++ b/damus/Shared/Media/Images/ImageMetadata.swift @@ -37,25 +37,32 @@ struct ImageMetaDim: Equatable, StringCodable { struct ImageMetadata: Equatable { let url: URL let blurhash: String? + let thumbhash: String? // ThumbHash: better detail, aspect ratio, alpha support let dim: ImageMetaDim? - - init(url: URL, blurhash: String? = nil, dim: ImageMetaDim? = nil) { + + init(url: URL, blurhash: String? = nil, thumbhash: String? = nil, dim: ImageMetaDim? = nil) { self.url = url self.blurhash = blurhash + self.thumbhash = thumbhash self.dim = dim } - + init?(tag: [String]) { guard let meta = decode_image_metadata(tag) else { return nil } - + self = meta } - + func to_tag() -> [String] { return image_metadata_to_tag(self) } + + /// Returns true if we have any placeholder hash (thumbhash preferred over blurhash) + var hasPlaceholder: Bool { + thumbhash != nil || blurhash != nil + } } func process_blurhash(blurhash: String, size: CGSize?) async -> UIImage? { @@ -68,12 +75,45 @@ func process_blurhash(blurhash: String, size: CGSize?) async -> UIImage? { } return img } - + return await res.value } +/// Decodes a base64-encoded ThumbHash string into a UIImage placeholder. +/// ThumbHash produces better quality placeholders than BlurHash with embedded aspect ratio. +func process_thumbhash(thumbhash: String, size: CGSize?) async -> UIImage? { + let res = Task.detached(priority: .low) { () -> UIImage? in + // ThumbHash is stored as base64-encoded data + guard let hashData = Data(base64Encoded: thumbhash) else { + return nil + } + // thumbHashToImage handles aspect ratio internally, returns ~32x32 image + return thumbHashToImage(hash: hashData) + } + return await res.value +} + +/// Processes a placeholder hash, preferring thumbhash over blurhash. +/// Returns a UIImage suitable for display while the full image loads. +func process_placeholder(meta: ImageMetadata) async -> UIImage? { + // Prefer thumbhash: better quality, embedded aspect ratio, alpha support + if let thumbhash = meta.thumbhash { + return await process_thumbhash(thumbhash: thumbhash, size: meta.dim?.size) + } + // Fall back to blurhash for interoperability with other Nostr clients + if let blurhash = meta.blurhash { + return await process_blurhash(blurhash: blurhash, size: meta.dim?.size) + } + return nil +} + func image_metadata_to_tag(_ meta: ImageMetadata) -> [String] { var tags = ["imeta", "url \(meta.url.absoluteString)"] + // Include thumbhash if available (preferred placeholder format) + if let thumbhash = meta.thumbhash { + tags.append("thumbhash \(thumbhash)") + } + // Also include blurhash for backwards compatibility with older clients if let blurhash = meta.blurhash { tags.append("blurhash \(blurhash)") } @@ -86,35 +126,43 @@ func image_metadata_to_tag(_ meta: ImageMetadata) -> [String] { func decode_image_metadata(_ parts: [String]) -> ImageMetadata? { var url: URL? = nil var blurhash: String? = nil + var thumbhash: String? = nil var dim: ImageMetaDim? = nil - + for part in parts { + // Skip the "imeta" tag identifier if part == "imeta" { continue } - + let ps = part.split(separator: " ") - + guard ps.count == 2 else { return nil } let pname = ps[0] let pval = ps[1] - - if pname == "blurhash" { + + switch pname { + case "thumbhash": + thumbhash = String(pval) + case "blurhash": blurhash = String(pval) - } else if pname == "dim" { + case "dim": dim = parse_image_meta_dim(String(pval)) - } else if pname == "url" { + case "url": url = URL(string: String(pval)) + default: + // Ignore unknown fields for forward compatibility + break } } - + guard let url else { return nil } - return ImageMetadata(url: url, blurhash: blurhash, dim: dim) + return ImageMetadata(url: url, blurhash: blurhash, thumbhash: thumbhash, dim: dim) } func parse_image_meta_dim(_ pval: String) -> ImageMetaDim? { @@ -145,11 +193,11 @@ func calculate_blurhash(img: UIImage) async -> String? { guard img.size.height > 0 else { return nil } - + let res = Task.detached(priority: .low) { let bhs = get_blurhash_size(img_size: img.size) ?? CGSize(width: 100.0, height: 100.0) let smaller = img.resized(to: bhs) - + guard let blurhash = smaller.blurHash(numberOfComponents: (5,5)) else { let meta: String? = nil return meta @@ -157,16 +205,38 @@ func calculate_blurhash(img: UIImage) async -> String? { return blurhash } - + + return await res.value +} + +/// Calculates a ThumbHash from a UIImage. +/// The hash is returned as a base64-encoded string suitable for storage in imeta tags. +/// ThumbHash automatically handles aspect ratio and produces ~25 bytes of data. +func calculate_thumbhash(img: UIImage) async -> String? { + guard img.size.width > 0, img.size.height > 0 else { + return nil + } + + let res = Task.detached(priority: .low) { () -> String? in + // imageToThumbHash handles resizing internally (max 100x100) + let hashData = imageToThumbHash(image: img) + // Return as base64 string for storage in Nostr events + return hashData.base64EncodedString() + } + return await res.value } -func calculate_image_metadata(url: URL, img: UIImage, blurhash: String) -> ImageMetadata { +/// Creates ImageMetadata with placeholder hashes for uploaded images. +/// Both thumbhash and blurhash are included for maximum client compatibility: +/// - thumbhash: better quality, aspect ratio, alpha support (newer clients) +/// - blurhash: widely supported by existing Nostr clients +func calculate_image_metadata(url: URL, img: UIImage, thumbhash: String?, blurhash: String?) -> ImageMetadata { let width = Int(img.size.width) let height = Int(img.size.height) let dim = ImageMetaDim(width: width, height: height) - - return ImageMetadata(url: url, blurhash: blurhash, dim: dim) + + return ImageMetadata(url: url, blurhash: blurhash, thumbhash: thumbhash, dim: dim) } @@ -183,25 +253,31 @@ func event_image_metadata(ev: NostrEvent) -> [ImageMetadata] { func process_image_metadatas(cache: EventCache, ev: NostrEvent) { for meta in event_image_metadata(ev: ev) { + // Skip if already cached guard cache.lookup_img_metadata(url: meta.url) == nil else { continue } - - // We don't need blurhash if we already have the source image cached + + // Skip placeholder processing if the source image is already cached if ImageCache.default.isCached(forKey: meta.url.absoluteString) { continue } - - let state = ImageMetadataState(state: meta.blurhash == nil ? .not_needed : .processing, meta: meta) + + // Determine initial state based on whether we have any placeholder hash + let needsProcessing = meta.hasPlaceholder + let initialState: ImageMetaProcessState = needsProcessing ? .processing : .not_needed + let state = ImageMetadataState(state: initialState, meta: meta) cache.store_img_metadata(url: meta.url, meta: state) - - guard let blurhash = state.meta.blurhash else { - return + + // Skip async processing if no placeholder hash is available + guard needsProcessing else { + continue } - + + // Process placeholder asynchronously (thumbhash preferred, blurhash fallback) Task { - let img = await process_blurhash(blurhash: blurhash, size: state.meta.dim?.size) - + let img = await process_placeholder(meta: state.meta) + Task { @MainActor in if let img { state.state = .processed(img) diff --git a/damus/Shared/Media/ThumbHash/ThumbHash.swift b/damus/Shared/Media/ThumbHash/ThumbHash.swift new file mode 100644 index 000000000..c0fb21981 --- /dev/null +++ b/damus/Shared/Media/ThumbHash/ThumbHash.swift @@ -0,0 +1,708 @@ +// +// ThumbHash.swift +// damus +// +// ThumbHash implementation from https://github.com/evanw/thumbhash +// Author: Evan Wallace (https://evanw.github.io/thumbhash/) +// License: MIT +// +// ThumbHash is a compact representation of an image placeholder. +// Compared to BlurHash, it offers: +// - Better detail preservation +// - Aspect ratio information embedded in the hash +// - Alpha channel (transparency) support +// - More accurate color representation +// +// The hash is typically 25 bytes (base64: ~33 chars), similar to BlurHash. +// + +import Foundation + +// NOTE: Swift has an exponential-time type checker and compiling very simple +// expressions can easily take many seconds, especially when expressions involve +// numeric type constructors. +// +// This file deliberately breaks compound expressions up into separate variables +// to improve compile time even though this comes at the expense of readability. +// This is a known workaround for this deficiency in the Swift compiler. +// +// The following command is helpful when debugging Swift compile time issues: +// +// swiftc ThumbHash.swift -Xfrontend -debug-time-function-bodies +// +// These optimizations brought the compile time for this file from around 2.5 +// seconds to around 250ms (10x faster). + +// NOTE: Swift's debug-build performance of for-in loops over numeric ranges is +// really awful. Debug builds compile a very generic indexing iterator thing +// that makes many nested calls for every iteration, which makes debug-build +// performance crawl. +// +// This file deliberately avoids for-in loops that loop for more than a few +// times to improve debug-build run time even though this comes at the expense +// of readability. Similarly unsafe pointers are used instead of array getters +// to avoid unnecessary bounds checks, which have extra overhead in debug builds. +// +// These optimizations brought the run time to encode and decode 10 ThumbHashes +// in debug mode from 700ms to 70ms (10x faster). + +/// Encodes RGBA pixel data into a ThumbHash. +/// +/// - Parameters: +/// - w: Image width (must be <= 100) +/// - h: Image height (must be <= 100) +/// - rgba: Raw RGBA pixel data (w * h * 4 bytes) +/// - Returns: ThumbHash data (~25 bytes) +func rgbaToThumbHash(w: Int, h: Int, rgba: Data) -> Data { + // Encoding an image larger than 100x100 is slow with no benefit + assert(w <= 100 && h <= 100) + assert(rgba.count == w * h * 4) + + // Determine the average color + var avg_r: Float32 = 0 + var avg_g: Float32 = 0 + var avg_b: Float32 = 0 + var avg_a: Float32 = 0 + rgba.withUnsafeBytes { rgba in + var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count) + let n = w * h + var i = 0 + while i < n { + let alpha = Float32(rgba[3]) / 255 + avg_r += alpha / 255 * Float32(rgba[0]) + avg_g += alpha / 255 * Float32(rgba[1]) + avg_b += alpha / 255 * Float32(rgba[2]) + avg_a += alpha + rgba = rgba.advanced(by: 4) + i += 1 + } + } + if avg_a > 0 { + avg_r /= avg_a + avg_g /= avg_a + avg_b /= avg_a + } + + let hasAlpha = avg_a < Float32(w * h) + let l_limit = hasAlpha ? 5 : 7 // Use fewer luminance bits if there's alpha + let imax_wh = max(w, h) + let iwl_limit = l_limit * w + let ihl_limit = l_limit * h + let fmax_wh = Float32(imax_wh) + let fwl_limit = Float32(iwl_limit) + let fhl_limit = Float32(ihl_limit) + let flx = round(fwl_limit / fmax_wh) + let fly = round(fhl_limit / fmax_wh) + var lx = Int(flx) + var ly = Int(fly) + lx = max(1, lx) + ly = max(1, ly) + var lpqa = [Float32](repeating: 0, count: w * h * 4) + + // Convert the image from RGBA to LPQA (composite atop the average color) + rgba.withUnsafeBytes { rgba in + lpqa.withUnsafeMutableBytes { lpqa in + var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count) + var lpqa = lpqa.baseAddress!.bindMemory(to: Float32.self, capacity: lpqa.count) + let n = w * h + var i = 0 + while i < n { + let alpha = Float32(rgba[3]) / 255 + let r = avg_r * (1 - alpha) + alpha / 255 * Float32(rgba[0]) + let g = avg_g * (1 - alpha) + alpha / 255 * Float32(rgba[1]) + let b = avg_b * (1 - alpha) + alpha / 255 * Float32(rgba[2]) + lpqa[0] = (r + g + b) / 3 + lpqa[1] = (r + g) / 2 - b + lpqa[2] = r - g + lpqa[3] = alpha + rgba = rgba.advanced(by: 4) + lpqa = lpqa.advanced(by: 4) + i += 1 + } + } + } + + // Encode using the DCT into DC (constant) and normalized AC (varying) terms + let encodeChannel = { (channel: UnsafePointer, nx: Int, ny: Int) -> (Float32, [Float32], Float32) in + var dc: Float32 = 0 + var ac: [Float32] = [] + var scale: Float32 = 0 + var fx = [Float32](repeating: 0, count: w) + fx.withUnsafeMutableBytes { fx in + let fx = fx.baseAddress!.bindMemory(to: Float32.self, capacity: fx.count) + var cy = 0 + while cy < ny { + var cx = 0 + while cx * ny < nx * (ny - cy) { + var ptr = channel + var f: Float32 = 0 + var x = 0 + while x < w { + let fw = Float32(w) + let fxx = Float32(x) + let fcx = Float32(cx) + fx[x] = cos(Float32.pi / fw * fcx * (fxx + 0.5)) + x += 1 + } + var y = 0 + while y < h { + let fh = Float32(h) + let fyy = Float32(y) + let fcy = Float32(cy) + let fy = cos(Float32.pi / fh * fcy * (fyy + 0.5)) + var x = 0 + while x < w { + f += ptr.pointee * fx[x] * fy + x += 1 + ptr = ptr.advanced(by: 4) + } + y += 1 + } + f /= Float32(w * h) + if cx > 0 || cy > 0 { + ac.append(f) + scale = max(scale, abs(f)) + } else { + dc = f + } + cx += 1 + } + cy += 1 + } + } + if scale > 0 { + let n = ac.count + var i = 0 + while i < n { + ac[i] = 0.5 + 0.5 / scale * ac[i] + i += 1 + } + } + return (dc, ac, scale) + } + let ( + (l_dc, l_ac, l_scale), + (p_dc, p_ac, p_scale), + (q_dc, q_ac, q_scale), + (a_dc, a_ac, a_scale) + ) = lpqa.withUnsafeBytes { lpqa in + let lpqa = lpqa.baseAddress!.bindMemory(to: Float32.self, capacity: lpqa.count) + return ( + encodeChannel(lpqa, max(3, lx), max(3, ly)), + encodeChannel(lpqa.advanced(by: 1), 3, 3), + encodeChannel(lpqa.advanced(by: 2), 3, 3), + hasAlpha ? encodeChannel(lpqa.advanced(by: 3), 5, 5) : (1, [], 1) + ) + } + + // Write the constants + let isLandscape = w > h + let fl_dc = round(63.0 * l_dc) + let fp_dc = round(31.5 + 31.5 * p_dc) + let fq_dc = round(31.5 + 31.5 * q_dc) + let fl_scale = round(31.0 * l_scale) + let il_dc = UInt32(fl_dc) + let ip_dc = UInt32(fp_dc) + let iq_dc = UInt32(fq_dc) + let il_scale = UInt32(fl_scale) + let ihasAlpha = UInt32(hasAlpha ? 1 : 0) + let header24 = il_dc | (ip_dc << 6) | (iq_dc << 12) | (il_scale << 18) | (ihasAlpha << 23) + let fp_scale = round(63.0 * p_scale) + let fq_scale = round(63.0 * q_scale) + let ilxy = UInt16(isLandscape ? ly : lx) + let ip_scale = UInt16(fp_scale) + let iq_scale = UInt16(fq_scale) + let iisLandscape = UInt16(isLandscape ? 1 : 0) + let header16 = ilxy | (ip_scale << 3) | (iq_scale << 9) | (iisLandscape << 15) + var hash = Data(capacity: 25) + hash.append(UInt8(header24 & 255)) + hash.append(UInt8((header24 >> 8) & 255)) + hash.append(UInt8(header24 >> 16)) + hash.append(UInt8(header16 & 255)) + hash.append(UInt8(header16 >> 8)) + var isOdd = false + if hasAlpha { + let fa_dc = round(15.0 * a_dc) + let fa_scale = round(15.0 * a_scale) + let ia_dc = UInt8(fa_dc) + let ia_scale = UInt8(fa_scale) + hash.append(ia_dc | (ia_scale << 4)) + } + + // Write the varying factors + for ac in [l_ac, p_ac, q_ac] { + for f in ac { + let f15 = round(15.0 * f) + let i15 = UInt8(f15) + if isOdd { + hash[hash.count - 1] |= i15 << 4 + } else { + hash.append(i15) + } + isOdd = !isOdd + } + } + if hasAlpha { + for f in a_ac { + let f15 = round(15.0 * f) + let i15 = UInt8(f15) + if isOdd { + hash[hash.count - 1] |= i15 << 4 + } else { + hash.append(i15) + } + isOdd = !isOdd + } + } + return hash +} + +/// Decodes a ThumbHash into RGBA pixel data. +/// +/// - Parameter hash: ThumbHash data +/// - Returns: Tuple of (width, height, rgba pixel data) +func thumbHashToRGBA(hash: Data) -> (Int, Int, Data) { + // Read the constants + let h0 = UInt32(hash[0]) + let h1 = UInt32(hash[1]) + let h2 = UInt32(hash[2]) + let h3 = UInt16(hash[3]) + let h4 = UInt16(hash[4]) + let header24 = h0 | (h1 << 8) | (h2 << 16) + let header16 = h3 | (h4 << 8) + let il_dc = header24 & 63 + let ip_dc = (header24 >> 6) & 63 + let iq_dc = (header24 >> 12) & 63 + var l_dc = Float32(il_dc) + var p_dc = Float32(ip_dc) + var q_dc = Float32(iq_dc) + l_dc = l_dc / 63 + p_dc = p_dc / 31.5 - 1 + q_dc = q_dc / 31.5 - 1 + let il_scale = (header24 >> 18) & 31 + var l_scale = Float32(il_scale) + l_scale = l_scale / 31 + let hasAlpha = (header24 >> 23) != 0 + let ip_scale = (header16 >> 3) & 63 + let iq_scale = (header16 >> 9) & 63 + var p_scale = Float32(ip_scale) + var q_scale = Float32(iq_scale) + p_scale = p_scale / 63 + q_scale = q_scale / 63 + let isLandscape = (header16 >> 15) != 0 + let lx16 = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7) + let ly16 = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7) + let lx = Int(lx16) + let ly = Int(ly16) + var a_dc = Float32(1) + var a_scale = Float32(1) + if hasAlpha { + let ia_dc = hash[5] & 15 + let ia_scale = hash[5] >> 4 + a_dc = Float32(ia_dc) + a_scale = Float32(ia_scale) + a_dc /= 15 + a_scale /= 15 + } + + // Read the varying factors (boost saturation by 1.25x to compensate for quantization) + let ac_start = hasAlpha ? 6 : 5 + var ac_index = 0 + let decodeChannel = { (nx: Int, ny: Int, scale: Float32) -> [Float32] in + var ac: [Float32] = [] + for cy in 0 ..< ny { + var cx = cy > 0 ? 0 : 1 + while cx * ny < nx * (ny - cy) { + let iac = (hash[ac_start + (ac_index >> 1)] >> ((ac_index & 1) << 2)) & 15; + var fac = Float32(iac) + fac = (fac / 7.5 - 1) * scale + ac.append(fac) + ac_index += 1 + cx += 1 + } + } + return ac + } + let l_ac = decodeChannel(lx, ly, l_scale) + let p_ac = decodeChannel(3, 3, p_scale * 1.25) + let q_ac = decodeChannel(3, 3, q_scale * 1.25) + let a_ac = hasAlpha ? decodeChannel(5, 5, a_scale) : [] + + // Decode using the DCT into RGB + let ratio = thumbHashToApproximateAspectRatio(hash: hash) + let fw = round(ratio > 1 ? 32 : 32 * ratio) + let fh = round(ratio > 1 ? 32 / ratio : 32) + let w = Int(fw) + let h = Int(fh) + var rgba = Data(count: w * h * 4) + let cx_stop = max(lx, hasAlpha ? 5 : 3) + let cy_stop = max(ly, hasAlpha ? 5 : 3) + var fx = [Float32](repeating: 0, count: cx_stop) + var fy = [Float32](repeating: 0, count: cy_stop) + fx.withUnsafeMutableBytes { fx in + let fx = fx.baseAddress!.bindMemory(to: Float32.self, capacity: fx.count) + fy.withUnsafeMutableBytes { fy in + let fy = fy.baseAddress!.bindMemory(to: Float32.self, capacity: fy.count) + rgba.withUnsafeMutableBytes { rgba in + var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count) + var y = 0 + while y < h { + var x = 0 + while x < w { + var l = l_dc + var p = p_dc + var q = q_dc + var a = a_dc + + // Precompute the coefficients + var cx = 0 + while cx < cx_stop { + let fw = Float32(w) + let fxx = Float32(x) + let fcx = Float32(cx) + fx[cx] = cos(Float32.pi / fw * (fxx + 0.5) * fcx) + cx += 1 + } + var cy = 0 + while cy < cy_stop { + let fh = Float32(h) + let fyy = Float32(y) + let fcy = Float32(cy) + fy[cy] = cos(Float32.pi / fh * (fyy + 0.5) * fcy) + cy += 1 + } + + // Decode L + var j = 0 + cy = 0 + while cy < ly { + var cx = cy > 0 ? 0 : 1 + let fy2 = fy[cy] * 2 + while cx * ly < lx * (ly - cy) { + l += l_ac[j] * fx[cx] * fy2 + j += 1 + cx += 1 + } + cy += 1 + } + + // Decode P and Q + j = 0 + cy = 0 + while cy < 3 { + var cx = cy > 0 ? 0 : 1 + let fy2 = fy[cy] * 2 + while cx < 3 - cy { + let f = fx[cx] * fy2 + p += p_ac[j] * f + q += q_ac[j] * f + j += 1 + cx += 1 + } + cy += 1 + } + + // Decode A + if hasAlpha { + j = 0 + cy = 0 + while cy < 5 { + var cx = cy > 0 ? 0 : 1 + let fy2 = fy[cy] * 2 + while cx < 5 - cy { + a += a_ac[j] * fx[cx] * fy2 + j += 1 + cx += 1 + } + cy += 1 + } + } + + // Convert to RGB + var b = l - 2 / 3 * p + var r = (3 * l - b + q) / 2 + var g = r - q + r = max(0, 255 * min(1, r)) + g = max(0, 255 * min(1, g)) + b = max(0, 255 * min(1, b)) + a = max(0, 255 * min(1, a)) + rgba[0] = UInt8(r) + rgba[1] = UInt8(g) + rgba[2] = UInt8(b) + rgba[3] = UInt8(a) + rgba = rgba.advanced(by: 4) + x += 1 + } + y += 1 + } + } + } + } + return (w, h, rgba) +} + +/// Extracts the average RGBA color from a ThumbHash without fully decoding it. +/// +/// - Parameter hash: ThumbHash data +/// - Returns: Tuple of (r, g, b, a) values in range 0-1 +func thumbHashToAverageRGBA(hash: Data) -> (Float32, Float32, Float32, Float32) { + let h0 = UInt32(hash[0]) + let h1 = UInt32(hash[1]) + let h2 = UInt32(hash[2]) + let header = h0 | (h1 << 8) | (h2 << 16) + let il = header & 63 + let ip = (header >> 6) & 63 + let iq = (header >> 12) & 63 + var l = Float32(il) + var p = Float32(ip) + var q = Float32(iq) + l = l / 63 + p = p / 31.5 - 1 + q = q / 31.5 - 1 + let hasAlpha = (header >> 23) != 0 + var a = Float32(1) + if hasAlpha { + let ia = hash[5] & 15 + a = Float32(ia) + a = a / 15 + } + let b = l - 2 / 3 * p + let r = (3 * l - b + q) / 2 + let g = r - q + return ( + max(0, min(1, r)), + max(0, min(1, g)), + max(0, min(1, b)), + a + ) +} + +/// Extracts the approximate aspect ratio from a ThumbHash without decoding it. +/// +/// - Parameter hash: ThumbHash data +/// - Returns: Aspect ratio (width / height) +func thumbHashToApproximateAspectRatio(hash: Data) -> Float32 { + let header = hash[3] + let hasAlpha = (hash[2] & 0x80) != 0 + let isLandscape = (hash[4] & 0x80) != 0 + let lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7 + let ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7 + return Float32(lx) / Float32(ly) +} + +// MARK: - iOS UIImage Extensions + +#if os(iOS) +import UIKit + +/// Encodes a UIImage into a ThumbHash. +/// +/// The image is first scaled down to max 100x100 while preserving aspect ratio, +/// then encoded using DCT (Discrete Cosine Transform) into a compact hash. +/// +/// - Parameter image: Source UIImage to encode +/// - Returns: ThumbHash data (~25 bytes, suitable for base64 encoding) +func imageToThumbHash(image: UIImage) -> Data { + let size = image.size + let w = Int(round(100 * size.width / max(size.width, size.height))) + let h = Int(round(100 * size.height / max(size.width, size.height))) + var rgba = Data(count: w * h * 4) + rgba.withUnsafeMutableBytes { rgba in + guard let space = image.cgImage?.colorSpace else { return } + guard let context = CGContext( + data: rgba.baseAddress, + width: w, + height: h, + bitsPerComponent: 8, + bytesPerRow: w * 4, + space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return } + + // EXIF orientation only works if you draw the UIImage, not the CGImage + context.concatenate(CGAffineTransform(1, 0, 0, -1, 0, CGFloat(h))) + UIGraphicsPushContext(context) + image.draw(in: CGRect(x: 0, y: 0, width: w, height: h)) + UIGraphicsPopContext() + + // Convert from premultiplied alpha to unpremultiplied alpha + var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count) + let n = w * h + var i = 0 + while i < n { + let a = UInt16(rgba[3]) + if a > 0 && a < 255 { + var r = UInt16(rgba[0]) + var g = UInt16(rgba[1]) + var b = UInt16(rgba[2]) + r = min(255, r * 255 / a) + g = min(255, g * 255 / a) + b = min(255, b * 255 / a) + rgba[0] = UInt8(r) + rgba[1] = UInt8(g) + rgba[2] = UInt8(b) + } + rgba = rgba.advanced(by: 4) + i += 1 + } + } + return rgbaToThumbHash(w: w, h: h, rgba: rgba) +} + +/// Decodes a ThumbHash into a UIImage placeholder. +/// +/// The resulting image is typically around 32x32 pixels (aspect ratio preserved) +/// and is suitable for display as a blurred placeholder while the full image loads. +/// +/// - Parameter hash: ThumbHash data +/// - Returns: Decoded UIImage placeholder +func thumbHashToImage(hash: Data) -> UIImage { + var (w, h, rgba) = thumbHashToRGBA(hash: hash) + rgba.withUnsafeMutableBytes { rgba in + // Convert from unpremultiplied alpha to premultiplied alpha + var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count) + let n = w * h + var i = 0 + while i < n { + let a = UInt16(rgba[3]) + if a < 255 { + var r = UInt16(rgba[0]) + var g = UInt16(rgba[1]) + var b = UInt16(rgba[2]) + r = min(255, r * a / 255) + g = min(255, g * a / 255) + b = min(255, b * a / 255) + rgba[0] = UInt8(r) + rgba[1] = UInt8(g) + rgba[2] = UInt8(b) + } + rgba = rgba.advanced(by: 4) + i += 1 + } + } + let image = CGImage( + width: w, + height: h, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: w * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo(rawValue: CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue), + provider: CGDataProvider(data: rgba as CFData)!, + decode: nil, + shouldInterpolate: true, + intent: .perceptual + ) + return UIImage(cgImage: image!) +} +#endif + +// MARK: - macOS NSImage Extensions + +#if os(macOS) +import Cocoa + +/// Encodes an NSImage into a ThumbHash. +/// +/// - Parameter image: Source NSImage to encode +/// - Returns: ThumbHash data (~25 bytes) +func imageToThumbHash(image: NSImage) -> Data { + let size = image.size + let fw = round(100 * size.width / max(size.width, size.height)) + let fh = round(100 * size.height / max(size.width, size.height)) + let w = Int(fw) + let h = Int(fh) + var rgba = Data(count: w * h * 4) + rgba.withUnsafeMutableBytes { rgba in + var rect = NSRect(x: 0, y: 0, width: w, height: h) + guard let cgImage = image.cgImage(forProposedRect: &rect, context: nil, hints: nil) else { return } + guard let space = (image.representations[0] as? NSBitmapImageRep)?.colorSpace.cgColorSpace else { return } + guard let context = CGContext( + data: rgba.baseAddress, + width: w, + height: h, + bitsPerComponent: 8, + bytesPerRow: w * 4, + space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return } + + context.draw(cgImage, in: rect) + + // Convert from premultiplied alpha to unpremultiplied alpha + var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count) + let n = w * h + var i = 0 + while i < n { + let a = UInt16(rgba[3]) + if a > 0 && a < 255 { + var r = UInt16(rgba[0]) + var g = UInt16(rgba[1]) + var b = UInt16(rgba[2]) + r = min(255, r * 255 / a) + g = min(255, g * 255 / a) + b = min(255, b * 255 / a) + rgba[0] = UInt8(r) + rgba[1] = UInt8(g) + rgba[2] = UInt8(b) + } + rgba = rgba.advanced(by: 4) + i += 1 + } + } + return rgbaToThumbHash(w: w, h: h, rgba: rgba) +} + +/// Decodes a ThumbHash into an NSImage placeholder. +/// +/// - Parameter hash: ThumbHash data +/// - Returns: Decoded NSImage placeholder +func thumbHashToImage(hash: Data) -> NSImage { + let (w, h, rgba) = thumbHashToRGBA(hash: hash) + let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: w, + pixelsHigh: h, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: w * 4, + bitsPerPixel: 32 + )! + rgba.withUnsafeBytes { rgba in + // Convert from unpremultiplied alpha to premultiplied alpha + var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count) + var to = bitmap.bitmapData! + let n = w * h + var i = 0 + while i < n { + let a = rgba[3] + if a == 255 { + to[0] = rgba[0] + to[1] = rgba[1] + to[2] = rgba[2] + } else { + var r = UInt16(rgba[0]) + var g = UInt16(rgba[1]) + var b = UInt16(rgba[2]) + let a = UInt16(a) + r = min(255, r * a / 255) + g = min(255, g * a / 255) + b = min(255, b * a / 255) + to[0] = UInt8(r) + to[1] = UInt8(g) + to[2] = UInt8(b) + } + to[3] = a + rgba = rgba.advanced(by: 4) + to = to.advanced(by: 4) + i += 1 + } + } + let image = NSImage(size: NSSize(width: w, height: h)) + image.addRepresentation(bitmap) + return image +} +#endif diff --git a/damusTests/ImageMetadataTest.swift b/damusTests/ImageMetadataTest.swift index f8898f2d9..23e3f090c 100644 --- a/damusTests/ImageMetadataTest.swift +++ b/damusTests/ImageMetadataTest.swift @@ -9,6 +9,134 @@ import XCTest @testable import damus final class ImageMetadataTest : XCTestCase { + + // MARK: - ThumbHash Tests + + /// Test that ThumbHash encode/decode roundtrip produces a valid image + func testThumbHashEncodeDecodeRoundtrip() { + // Create a simple test image (red square) + let size = CGSize(width: 64, height: 64) + UIGraphicsBeginImageContext(size) + UIColor.red.setFill() + UIRectFill(CGRect(origin: .zero, size: size)) + guard let testImage = UIGraphicsGetImageFromCurrentImageContext() else { + XCTFail("Failed to create test image") + return + } + UIGraphicsEndImageContext() + + // Encode to thumbhash + let hashData = imageToThumbHash(image: testImage) + XCTAssertFalse(hashData.isEmpty, "ThumbHash should not be empty") + XCTAssertLessThanOrEqual(hashData.count, 30, "ThumbHash should be compact (~25 bytes)") + + // Decode back to image + let decodedImage = thumbHashToImage(hash: hashData) + XCTAssertGreaterThan(decodedImage.size.width, 0, "Decoded image should have valid width") + XCTAssertGreaterThan(decodedImage.size.height, 0, "Decoded image should have valid height") + } + + /// Test that base64 encoding/decoding works for storage in imeta tags + func testThumbHashBase64Roundtrip() { + // Create test image + let size = CGSize(width: 32, height: 32) + UIGraphicsBeginImageContext(size) + UIColor.blue.setFill() + UIRectFill(CGRect(origin: .zero, size: size)) + guard let testImage = UIGraphicsGetImageFromCurrentImageContext() else { + XCTFail("Failed to create test image") + return + } + UIGraphicsEndImageContext() + + // Encode to thumbhash, then to base64 (as stored in Nostr events) + let hashData = imageToThumbHash(image: testImage) + let base64String = hashData.base64EncodedString() + + // Decode from base64 back to image + guard let decodedData = Data(base64Encoded: base64String) else { + XCTFail("Failed to decode base64 thumbhash") + return + } + let decodedImage = thumbHashToImage(hash: decodedData) + XCTAssertGreaterThan(decodedImage.size.width, 0) + } + + /// Test ImageMetadata parsing with thumbhash in imeta tag + func testImageMetadataWithThumbHash() { + let thumbhashValue = "1QcSHQRnh493V4dIh4eXh1h4kJUI" + let tag = [ + "imeta", + "url https://example.com/image.jpg", + "thumbhash \(thumbhashValue)", + "dim 800x600" + ] + + guard let meta = ImageMetadata(tag: tag) else { + XCTFail("Failed to parse ImageMetadata with thumbhash") + return + } + + XCTAssertEqual(meta.url.absoluteString, "https://example.com/image.jpg") + XCTAssertEqual(meta.thumbhash, thumbhashValue) + XCTAssertEqual(meta.dim?.width, 800) + XCTAssertEqual(meta.dim?.height, 600) + XCTAssertNil(meta.blurhash, "blurhash should be nil when not provided") + } + + /// Test ImageMetadata parsing with both thumbhash and blurhash (backwards compat) + func testImageMetadataWithBothHashes() { + let tag = [ + "imeta", + "url https://example.com/image.jpg", + "thumbhash 1QcSHQRnh493V4dIh4eXh1h4kJUI", + "blurhash LEHV6nWB2yk8pyo0adR*.7kCMdnj", + "dim 800x600" + ] + + guard let meta = ImageMetadata(tag: tag) else { + XCTFail("Failed to parse ImageMetadata") + return + } + + XCTAssertNotNil(meta.thumbhash, "thumbhash should be present") + XCTAssertNotNil(meta.blurhash, "blurhash should be present") + XCTAssertTrue(meta.hasPlaceholder, "hasPlaceholder should be true") + } + + /// Test that image_metadata_to_tag includes thumbhash + func testImageMetadataToTagWithThumbHash() { + let meta = ImageMetadata( + url: URL(string: "https://example.com/test.jpg")!, + blurhash: nil, + thumbhash: "1QcSHQRnh493V4dIh4eXh1h4kJUI", + dim: ImageMetaDim(width: 100, height: 100) + ) + + let tag = meta.to_tag() + + XCTAssertTrue(tag.contains("thumbhash 1QcSHQRnh493V4dIh4eXh1h4kJUI")) + XCTAssertEqual(tag[0], "imeta") + } + + /// Test that image_metadata_to_tag includes both hashes for compatibility + func testImageMetadataToTagWithBothHashes() { + let meta = ImageMetadata( + url: URL(string: "https://example.com/test.jpg")!, + blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + thumbhash: "1QcSHQRnh493V4dIh4eXh1h4kJUI", + dim: ImageMetaDim(width: 100, height: 100) + ) + + let tag = meta.to_tag() + + XCTAssertTrue(tag.contains("thumbhash 1QcSHQRnh493V4dIh4eXh1h4kJUI"), "Should include thumbhash") + XCTAssertTrue(tag.contains("blurhash LEHV6nWB2yk8pyo0adR*.7kCMdnj"), "Should include blurhash for compatibility") + XCTAssertEqual(tag[0], "imeta") + } + + // MARK: - GPS Data Tests + func testRemoveGPSData() { let bundle = Bundle(for: type(of: self)) guard let imageURL = bundle.url(forResource: "img_with_location", withExtension: "jpeg"),