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 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")!])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user