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:
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?
}