Show blurhash placeholders from image metadata

Changelog-Added: Show blurhash placeholders from image metadata
This commit is contained in:
William Casarin
2023-04-26 15:20:12 -07:00
parent 3b50f82094
commit d16192e845
8 changed files with 196 additions and 45 deletions

View File

@@ -37,31 +37,53 @@ enum ImageShape {
case landscape case landscape
case portrait case portrait
case unknown case unknown
static func determine_image_shape(_ size: CGSize) -> ImageShape {
guard size.height > 0 else {
return .unknown
}
let imageRatio = size.width / size.height
switch imageRatio {
case 1.0: return .square
case ..<1.0: return .portrait
case 1.0...: return .landscape
default: return .unknown
}
}
} }
// Try either calculated imagefill from the real image or from metadata hints in tags
func lookup_imgmeta_size_hint(events: EventCache, url: URL?) -> CGSize? {
guard let url,
let meta = events.lookup_img_metadata(url: url),
let img_size = meta.meta.dim?.size else {
return nil
}
return img_size
}
struct ImageCarousel: View { struct ImageCarousel: View {
var urls: [URL] var urls: [URL]
let evid: String let evid: String
let previews: PreviewCache
let disable_animation: Bool let state: DamusState
@State private var open_sheet: Bool = false @State private var open_sheet: Bool = false
@State private var current_url: URL? = nil @State private var current_url: URL? = nil
@State private var image_fill: ImageFill? = nil @State private var image_fill: ImageFill? = nil
@State private var fillHeight: CGFloat = 350
@State private var maxHeight: CGFloat = UIScreen.main.bounds.height * 0.85
init(previews: PreviewCache, evid: String, urls: [URL], disable_animation: Bool) { let fillHeight: CGFloat = 350
let maxHeight: CGFloat = UIScreen.main.bounds.height * 1.2
init(state: DamusState, evid: String, urls: [URL]) {
_open_sheet = State(initialValue: false) _open_sheet = State(initialValue: false)
_current_url = State(initialValue: nil) _current_url = State(initialValue: nil)
_image_fill = State(initialValue: previews.lookup_image_meta(evid)) _image_fill = State(initialValue: state.previews.lookup_image_meta(evid))
self.urls = urls self.urls = urls
self.evid = evid self.evid = evid
self.previews = previews self.state = state
self.disable_animation = disable_animation
} }
var filling: Bool { var filling: Bool {
@@ -69,7 +91,29 @@ struct ImageCarousel: View {
} }
var height: CGFloat { var height: CGFloat {
image_fill?.height ?? 100 image_fill?.height ?? fillHeight
}
func Placeholder(url: URL, geo_size: CGSize) -> some View {
Group {
if let meta = state.events.lookup_img_metadata(url: url),
case .processed(let blurhash) = meta.state {
Image(uiImage: blurhash)
.resizable()
.frame(width: geo_size.width * UIScreen.main.scale, height: self.height * UIScreen.main.scale)
} else {
EmptyView()
}
}
.onAppear {
if self.image_fill == nil,
let meta = state.events.lookup_img_metadata(url: url),
let size = meta.meta.dim?.size
{
let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: size, maxHeight: maxHeight, fillHeight: fillHeight)
self.image_fill = fill
}
}
} }
var body: some View { var body: some View {
@@ -82,15 +126,19 @@ struct ImageCarousel: View {
KFAnimatedImage(url) KFAnimatedImage(url)
.callbackQueue(.dispatch(.global(qos:.background))) .callbackQueue(.dispatch(.global(qos:.background)))
.backgroundDecode(true) .backgroundDecode(true)
.imageContext(.note, disable_animation: disable_animation) .imageContext(.note, disable_animation: state.settings.disable_animation)
.image_fade(duration: 1.0)
.cancelOnDisappear(true) .cancelOnDisappear(true)
.configure { view in .configure { view in
view.framePreloadCount = 3 view.framePreloadCount = 3
} }
.imageFill(for: geo.size, max: maxHeight, fill: fillHeight) { fill in .imageFill(for: geo.size, max: maxHeight, fill: fillHeight) { fill in
previews.cache_image_meta(evid: evid, image_fill: fill) state.previews.cache_image_meta(evid: evid, image_fill: fill)
image_fill = fill image_fill = fill
} }
.background {
Placeholder(url: url, geo_size: geo.size)
}
.aspectRatio(contentMode: filling ? .fill : .fit) .aspectRatio(contentMode: filling ? .fill : .fit)
.tabItem { .tabItem {
Text(url.absoluteString) Text(url.absoluteString)
@@ -101,9 +149,9 @@ struct ImageCarousel: View {
} }
} }
.fullScreenCover(isPresented: $open_sheet) { .fullScreenCover(isPresented: $open_sheet) {
ImageView(urls: urls, disable_animation: disable_animation) ImageView(urls: urls, disable_animation: state.settings.disable_animation)
} }
.frame(height: height) .frame(height: self.height)
.onTapGesture { .onTapGesture {
open_sheet = true open_sheet = true
} }
@@ -137,25 +185,14 @@ public struct ImageFill {
let filling: Bool? let filling: Bool?
let height: CGFloat let height: CGFloat
static func determine_image_shape(_ size: CGSize) -> ImageShape {
guard size.height > 0 else {
return .unknown
}
let imageRatio = size.width / size.height
switch imageRatio {
case 1.0: return .square
case ..<1.0: return .portrait
case 1.0...: return .landscape
default: return .unknown
}
}
static func calculate_image_fill(geo_size: CGSize, img_size: CGSize, maxHeight: CGFloat, fillHeight: CGFloat) -> ImageFill { static func calculate_image_fill(geo_size: CGSize, img_size: CGSize, maxHeight: CGFloat, fillHeight: CGFloat) -> ImageFill {
let shape = determine_image_shape(img_size) let shape = ImageShape.determine_image_shape(img_size)
let xfactor = geo_size.width / img_size.width let xfactor = geo_size.width / img_size.width
let scaled = img_size.height * xfactor let scaled = img_size.height * xfactor
//print("calc_img_fill \(img_size.width)x\(img_size.height) xfactor:\(xfactor) scaled:\(scaled)")
// calculate scaled image height // calculate scaled image height
// set scale factor and constrain images to minimum 150 // set scale factor and constrain images to minimum 150
// and animations to scaled factor for dynamic size adjustment // and animations to scaled factor for dynamic size adjustment
@@ -172,7 +209,7 @@ public struct ImageFill {
struct ImageCarousel_Previews: PreviewProvider { struct ImageCarousel_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
ImageCarousel(previews: test_damus_state().previews, evid: "evid", urls: [URL(string: "https://jb55.com/red-me.jpg")!,URL(string: "https://jb55.com/red-me.jpg")!], disable_animation: false) ImageCarousel(state: test_damus_state(), evid: "evid", urls: [URL(string: "https://jb55.com/red-me.jpg")!,URL(string: "https://jb55.com/red-me.jpg")!])
} }
} }

View File

@@ -519,6 +519,8 @@ class HomeModel: ObservableObject {
return return
} }
// TODO: will we need to process this in other places like zap request contents, etc?
process_image_metadata(cache: damus_state.events, ev: ev)
damus_state.replies.count_replies(ev) damus_state.replies.count_replies(ev)
damus_state.events.insert(ev) damus_state.events.insert(ev)

View File

@@ -9,12 +9,39 @@ import Combine
import Foundation import Foundation
import UIKit import UIKit
class ImageMetadataState {
var state: ImageMetaProcessState
var meta: ImageMetadata
init(state: ImageMetaProcessState, meta: ImageMetadata) {
self.state = state
self.meta = meta
}
}
enum ImageMetaProcessState {
case processing
case failed
case processed(UIImage)
var img: UIImage? {
switch self {
case .processed(let img):
return img
default:
return nil
}
}
}
class EventCache { class EventCache {
private var events: [String: NostrEvent] = [:] private var events: [String: NostrEvent] = [:]
private var replies = ReplyMap() private var replies = ReplyMap()
private var cancellable: AnyCancellable? private var cancellable: AnyCancellable?
private var translations: [String: TranslateStatus] = [:] private var translations: [String: TranslateStatus] = [:]
private var artifacts: [String: NoteArtifacts] = [:] private var artifacts: [String: NoteArtifacts] = [:]
// url to meta
private var image_metadata: [String: ImageMetadataState] = [:]
var validation: [String: ValidationResult] = [:] var validation: [String: ValidationResult] = [:]
//private var thread_latest: [String: Int64] //private var thread_latest: [String: Int64]
@@ -43,10 +70,18 @@ class EventCache {
self.artifacts[evid] = artifacts self.artifacts[evid] = artifacts
} }
func store_img_metadata(url: URL, meta: ImageMetadataState) {
self.image_metadata[url.absoluteString.lowercased()] = meta
}
func lookup_artifacts(evid: String) -> NoteArtifacts? { func lookup_artifacts(evid: String) -> NoteArtifacts? {
return self.artifacts[evid] return self.artifacts[evid]
} }
func lookup_img_metadata(url: URL) -> ImageMetadataState? {
return image_metadata[url.absoluteString.lowercased()]
}
func lookup_translated_artifacts(evid: String) -> TranslateStatus? { func lookup_translated_artifacts(evid: String) -> TranslateStatus? {
return self.translations[evid] return self.translations[evid]
} }

View File

@@ -31,6 +31,13 @@ extension KFOptionSetter {
return self return self
} }
func image_fade(duration: TimeInterval) -> Self {
options.transition = ImageTransition.fade(duration)
options.keepCurrentImageWhileLoading = false
return self
}
func onFailure(fallbackUrl: URL?, cacheKey: String?) -> Self { func onFailure(fallbackUrl: URL?, cacheKey: String?) -> Self {
guard let url = fallbackUrl, let key = cacheKey else { return self } guard let url = fallbackUrl, let key = cacheKey else { return self }
let imageResource = ImageResource(downloadURL: url, cacheKey: key) let imageResource = ImageResource(downloadURL: url, cacheKey: key)

View File

@@ -25,18 +25,25 @@ struct ImageMetaDim: Equatable, StringCodable {
"\(width)x\(height)" "\(width)x\(height)"
} }
var size: CGSize {
return CGSize(width: CGFloat(self.width), height: CGFloat(self.height))
}
let width: Int let width: Int
let height: Int let height: Int
}
struct ProcessedImageMetadata {
let blurhash: UIImage?
let dim: ImageMetaDim?
} }
struct ImageMetadata: Equatable { struct ImageMetadata: Equatable {
let url: URL let url: URL
let blurhash: String let blurhash: String?
let dim: ImageMetaDim let dim: ImageMetaDim?
init(url: URL, blurhash: String, dim: ImageMetaDim) { init(url: URL, blurhash: String? = nil, dim: ImageMetaDim? = nil) {
self.url = url self.url = url
self.blurhash = blurhash self.blurhash = blurhash
self.dim = dim self.dim = dim
@@ -55,8 +62,30 @@ struct ImageMetadata: Equatable {
} }
} }
func process_blurhash(blurhash: String, size: CGSize?) async -> UIImage? {
let res = Task.init {
let size = get_blurhash_size(img_size: size ?? CGSize(width: 100.0, height: 100.0))
guard let img = UIImage.init(blurHash: blurhash, size: size) else {
let noimg: UIImage? = nil
return noimg
}
return img
}
return await res.value
}
func image_metadata_to_tag(_ meta: ImageMetadata) -> [String] { func image_metadata_to_tag(_ meta: ImageMetadata) -> [String] {
return ["imeta", "url \(meta.url.absoluteString)", "blurhash \(meta.blurhash)", "dim \(meta.dim.to_string())"] var tags = ["imeta", "url \(meta.url.absoluteString)"]
if let blurhash = meta.blurhash {
tags.append("blurhash \(blurhash)")
}
if let dim = meta.dim {
tags.append("dim \(dim.to_string())")
}
return tags
} }
func decode_image_metadata(_ parts: [String]) -> ImageMetadata? { func decode_image_metadata(_ parts: [String]) -> ImageMetadata? {
@@ -65,7 +94,12 @@ func decode_image_metadata(_ parts: [String]) -> ImageMetadata? {
var dim: ImageMetaDim? = nil var dim: ImageMetaDim? = nil
for part in parts { for part in parts {
if part == "imeta" {
continue
}
let ps = part.split(separator: " ") let ps = part.split(separator: " ")
guard ps.count == 2 else { guard ps.count == 2 else {
return nil return nil
} }
@@ -81,7 +115,7 @@ func decode_image_metadata(_ parts: [String]) -> ImageMetadata? {
} }
} }
guard let blurhash, let dim, let url else { guard let url else {
return nil return nil
} }
@@ -107,16 +141,18 @@ extension UIImage {
} }
} }
func get_blurhash_size(img_size: CGSize) -> CGSize {
return CGSize(width: 100.0, height: (100.0/img_size.width) * img_size.height)
}
func calculate_blurhash(img: UIImage) async -> String? { func calculate_blurhash(img: UIImage) async -> String? {
guard img.size.height > 0 else { guard img.size.height > 0 else {
return nil return nil
} }
let res = Task.init { let res = Task.init {
let sw: Double = 100 let bhs = get_blurhash_size(img_size: img.size)
let sh: Double = (100.0/img.size.width) * img.size.height let smaller = img.resized(to: bhs)
let smaller = img.resized(to: CGSize(width: sw, height: sh))
guard let blurhash = smaller.blurHash(numberOfComponents: (5,5)) else { guard let blurhash = smaller.blurHash(numberOfComponents: (5,5)) else {
let meta: String? = nil let meta: String? = nil
@@ -130,9 +166,43 @@ func calculate_blurhash(img: UIImage) async -> String? {
} }
func calculate_image_metadata(url: URL, img: UIImage, blurhash: String) -> ImageMetadata { func calculate_image_metadata(url: URL, img: UIImage, blurhash: String) -> ImageMetadata {
let width = Int(round(img.size.width * img.scale)) let width = Int(img.size.width)
let height = Int(round(img.size.height * img.scale)) let height = Int(img.size.height)
let dim = ImageMetaDim(width: width, height: height) let dim = ImageMetaDim(width: width, height: height)
return ImageMetadata(url: url, blurhash: blurhash, dim: dim) return ImageMetadata(url: url, blurhash: blurhash, dim: dim)
} }
func process_image_metadata(cache: EventCache, ev: NostrEvent) {
for tag in ev.tags {
guard tag.count >= 2 && tag[0] == "imeta" else {
continue
}
guard let meta = ImageMetadata(tag: tag) else {
continue
}
guard cache.lookup_img_metadata(url: meta.url) == nil else {
continue
}
let state = ImageMetadataState(state: .processing, meta: meta)
cache.store_img_metadata(url: meta.url, meta: state)
if let blurhash = meta.blurhash {
Task.init {
let img = await process_blurhash(blurhash: blurhash, size: meta.dim?.size)
DispatchQueue.main.async {
if let img {
state.state = .processed(img)
} else {
state.state = .failed
}
}
}
}
}
}

View File

@@ -9,7 +9,6 @@ import SwiftUI
import Kingfisher import Kingfisher
// lots of overlap between this and ImageContainerView
struct ImageContainerView: View { struct ImageContainerView: View {
let url: URL? let url: URL?

View File

@@ -123,10 +123,10 @@ struct NoteContentView: View {
} }
if show_images && artifacts.images.count > 0 { if show_images && artifacts.images.count > 0 {
ImageCarousel(previews: damus_state.previews, evid: event.id, urls: artifacts.images, disable_animation: damus_state.settings.disable_animation) ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.images)
} else if !show_images && artifacts.images.count > 0 { } else if !show_images && artifacts.images.count > 0 {
ZStack { ZStack {
ImageCarousel(previews: damus_state.previews, evid: event.id, urls: artifacts.images, disable_animation: damus_state.settings.disable_animation) ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.images)
Blur() Blur()
.disabled(true) .disabled(true)
} }

View File

@@ -251,6 +251,7 @@ struct PostView: View {
let uploader = damus_state.settings.default_media_uploader let uploader = damus_state.settings.default_media_uploader
Task.init { Task.init {
let img = getImage(media: media) let img = getImage(media: media)
print("img size w:\(img.size.width) h:\(img.size.height)")
async let blurhash = calculate_blurhash(img: img) async let blurhash = calculate_blurhash(img: img)
let res = await image_upload.start(media: media, uploader: uploader) let res = await image_upload.start(media: media, uploader: uploader)