Add blurhash.

This commit is contained in:
Marcin Czachursk 2023-01-09 06:47:34 +01:00
parent 44ab440335
commit 769b044e39
4 changed files with 218 additions and 14 deletions

View File

@ -72,6 +72,7 @@
F897978A2968314A00B22335 /* LoadingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89797892968314A00B22335 /* LoadingIndicator.swift */; };
F897978D2968369600B22335 /* HapticService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F897978C2968369600B22335 /* HapticService.swift */; };
F897978F29684BCB00B22335 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F897978E29684BCB00B22335 /* LoadingView.swift */; };
F8984E4D296B648000A2610F /* UIImage+Blurhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8984E4C296B648000A2610F /* UIImage+Blurhash.swift */; };
F8A93D7E2965FD89001D8331 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A93D7D2965FD89001D8331 /* UserProfileView.swift */; };
F8A93D802965FED4001D8331 /* AccountService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A93D7F2965FED4001D8331 /* AccountService.swift */; };
F8A93D822965FF5D001D8331 /* MastodonClientAuthenticated+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A93D812965FF5D001D8331 /* MastodonClientAuthenticated+Account.swift */; };
@ -146,6 +147,7 @@
F89797892968314A00B22335 /* LoadingIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingIndicator.swift; sourceTree = "<group>"; };
F897978C2968369600B22335 /* HapticService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticService.swift; sourceTree = "<group>"; };
F897978E29684BCB00B22335 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = "<group>"; };
F8984E4C296B648000A2610F /* UIImage+Blurhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Blurhash.swift"; sourceTree = "<group>"; };
F8A93D7D2965FD89001D8331 /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = "<group>"; };
F8A93D7F2965FED4001D8331 /* AccountService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountService.swift; sourceTree = "<group>"; };
F8A93D812965FF5D001D8331 /* MastodonClientAuthenticated+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonClientAuthenticated+Account.swift"; sourceTree = "<group>"; };
@ -209,6 +211,7 @@
F8210DE42966E160001D9973 /* Color+SystemColors.swift */,
F8210DE62966E1D1001D9973 /* Color+Assets.swift */,
F8C14393296AF21B001FE31D /* Double+Round.swift */,
F8984E4C296B648000A2610F /* UIImage+Blurhash.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -444,6 +447,7 @@
F80048082961E6DE00E6868A /* StatusDataHandler.swift in Sources */,
F866F6A0296040A8002E8F88 /* ApplicationSettings+CoreDataClass.swift in Sources */,
F8210DEA2966E4F9001D9973 /* AnimatePlaceholderModifier.swift in Sources */,
F8984E4D296B648000A2610F /* UIImage+Blurhash.swift in Sources */,
F897978A2968314A00B22335 /* LoadingIndicator.swift in Sources */,
F8210DE52966E160001D9973 /* Color+SystemColors.swift in Sources */,
F85DBF93296760790069BF89 /* CacheAvatarService.swift in Sources */,

View File

@ -0,0 +1,154 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import UIKit
extension UIImage {
/// Code downloaded from: https://github.com/woltapp/blurhash/tree/master/Swift
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]
}
}

View File

@ -129,13 +129,11 @@ struct UserProfileView: View {
}
.padding()
LazyVGrid(columns: gridColumns) {
ForEach(self.statuses, id: \.id) { item in
NavigationLink(destination: StatusView(statusId: item.id)
.environmentObject(applicationState)) {
ImageRowAsync(attachments: item.mediaAttachments)
}
}
ForEach(self.statuses, id: \.id) { item in
NavigationLink(destination: StatusView(statusId: item.id)
.environmentObject(applicationState)) {
ImageRowAsync(attachments: item.mediaAttachments)
}
}
} else {

View File

@ -11,19 +11,52 @@ import NukeUI
struct ImageRowAsync: View {
@State public var attachments: [Attachment]
@State private var imageHeight = UIScreen.main.bounds.width
@State private var imageWidth = UIScreen.main.bounds.width
@State private var heightWasPrecalculated = true
var body: some View {
if let attachment = attachments.first {
ZStack {
LazyImage(url: attachment.url, resizingMode: .fill)
.onSuccess({ imageResponse in
LazyImage(url: attachment.url) { state in
if let image = state.image {
image
} else if state.error != nil {
ZStack {
Rectangle()
.fill(Color.placeholderText)
.frame(width: self.imageWidth, height: self.imageHeight)
VStack(alignment: .center) {
Spacer()
Text("Cannot download image")
.foregroundColor(.systemBackground)
Spacer()
}
}
} else {
VStack(alignment: .center) {
if let blurhash = attachment.blurhash,
let uiImage = UIImage(blurHash: blurhash, size: CGSize(width: 32, height: 32)) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
} else {
Rectangle()
.fill(Color.placeholderText)
.frame(width: self.imageWidth, height: self.imageHeight)
}
}
}
}
.onSuccess { imageResponse in
if heightWasPrecalculated == false {
let imgHeight = imageResponse.image.size.height
let imgWidth = imageResponse.image.size.width
let divider = imgWidth / UIScreen.main.bounds.size.width
self.imageHeight = imgHeight / divider
})
.frame(height: self.imageHeight <= 0 ? UIScreen.main.bounds.width : self.imageHeight)
self.imageHeight = self.calculateHeight(width: imgWidth, height: imgHeight)
}
}
.frame(width: self.imageWidth, height: self.imageHeight)
if let count = attachments.count, count > 1 {
BottomRight {
@ -36,8 +69,23 @@ struct ImageRowAsync: View {
}.padding()
}
}
.onAppear {
if let firstAttachment = attachments.first,
let imgHeight = (firstAttachment.meta as? ImageMetadata)?.original?.height,
let imgWidth = (firstAttachment.meta as? ImageMetadata)?.original?.width {
let calculatedHeight = self.calculateHeight(width: Double(imgWidth), height: Double(imgHeight))
self.imageHeight = calculatedHeight <= 0 ? UIScreen.main.bounds.width : calculatedHeight
} else {
heightWasPrecalculated = false
}
}
}
}
private func calculateHeight(width: Double, height: Double) -> CGFloat {
let divider = width / UIScreen.main.bounds.size.width
return height / divider
}
}
struct ImageRowAsync_Previews: PreviewProvider {