Show blurhash placeholders from image metadata
Changelog-Added: Show blurhash placeholders from image metadata
This commit is contained in:
@@ -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")!])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
|
||||
// lots of overlap between this and ImageContainerView
|
||||
struct ImageContainerView: View {
|
||||
let url: URL?
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user