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 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")!])
}
}

View File

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

View File

@@ -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]
}

View File

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

View File

@@ -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
}
}
}
}
}
}

View File

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

View File

@@ -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)
}

View File

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