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:
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)