From 9534f19262b43c0db7d5699aaa217f4abf97e292 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 12 Sep 2020 10:48:27 -0400 Subject: [PATCH] Show BlurHash previews of attachments --- Pachyderm/Model/Attachment.swift | 7 +- Tusker.xcodeproj/project.pbxproj | 12 ++ .../GalleryViewController.swift | 1 + .../LargeImageViewController.swift | 27 ++-- .../LoadingLargeImageViewController.swift | 40 +++-- Tusker/Vendor/BlurHashDecode.swift | 148 ++++++++++++++++++ Tusker/Views/Attachments/AttachmentView.swift | 29 +++- .../AttachmentsContainerView.swift | 70 +++++---- 8 files changed, 286 insertions(+), 48 deletions(-) create mode 100644 Tusker/Vendor/BlurHashDecode.swift diff --git a/Pachyderm/Model/Attachment.swift b/Pachyderm/Model/Attachment.swift index cc376dff..61964575 100644 --- a/Pachyderm/Model/Attachment.swift +++ b/Pachyderm/Model/Attachment.swift @@ -17,6 +17,7 @@ public class Attachment: Codable { public let textURL: URL? public let meta: Metadata? public let description: String? + public let blurHash: String? public static func update(_ attachment: Attachment, focus: (Float, Float)?, description: String?) -> Request { return Request(method: .put, path: "/api/v1/media/\(attachment.id)", body: .formData([ @@ -35,6 +36,7 @@ public class Attachment: Codable { self.textURL = try? container.decode(URL?.self, forKey: .textURL) self.meta = try? container.decode(Metadata?.self, forKey: .meta) self.description = try? container.decode(String?.self, forKey: .description) + self.blurHash = try? container.decode(String?.self, forKey: .blurHash) } private enum CodingKeys: String, CodingKey { @@ -46,6 +48,7 @@ public class Attachment: Codable { case textURL = "text_url" case meta case description + case blurHash = "blurhash" } } @@ -60,7 +63,7 @@ extension Attachment { } extension Attachment { - public class Metadata: Codable { + public struct Metadata: Codable { public let length: String? public let duration: Float? public let audioEncoding: String? @@ -91,7 +94,7 @@ extension Attachment { } } - public class ImageMetadata: Codable { + public struct ImageMetadata: Codable { public let width: Int? public let height: Int? public let size: String? diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 15211380..076dad3e 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -169,6 +169,7 @@ D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */; }; D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */; }; D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D679C09E215850EF00DA27FE /* XCBActions.swift */; }; + D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67B506C250B291200FAECFB /* BlurHashDecode.swift */; }; D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */; }; D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */; }; D67C57B221E28FAD00C3118B /* ComposeStatusReplyView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D67C57B121E28FAD00C3118B /* ComposeStatusReplyView.xib */; }; @@ -494,6 +495,7 @@ D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKDrawing+Render.swift"; sourceTree = ""; }; D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAccountAvatarView.swift; sourceTree = ""; }; D679C09E215850EF00DA27FE /* XCBActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBActions.swift; sourceTree = ""; }; + D67B506C250B291200FAECFB /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = ""; }; D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeAccountDetailView.swift; sourceTree = ""; }; D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Uniques.swift"; sourceTree = ""; }; D67C57B121E28FAD00C3118B /* ComposeStatusReplyView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ComposeStatusReplyView.xib; sourceTree = ""; }; @@ -1119,6 +1121,14 @@ path = XCallbackURL; sourceTree = ""; }; + D67B506B250B28FF00FAECFB /* Vendor */ = { + isa = PBXGroup; + children = ( + D67B506C250B291200FAECFB /* BlurHashDecode.swift */, + ); + path = Vendor; + sourceTree = ""; + }; D67C57A721E2649B00C3118B /* Account Detail */ = { isa = PBXGroup; children = ( @@ -1337,6 +1347,7 @@ D64D8CA82463B494006B0BAA /* CachedDictionary.swift */, D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */, D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */, + D67B506B250B28FF00FAECFB /* Vendor */, D6F1F84E2193B9BE00F5FE67 /* Caching */, D6757A7A2157E00100721E32 /* XCallbackURL */, D62D241E217AA46B005076CC /* Shortcuts */, @@ -1800,6 +1811,7 @@ D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */, D67C57B421E2910700C3118B /* ComposeStatusReplyView.swift in Sources */, D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */, + D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */, D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */, 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */, D627FF7B217E951500CC0648 /* DraftsTableViewController.swift in Sources */, diff --git a/Tusker/Screens/Attachment Gallery/GalleryViewController.swift b/Tusker/Screens/Attachment Gallery/GalleryViewController.swift index 76c34bb5..4cdc80b9 100644 --- a/Tusker/Screens/Attachment Gallery/GalleryViewController.swift +++ b/Tusker/Screens/Attachment Gallery/GalleryViewController.swift @@ -77,6 +77,7 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc case .image: let vc = LoadingLargeImageViewController(attachment: attachment) vc.shrinkGestureEnabled = false + vc.animationSourceView = sourceViews[index] return vc case .video, .audio: let vc = GalleryPlayerViewController() diff --git a/Tusker/Screens/Large Image/LargeImageViewController.swift b/Tusker/Screens/Large Image/LargeImageViewController.swift index 6852762e..1f5f0150 100644 --- a/Tusker/Screens/Large Image/LargeImageViewController.swift +++ b/Tusker/Screens/Large Image/LargeImageViewController.swift @@ -31,7 +31,12 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma @IBOutlet weak var bottomControlsView: UIView! @IBOutlet weak var descriptionLabel: UILabel! - var contentView: ContentView + var contentView: ContentView { + didSet { + oldValue.removeFromSuperview() + setupContentView() + } + } var contentViewLeadingConstraint: NSLayoutConstraint! var contentViewTopConstraint: NSLayoutConstraint! @@ -76,14 +81,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma override func viewDidLoad() { super.viewDidLoad() - contentView.translatesAutoresizingMaskIntoConstraints = false - scrollView.addSubview(contentView) - contentViewLeadingConstraint = contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor) - contentViewTopConstraint = contentView.topAnchor.constraint(equalTo: scrollView.topAnchor) - NSLayoutConstraint.activate([ - contentViewLeadingConstraint, - contentViewTopConstraint, - ]) + setupContentView() setControlsVisible(initialControlsVisible, animated: false) shareButton.isEnabled = !contentView.activityItemsForSharing.isEmpty @@ -106,6 +104,17 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma doubleTap.numberOfTapsRequired = 2 view.addGestureRecognizer(doubleTap) } + + private func setupContentView() { + contentView.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(contentView) + contentViewLeadingConstraint = contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor) + contentViewTopConstraint = contentView.topAnchor.constraint(equalTo: scrollView.topAnchor) + NSLayoutConstraint.activate([ + contentViewLeadingConstraint, + contentViewTopConstraint, + ]) + } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() diff --git a/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift b/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift index cf2d9af8..878fc624 100644 --- a/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift +++ b/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift @@ -10,14 +10,16 @@ import Pachyderm class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableViewController { + private var attachment: Attachment? let url: URL let cache: ImageCache let imageDescription: String? - var largeImageVC: LargeImageViewController? - var loadingVC: LoadingViewController? + private(set) var loaded = false + private(set) var largeImageVC: LargeImageViewController? + private var loadingVC: LoadingViewController? - var imageRequest: ImageCache.Request? + private var imageRequest: ImageCache.Request? private var initialControlsVisible: Bool = true var controlsVisible: Bool { @@ -70,6 +72,7 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie convenience init(attachment: Attachment) { self.init(url: attachment.url, cache: .attachments, imageDescription: attachment.description) + self.attachment = attachment } required init?(coder: NSCoder) { @@ -85,6 +88,8 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie if let data = cache.get(url) { createLargeImage(data: data) } else { + createPreview() + loadingVC = LoadingViewController() embedChild(loadingVC!) imageRequest = cache.get(url) { [weak self] (data) in @@ -110,15 +115,32 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie } } - func createLargeImage(data: Data) { + private func createLargeImage(data: Data) { + guard !loaded else { return } + loaded = true guard let image = UIImage(data: data) else { return } let gifData = url.pathExtension == "gif" ? data : nil + createLargeImage(image: image, gifData: gifData) + } + + private func createLargeImage(image: UIImage, gifData: Data?) { let imageView = LargeImageImageContentView(image: image, gifData: gifData) - - largeImageVC = LargeImageViewController(contentView: imageView, description: imageDescription, sourceView: animationSourceView) - largeImageVC!.initialControlsVisible = initialControlsVisible - largeImageVC!.shrinkGestureEnabled = false - embedChild(largeImageVC!) + + if let existing = largeImageVC { + existing.contentView = imageView + } else { + largeImageVC = LargeImageViewController(contentView: imageView, description: imageDescription, sourceView: animationSourceView) + largeImageVC!.initialControlsVisible = initialControlsVisible + largeImageVC!.shrinkGestureEnabled = false + embedChild(largeImageVC!) + } + } + + private func createPreview() { + guard !self.loaded, + let image = animationSourceView?.image else { return } + + self.createLargeImage(image: image, gifData: nil) } } diff --git a/Tusker/Vendor/BlurHashDecode.swift b/Tusker/Vendor/BlurHashDecode.swift new file mode 100644 index 00000000..9e9f6a6e --- /dev/null +++ b/Tusker/Vendor/BlurHashDecode.swift @@ -0,0 +1,148 @@ +/// BlurHash reference decoder implementation. +/// From https://github.com/woltapp/blurhash/blob/b23214ddcab803fe1ec9a3e6b20558caf33a23a5/Swift/BlurHashDecode.swift +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(_ 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 +}() + +private 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) -> Substring { + let start = index(startIndex, offsetBy: bounds.lowerBound) + let end = index(startIndex, offsetBy: bounds.upperBound) + return self[start...end] + } + + subscript (bounds: CountableRange) -> Substring { + let start = index(startIndex, offsetBy: bounds.lowerBound) + let end = index(startIndex, offsetBy: bounds.upperBound) + return self[start.. AttachmentView { - let attachmentView = AttachmentView(attachment: attachments[index], index: index) + private func createAttachmentView(index: Int, hSize: RelativeSize, vSize: RelativeSize) -> AttachmentView { + let width: CGFloat + switch hSize { + case .full: + width = bounds.width + case .half: + width = (bounds.width - 4) / 2 + } + let height: CGFloat + switch vSize { + case .full: + height = bounds.height + case .half: + height = (bounds.height - 4) / 2 + } + let size = CGSize(width: width, height: height) + + let attachmentView = AttachmentView(attachment: attachments[index], index: index, expectedSize: size) attachmentView.delegate = delegate attachmentView.translatesAutoresizingMaskIntoConstraints = false attachmentView.isAccessibilityElement = true @@ -376,20 +392,20 @@ class AttachmentsContainerView: UIView { } -fileprivate extension UIView { - enum RelativeSize { - case full, half - - var multiplier: CGFloat { - switch self { - case .full: - return 1 - case .half: - return 0.5 - } +fileprivate enum RelativeSize { + case full, half + + var multiplier: CGFloat { + switch self { + case .full: + return 1 + case .half: + return 0.5 } } - +} + +fileprivate extension UIView { func halfWidth(spacing: CGFloat = 4) -> NSLayoutConstraint { return widthAnchor.constraint(equalTo: superview!.widthAnchor, multiplier: 0.5, constant: -spacing / 2) }