damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit 3b50f8209447198c3a0459a62538ac5e2c627494
parent 46b53e1326d44697f2764f47dd9f6a2dea1b60bf
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 26 Apr 2023 10:41:05 -0700

Add image metadata to image uploads

Adds blurhash and image dimensions. This is an alternative and backwards
compatible version of NIP94 for images in kind1 notes.

Changelog-Added: Add image metadata to image uploads

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 37+++++++++++++++++++++++++++++++++++++
Mdamus/Models/Mentions.swift | 2+-
Mdamus/Models/Post.swift | 12++++++------
Adamus/Util/BlurHash/BlurHashDecode.swift | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Util/BlurHash/BlurHashEncode.swift | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Util/BlurHash/License.txt | 19+++++++++++++++++++
Adamus/Util/BlurHash/Readme.md | 45+++++++++++++++++++++++++++++++++++++++++++++
Adamus/Util/Images/ImageMetadata.swift | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Views/PostView.swift | 10++++++++--
9 files changed, 545 insertions(+), 9 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -39,6 +39,11 @@ 4C0A3F91280F6528000448DE /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0A3F90280F6528000448DE /* ChatView.swift */; }; 4C0A3F93280F66F5000448DE /* ReplyMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0A3F92280F66F5000448DE /* ReplyMap.swift */; }; 4C198DF829F89323004C165C /* BinaryParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C198DF729F89323004C165C /* BinaryParser.swift */; }; + 4C198DEF29F88C6B004C165C /* BlurHashEncode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C198DEB29F88C6B004C165C /* BlurHashEncode.swift */; }; + 4C198DF029F88C6B004C165C /* Readme.md in Resources */ = {isa = PBXBuildFile; fileRef = 4C198DEC29F88C6B004C165C /* Readme.md */; }; + 4C198DF129F88C6B004C165C /* License.txt in Resources */ = {isa = PBXBuildFile; fileRef = 4C198DED29F88C6B004C165C /* License.txt */; }; + 4C198DF229F88C6B004C165C /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C198DEE29F88C6B004C165C /* BlurHashDecode.swift */; }; + 4C198DF529F88D2E004C165C /* ImageMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C198DF429F88D2E004C165C /* ImageMetadata.swift */; }; 4C1A9A1A29DCA17E00516EAC /* ReplyCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A1929DCA17E00516EAC /* ReplyCounter.swift */; }; 4C1A9A1D29DDCF9B00516EAC /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A1C29DDCF9B00516EAC /* NotificationSettingsView.swift */; }; 4C1A9A1F29DDD24B00516EAC /* AppearanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A1E29DDD24B00516EAC /* AppearanceSettingsView.swift */; }; @@ -418,6 +423,11 @@ 4C0A3F90280F6528000448DE /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; }; 4C0A3F92280F66F5000448DE /* ReplyMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyMap.swift; sourceTree = "<group>"; }; 4C198DF729F89323004C165C /* BinaryParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryParser.swift; sourceTree = "<group>"; }; + 4C198DEB29F88C6B004C165C /* BlurHashEncode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashEncode.swift; sourceTree = "<group>"; }; + 4C198DEC29F88C6B004C165C /* Readme.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = Readme.md; sourceTree = "<group>"; }; + 4C198DED29F88C6B004C165C /* License.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = License.txt; sourceTree = "<group>"; }; + 4C198DEE29F88C6B004C165C /* BlurHashDecode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = "<group>"; }; + 4C198DF429F88D2E004C165C /* ImageMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageMetadata.swift; sourceTree = "<group>"; }; 4C1A9A1929DCA17E00516EAC /* ReplyCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyCounter.swift; sourceTree = "<group>"; }; 4C1A9A1C29DDCF9B00516EAC /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = "<group>"; }; 4C1A9A1E29DDD24B00516EAC /* AppearanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsView.swift; sourceTree = "<group>"; }; @@ -865,6 +875,25 @@ path = Parser; sourceTree = "<group>"; }; + 4C198DEA29F88C6B004C165C /* BlurHash */ = { + isa = PBXGroup; + children = ( + 4C198DEB29F88C6B004C165C /* BlurHashEncode.swift */, + 4C198DEC29F88C6B004C165C /* Readme.md */, + 4C198DED29F88C6B004C165C /* License.txt */, + 4C198DEE29F88C6B004C165C /* BlurHashDecode.swift */, + ); + path = BlurHash; + sourceTree = "<group>"; + }; + 4C198DF329F88D23004C165C /* Images */ = { + isa = PBXGroup; + children = ( + 4C198DF429F88D2E004C165C /* ImageMetadata.swift */, + ); + path = Images; + sourceTree = "<group>"; + }; 4C1A9A1B29DDCF8B00516EAC /* Settings */ = { isa = PBXGroup; children = ( @@ -988,6 +1017,9 @@ 4C7FF7D628233637009601DB /* Util */ = { isa = PBXGroup; children = ( + 4C198DF629F89317004C165C /* Parser */, + 4C198DF329F88D23004C165C /* Images */, + 4C198DEA29F88C6B004C165C /* BlurHash */, 4CE4F0F329D779B5005914DB /* PostBox.swift */, 7C0F392D29B57C8F0039859C /* Extensions */, 4CE879492995B58700F758CC /* Relays */, @@ -1485,6 +1517,8 @@ 4CE6DEEE27F7A08200C66700 /* Preview Assets.xcassets in Resources */, 3ACB685C297633BC00C46468 /* InfoPlist.strings in Resources */, 4CE6DEEB27F7A08200C66700 /* Assets.xcassets in Resources */, + 4C198DF129F88C6B004C165C /* License.txt in Resources */, + 4C198DF029F88C6B004C165C /* Readme.md in Resources */, 3A4325A82961E11400BFCD9D /* Localizable.stringsdict in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1549,6 +1583,7 @@ 4C75EFB92804A2740006080F /* EventView.swift in Sources */, 4C8D00C829DF791C0036AF10 /* CompatibleAttribute.swift in Sources */, 3AA247FD297E3CFF0090C62D /* RepostsModel.swift in Sources */, + 4C198DF229F88C6B004C165C /* BlurHashDecode.swift in Sources */, F75BA12F29A18EF500E10810 /* BookmarksView.swift in Sources */, 4CB883B6297730E400DC99E7 /* LNUrls.swift in Sources */, 4C7FF7D52823313F009601DB /* Mentions.swift in Sources */, @@ -1611,6 +1646,7 @@ 4CE8794829941DA700F758CC /* RelayFilters.swift in Sources */, 4CEE2B02280B39E800AB5EEF /* EventActionBar.swift in Sources */, 4C3BEFE0281DE1ED00B3DE84 /* DamusState.swift in Sources */, + 4C198DF529F88D2E004C165C /* ImageMetadata.swift in Sources */, 4CCEB7AE29B53D260078AA28 /* SearchingEventView.swift in Sources */, 4CF0ABE929844AF100D66079 /* AnyCodable.swift in Sources */, 4C0A3F8F280F640A000448DE /* ThreadModel.swift in Sources */, @@ -1664,6 +1700,7 @@ 3AA24802297E3DC20090C62D /* RepostView.swift in Sources */, 4CD7641B28A1641400B6928F /* EndBlock.swift in Sources */, 4C3EA66528FF5F6800C48A62 /* mem.c in Sources */, + 4C198DEF29F88C6B004C165C /* BlurHashEncode.swift in Sources */, 4CF0ABE52981EE0C00D66079 /* EULAView.swift in Sources */, 4CBCA930297DB57F00EC6B2F /* WebsiteLink.swift in Sources */, 4CAAD8B029888AD200060CEA /* RelayConfigView.swift in Sources */, diff --git a/damus/Models/Mentions.swift b/damus/Models/Mentions.swift @@ -682,7 +682,7 @@ func make_post_tags(post_blocks: [PostBlock], tags: [[String]], silent_mentions: } func post_to_event(post: NostrPost, privkey: String, pubkey: String) -> NostrEvent { - let tags = post.references.map(refid_to_tag) + let tags = post.references.map(refid_to_tag) + post.tags let post_blocks = parse_post_blocks(content: post.content) let post_tags = make_post_tags(post_blocks: post_blocks, tags: tags, silent_mentions: false) let content = render_blocks(blocks: post_tags.blocks) diff --git a/damus/Models/Post.swift b/damus/Models/Post.swift @@ -11,17 +11,17 @@ struct NostrPost { let kind: NostrKind let content: String let references: [ReferencedId] + let tags: [[String]] - init (content: String, references: [ReferencedId]) { + init (content: String, references: [ReferencedId], kind: NostrKind = .text, tags: [[String]] = []) { self.content = content self.references = references - self.kind = .text + self.kind = kind + self.tags = tags } - init (content: String, references: [ReferencedId], kind: NostrKind) { - self.content = content - self.references = references - self.kind = kind + func to_event(keypair: FullKeypair) -> NostrEvent { + return post_to_event(post: self, privkey: keypair.privkey, pubkey: keypair.pubkey) } } diff --git a/damus/Util/BlurHash/BlurHashDecode.swift b/damus/Util/BlurHash/BlurHashDecode.swift @@ -0,0 +1,146 @@ +import UIKit + +extension UIImage { + public convenience init?(blurHash: String, size: CGSize, punch: Float = 1) { + guard blurHash.count >= 6 else { return nil } + + let sizeFlag = String(blurHash[0]).decode83() + let numY = (sizeFlag / 9) + 1 + let numX = (sizeFlag % 9) + 1 + + let quantisedMaximumValue = String(blurHash[1]).decode83() + let maximumValue = Float(quantisedMaximumValue + 1) / 166 + + guard blurHash.count == 4 + 2 * numX * numY else { return nil } + + let colours: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in + if i == 0 { + let value = String(blurHash[2 ..< 6]).decode83() + return decodeDC(value) + } else { + let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83() + return decodeAC(value, maximumValue: maximumValue * punch) + } + } + + let width = Int(size.width) + let height = Int(size.height) + let bytesPerRow = width * 3 + guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil } + CFDataSetLength(data, bytesPerRow * height) + guard let pixels = CFDataGetMutableBytePtr(data) else { return nil } + + for y in 0 ..< height { + for x in 0 ..< width { + var r: Float = 0 + var g: Float = 0 + var b: Float = 0 + + for j in 0 ..< numY { + for i in 0 ..< numX { + let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height)) + let colour = colours[i + j * numX] + r += colour.0 * basis + g += colour.1 * basis + b += colour.2 * basis + } + } + + let intR = UInt8(linearTosRGB(r)) + let intG = UInt8(linearTosRGB(g)) + let intB = UInt8(linearTosRGB(b)) + + pixels[3 * x + 0 + y * bytesPerRow] = intR + pixels[3 * x + 1 + y * bytesPerRow] = intG + pixels[3 * x + 2 + y * bytesPerRow] = intB + } + } + + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue) + + guard let provider = CGDataProvider(data: data) else { return nil } + guard let cgImage = CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 24, bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: bitmapInfo, provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent) else { return nil } + + self.init(cgImage: cgImage) + } +} + +private func decodeDC(_ value: Int) -> (Float, Float, Float) { + let intR = value >> 16 + let intG = (value >> 8) & 255 + let intB = value & 255 + return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB)) +} + +private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) { + let quantR = value / (19 * 19) + let quantG = (value / 19) % 19 + let quantB = value % 19 + + let rgb = ( + signPow((Float(quantR) - 9) / 9, 2) * maximumValue, + signPow((Float(quantG) - 9) / 9, 2) * maximumValue, + signPow((Float(quantB) - 9) / 9, 2) * maximumValue + ) + + return rgb +} + +private func signPow(_ value: Float, _ exp: Float) -> Float { + return copysign(pow(abs(value), exp), value) +} + +private func linearTosRGB(_ value: Float) -> Int { + let v = max(0, min(1, value)) + if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) } + else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) } +} + +private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float { + let v = Float(Int64(value)) / 255 + if v <= 0.04045 { return v / 12.92 } + else { return pow((v + 0.055) / 1.055, 2.4) } +} + +private let encodeCharacters: [String] = { + return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) } +}() + +private let decodeCharacters: [String: Int] = { + var dict: [String: Int] = [:] + for (index, character) in encodeCharacters.enumerated() { + dict[character] = index + } + return dict +}() + +extension String { + func decode83() -> Int { + var value: Int = 0 + for character in self { + if let digit = decodeCharacters[String(character)] { + value = value * 83 + digit + } + } + return value + } +} + +private extension String { + subscript (offset: Int) -> Character { + return self[index(startIndex, offsetBy: offset)] + } + + subscript (bounds: CountableClosedRange<Int>) -> Substring { + let start = index(startIndex, offsetBy: bounds.lowerBound) + let end = index(startIndex, offsetBy: bounds.upperBound) + return self[start...end] + } + + subscript (bounds: CountableRange<Int>) -> Substring { + let start = index(startIndex, offsetBy: bounds.lowerBound) + let end = index(startIndex, offsetBy: bounds.upperBound) + return self[start..<end] + } +} diff --git a/damus/Util/BlurHash/BlurHashEncode.swift b/damus/Util/BlurHash/BlurHashEncode.swift @@ -0,0 +1,145 @@ +import UIKit + +extension UIImage { + public func blurHash(numberOfComponents components: (Int, Int)) -> String? { + let pixelWidth = Int(round(size.width * scale)) + let pixelHeight = Int(round(size.height * scale)) + + let context = CGContext( + data: nil, + width: pixelWidth, + height: pixelHeight, + bitsPerComponent: 8, + bytesPerRow: pixelWidth * 4, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + context.scaleBy(x: scale, y: -scale) + context.translateBy(x: 0, y: -size.height) + + UIGraphicsPushContext(context) + draw(at: .zero) + UIGraphicsPopContext() + + guard let cgImage = context.makeImage(), + let dataProvider = cgImage.dataProvider, + let data = dataProvider.data, + let pixels = CFDataGetBytePtr(data) else { + assertionFailure("Unexpected error!") + return nil + } + + let width = cgImage.width + let height = cgImage.height + let bytesPerRow = cgImage.bytesPerRow + + var factors: [(Float, Float, Float)] = [] + for y in 0 ..< components.1 { + for x in 0 ..< components.0 { + let normalisation: Float = (x == 0 && y == 0) ? 1 : 2 + let factor = multiplyBasisFunction(pixels: pixels, width: width, height: height, bytesPerRow: bytesPerRow, bytesPerPixel: cgImage.bitsPerPixel / 8, pixelOffset: 0) { + normalisation * cos(Float.pi * Float(x) * $0 / Float(width)) as Float * cos(Float.pi * Float(y) * $1 / Float(height)) as Float + } + factors.append(factor) + } + } + + let dc = factors.first! + let ac = factors.dropFirst() + + var hash = "" + + let sizeFlag = (components.0 - 1) + (components.1 - 1) * 9 + hash += sizeFlag.encode83(length: 1) + + let maximumValue: Float + if ac.count > 0 { + let actualMaximumValue = ac.map({ max(abs($0.0), abs($0.1), abs($0.2)) }).max()! + let quantisedMaximumValue = Int(max(0, min(82, floor(actualMaximumValue * 166 - 0.5)))) + maximumValue = Float(quantisedMaximumValue + 1) / 166 + hash += quantisedMaximumValue.encode83(length: 1) + } else { + maximumValue = 1 + hash += 0.encode83(length: 1) + } + + hash += encodeDC(dc).encode83(length: 4) + + for factor in ac { + hash += encodeAC(factor, maximumValue: maximumValue).encode83(length: 2) + } + + return hash + } + + private func multiplyBasisFunction(pixels: UnsafePointer<UInt8>, width: Int, height: Int, bytesPerRow: Int, bytesPerPixel: Int, pixelOffset: Int, basisFunction: (Float, Float) -> Float) -> (Float, Float, Float) { + var r: Float = 0 + var g: Float = 0 + var b: Float = 0 + + let buffer = UnsafeBufferPointer(start: pixels, count: height * bytesPerRow) + + for x in 0 ..< width { + for y in 0 ..< height { + let basis = basisFunction(Float(x), Float(y)) + r += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 0 + y * bytesPerRow]) + g += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 1 + y * bytesPerRow]) + b += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 2 + y * bytesPerRow]) + } + } + + let scale = 1 / Float(width * height) + + return (r * scale, g * scale, b * scale) + } +} + +private func encodeDC(_ value: (Float, Float, Float)) -> Int { + let roundedR = linearTosRGB(value.0) + let roundedG = linearTosRGB(value.1) + let roundedB = linearTosRGB(value.2) + return (roundedR << 16) + (roundedG << 8) + roundedB +} + +private func encodeAC(_ value: (Float, Float, Float), maximumValue: Float) -> Int { + let quantR = Int(max(0, min(18, floor(signPow(value.0 / maximumValue, 0.5) * 9 + 9.5)))) + let quantG = Int(max(0, min(18, floor(signPow(value.1 / maximumValue, 0.5) * 9 + 9.5)))) + let quantB = Int(max(0, min(18, floor(signPow(value.2 / maximumValue, 0.5) * 9 + 9.5)))) + + return quantR * 19 * 19 + quantG * 19 + quantB +} + +private func signPow(_ value: Float, _ exp: Float) -> Float { + return copysign(pow(abs(value), exp), value) +} + +private func linearTosRGB(_ value: Float) -> Int { + let v = max(0, min(1, value)) + if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) } + else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) } +} + +private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float { + let v = Float(Int64(value)) / 255 + if v <= 0.04045 { return v / 12.92 } + else { return pow((v + 0.055) / 1.055, 2.4) } +} + +private let encodeCharacters: [String] = { + return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) } +}() + +extension BinaryInteger { + func encode83(length: Int) -> String { + var result = "" + for i in 1 ... length { + let digit = (Int(self) / pow(83, length - i)) % 83 + result += encodeCharacters[Int(digit)] + } + return result + } +} + +private func pow(_ base: Int, _ exponent: Int) -> Int { + return (0 ..< exponent).reduce(1) { value, _ in value * base } +} diff --git a/damus/Util/BlurHash/License.txt b/damus/Util/BlurHash/License.txt @@ -0,0 +1,19 @@ +Copyright (c) 2018 Wolt Enterprises + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/damus/Util/BlurHash/Readme.md b/damus/Util/BlurHash/Readme.md @@ -0,0 +1,45 @@ +# BlurHash for iOS, in Swift + +## Standalone decoder and encoder + +[BlurHashDecode.swift](BlurHashDecode.swift) and [BlurHashEncode.swift](BlurHashEncode.swift) contain a decoder +and encoder for BlurHash to and from `UIImage`. Both files are completeiy standalone, and can simply be copied into your +project directly. + +### Decoding + +[BlurHashDecode.swift](BlurHashDecode.swift) implements the following extension on `UIImage`: + + public convenience init?(blurHash: String, size: CGSize, punch: Float = 1) + +This creates a UIImage containing the placeholder image decoded from the BlurHash string, or returns nil if decoding failed. +The parameters are: + +* `blurHash` - A string containing the BlurHash. +* `size` - The requested output size. You should keep this small, and let UIKit scale it up for you. 32 pixels wide is plenty. +* `punch` - Adjusts the contrast of the output image. Tweak it if you want a different look for your placeholders. + +### Encoding + + [BlurHashEncode.swift](BlurHashEncode.swift) implements the following extension on `UIImage`: + + public func blurHash(numberOfComponents components: (Int, Int)) -> String? + +This returns a string containing the BlurHash for the image, or nil if the image was in a weird format that is not supported. +The parameters are: + +* `numberOfComponents` - a Tuple of integers specifying the number of components in the X and Y directions. Both must be +between 1 and 9 inclusive, or the function will return nil. 3 to 5 is usually a good range. + +## BlurHashKit + +This is a more advanced library, currently in development. It will let you do more advanced operations using BlurHashes, +such testing whether various parts of an image are dark and light, or generating BlurHashes as gradients from corner colours. + +It is currently not documented or finalised, but feel free to look into the different files and what they implement, or look at +how it is used by the test app. + +## BlurHashTest.app + +This is a simple test app that shows how to use the various pieces of BlurHash functionality, and lets you play with the +algorithm. diff --git a/damus/Util/Images/ImageMetadata.swift b/damus/Util/Images/ImageMetadata.swift @@ -0,0 +1,138 @@ +// +// ImageMetadata.swift +// damus +// +// Created by William Casarin on 2023-04-25. +// + +import Foundation +import UIKit + +struct ImageMetaDim: Equatable, StringCodable { + init(width: Int, height: Int) { + self.width = width + self.height = height + } + + init?(from string: String) { + guard let dim = parse_image_meta_dim(string) else { + return nil + } + self = dim + } + + func to_string() -> String { + "\(width)x\(height)" + } + + let width: Int + let height: Int + + +} + +struct ImageMetadata: Equatable { + let url: URL + let blurhash: String + let dim: ImageMetaDim + + init(url: URL, blurhash: String, dim: ImageMetaDim) { + self.url = url + self.blurhash = blurhash + 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) + } +} + +func image_metadata_to_tag(_ meta: ImageMetadata) -> [String] { + return ["imeta", "url \(meta.url.absoluteString)", "blurhash \(meta.blurhash)", "dim \(meta.dim.to_string())"] +} + +func decode_image_metadata(_ parts: [String]) -> ImageMetadata? { + var url: URL? = nil + var blurhash: String? = nil + var dim: ImageMetaDim? = nil + + for part in parts { + let ps = part.split(separator: " ") + guard ps.count == 2 else { + return nil + } + let pname = ps[0] + let pval = ps[1] + + if pname == "blurhash" { + blurhash = String(pval) + } else if pname == "dim" { + dim = parse_image_meta_dim(String(pval)) + } else if pname == "url" { + url = URL(string: String(pval)) + } + } + + guard let blurhash, let dim, let url else { + return nil + } + + return ImageMetadata(url: url, blurhash: blurhash, dim: dim) +} + +func parse_image_meta_dim(_ pval: String) -> ImageMetaDim? { + let parts = pval.split(separator: "x") + guard parts.count == 2, + let width = Int(parts[0]), + let height = Int(parts[1]) else { + return nil + } + + return ImageMetaDim(width: width, height: height) +} + +extension UIImage { + func resized(to size: CGSize) -> UIImage { + return UIGraphicsImageRenderer(size: size).image { _ in + draw(in: CGRect(origin: .zero, size: size)) + } + } +} + +func calculate_blurhash(img: UIImage) async -> String? { + guard img.size.height > 0 else { + return nil + } + + let res = Task.init { + let sw: Double = 100 + let sh: Double = (100.0/img.size.width) * img.size.height + + let smaller = img.resized(to: CGSize(width: sw, height: sh)) + + guard let blurhash = smaller.blurHash(numberOfComponents: (5,5)) else { + let meta: String? = nil + return meta + } + + return blurhash + } + + return await res.value +} + +func calculate_image_metadata(url: URL, img: UIImage, blurhash: String) -> ImageMetadata { + let width = Int(round(img.size.width * img.scale)) + let height = Int(round(img.size.height * img.scale)) + let dim = ImageMetaDim(width: width, height: height) + + return ImageMetadata(url: url, blurhash: blurhash, dim: dim) +} diff --git a/damus/Views/PostView.swift b/damus/Views/PostView.swift @@ -82,6 +82,8 @@ struct PostView: View { var content = self.post.string.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) let imagesString = uploadedMedias.map { $0.uploadedURL.absoluteString }.joined(separator: " ") + + let img_meta_tags = uploadedMedias.compactMap { $0.metadata?.to_tag() } content.append(" " + imagesString + " ") @@ -89,7 +91,7 @@ struct PostView: View { content.append(" nostr:" + id) } - let new_post = NostrPost(content: content, references: references, kind: kind) + let new_post = NostrPost(content: content, references: references, kind: kind, tags: img_meta_tags) NotificationCenter.default.post(name: .post, object: NostrPostResult.post(new_post)) @@ -249,6 +251,7 @@ struct PostView: View { let uploader = damus_state.settings.default_media_uploader Task.init { let img = getImage(media: media) + async let blurhash = calculate_blurhash(img: img) let res = await image_upload.start(media: media, uploader: uploader) switch res { @@ -257,7 +260,9 @@ struct PostView: View { self.error = "Error uploading image :(" return } - let uploadedMedia = UploadedMedia(localURL: media.localURL, uploadedURL: url, representingImage: img) + let blurhash = await blurhash + let meta = blurhash.map { bh in calculate_image_metadata(url: url, img: img, blurhash: bh) } + let uploadedMedia = UploadedMedia(localURL: media.localURL, uploadedURL: url, representingImage: img, metadata: meta) uploadedMedias.append(uploadedMedia) case .failed(let error): @@ -504,6 +509,7 @@ struct UploadedMedia: Equatable { let localURL: URL let uploadedURL: URL let representingImage: UIImage + let metadata: ImageMetadata? }