damus

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

commit d16192e845264ae6a6959c409e3df5c2342ab45d
parent 3b50f8209447198c3a0459a62538ac5e2c627494
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 26 Apr 2023 15:20:12 -0700

Show blurhash placeholders from image metadata

Changelog-Added: Show blurhash placeholders from image metadata

Diffstat:
Mdamus/Components/ImageCarousel.swift | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mdamus/Models/HomeModel.swift | 2++
Mdamus/Util/EventCache.swift | 35+++++++++++++++++++++++++++++++++++
Mdamus/Util/Extensions/KFOptionSetter+.swift | 7+++++++
Mdamus/Util/Images/ImageMetadata.swift | 96++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mdamus/Views/Images/ImageContainerView.swift | 1-
Mdamus/Views/NoteContentView.swift | 4++--
Mdamus/Views/PostView.swift | 1+
8 files changed, 196 insertions(+), 45 deletions(-)

diff --git a/damus/Components/ImageCarousel.swift b/damus/Components/ImageCarousel.swift @@ -37,31 +37,53 @@ enum ImageShape { case landscape case portrait 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 { var urls: [URL] let evid: String - let previews: PreviewCache - let disable_animation: Bool + let state: DamusState @State private var open_sheet: Bool = false @State private var current_url: URL? = 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) _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.evid = evid - self.previews = previews - self.disable_animation = disable_animation + self.state = state } var filling: Bool { @@ -69,7 +91,29 @@ struct ImageCarousel: View { } 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 { @@ -82,15 +126,19 @@ struct ImageCarousel: View { KFAnimatedImage(url) .callbackQueue(.dispatch(.global(qos:.background))) .backgroundDecode(true) - .imageContext(.note, disable_animation: disable_animation) + .imageContext(.note, disable_animation: state.settings.disable_animation) + .image_fade(duration: 1.0) .cancelOnDisappear(true) .configure { view in view.framePreloadCount = 3 } .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 } + .background { + Placeholder(url: url, geo_size: geo.size) + } .aspectRatio(contentMode: filling ? .fill : .fit) .tabItem { Text(url.absoluteString) @@ -101,9 +149,9 @@ struct ImageCarousel: View { } } .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 { open_sheet = true } @@ -137,25 +185,14 @@ public struct ImageFill { let filling: Bool? 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 { - let shape = determine_image_shape(img_size) + let shape = ImageShape.determine_image_shape(img_size) let xfactor = geo_size.width / img_size.width 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 // set scale factor and constrain images to minimum 150 // and animations to scaled factor for dynamic size adjustment @@ -172,7 +209,7 @@ public struct ImageFill { struct ImageCarousel_Previews: PreviewProvider { 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")!]) } } diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -519,6 +519,8 @@ class HomeModel: ObservableObject { 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.events.insert(ev) diff --git a/damus/Util/EventCache.swift b/damus/Util/EventCache.swift @@ -9,12 +9,39 @@ import Combine import Foundation 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 { private var events: [String: NostrEvent] = [:] private var replies = ReplyMap() private var cancellable: AnyCancellable? private var translations: [String: TranslateStatus] = [:] private var artifacts: [String: NoteArtifacts] = [:] + // url to meta + private var image_metadata: [String: ImageMetadataState] = [:] var validation: [String: ValidationResult] = [:] //private var thread_latest: [String: Int64] @@ -43,10 +70,18 @@ class EventCache { 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? { return self.artifacts[evid] } + func lookup_img_metadata(url: URL) -> ImageMetadataState? { + return image_metadata[url.absoluteString.lowercased()] + } + func lookup_translated_artifacts(evid: String) -> TranslateStatus? { return self.translations[evid] } diff --git a/damus/Util/Extensions/KFOptionSetter+.swift b/damus/Util/Extensions/KFOptionSetter+.swift @@ -31,6 +31,13 @@ extension KFOptionSetter { 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 { guard let url = fallbackUrl, let key = cacheKey else { return self } let imageResource = ImageResource(downloadURL: url, cacheKey: key) diff --git a/damus/Util/Images/ImageMetadata.swift b/damus/Util/Images/ImageMetadata.swift @@ -25,18 +25,25 @@ struct ImageMetaDim: Equatable, StringCodable { "\(width)x\(height)" } + var size: CGSize { + return CGSize(width: CGFloat(self.width), height: CGFloat(self.height)) + } + let width: Int let height: Int - - +} + +struct ProcessedImageMetadata { + let blurhash: UIImage? + let dim: ImageMetaDim? } struct ImageMetadata: Equatable { let url: URL - let blurhash: String - let dim: ImageMetaDim + let blurhash: String? + let dim: ImageMetaDim? - init(url: URL, blurhash: String, dim: ImageMetaDim) { + init(url: URL, blurhash: String? = nil, dim: ImageMetaDim? = nil) { self.url = url self.blurhash = blurhash 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] { - 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? { @@ -65,7 +94,12 @@ func decode_image_metadata(_ parts: [String]) -> ImageMetadata? { var dim: ImageMetaDim? = nil for part in parts { + if part == "imeta" { + continue + } + let ps = part.split(separator: " ") + guard ps.count == 2 else { 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 } @@ -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? { 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)) + let bhs = get_blurhash_size(img_size: img.size) + let smaller = img.resized(to: bhs) guard let blurhash = smaller.blurHash(numberOfComponents: (5,5)) else { 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 { - let width = Int(round(img.size.width * img.scale)) - let height = Int(round(img.size.height * img.scale)) + let width = Int(img.size.width) + let height = Int(img.size.height) let dim = ImageMetaDim(width: width, height: height) 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 + } + } + } + } + } +} diff --git a/damus/Views/Images/ImageContainerView.swift b/damus/Views/Images/ImageContainerView.swift @@ -9,7 +9,6 @@ import SwiftUI import Kingfisher -// lots of overlap between this and ImageContainerView struct ImageContainerView: View { let url: URL? diff --git a/damus/Views/NoteContentView.swift b/damus/Views/NoteContentView.swift @@ -123,10 +123,10 @@ struct NoteContentView: View { } 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 { 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() .disabled(true) } diff --git a/damus/Views/PostView.swift b/damus/Views/PostView.swift @@ -251,6 +251,7 @@ struct PostView: View { let uploader = damus_state.settings.default_media_uploader Task.init { let img = getImage(media: media) + print("img size w:\(img.size.width) h:\(img.size.height)") async let blurhash = calculate_blurhash(img: img) let res = await image_upload.start(media: media, uploader: uploader)