Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions App/EditorModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,12 @@ final class EditorModel: ObservableObject {
var canUndo: Bool { !undoStack.isEmpty }
var canRedo: Bool { !redoStack.isEmpty }

/// Each step can hold a full-size image (after crop/resize/background edits), so history is capped.
private static let undoLimit = 50

private func snapshot() {
undoStack.append(Snapshot(doc: doc, base: base, imageLayers: imageLayers))
if undoStack.count > Self.undoLimit { undoStack.removeFirst(undoStack.count - Self.undoLimit) }
redoStack.removeAll()
hasUnsavedChanges = true
restyledSelectionID = nil
Expand Down
3 changes: 2 additions & 1 deletion App/EditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1170,7 +1170,8 @@ private struct EditorRecentThumbnail: View {
.onHover { hovering in withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) { isHovered = hovering } }
.help("Click to layer on top, ⌥-click to combine beside, or drag onto the canvas to place it")
.onDrag { NSItemProvider(contentsOf: AppState.shared.library.fileURL(for: record)) ?? NSItemProvider() }
.task { thumbnail = NSImage(contentsOf: AppState.shared.library.fileURL(for: record)) }
.task { thumbnail = await ThumbnailCache.shared.thumbnail(for: AppState.shared.library.fileURL(for: record)) }
.onDisappear { thumbnail = nil }
}

private func addToCanvas() {
Expand Down
58 changes: 39 additions & 19 deletions App/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -608,9 +608,8 @@ struct HomeView: View {
VStack(alignment: .leading, spacing: 8) {
ZStack {
RoundedRectangle(cornerRadius: 12).fill(Theme.panelBG).frame(height: 120)
if let first = members.first, let img = NSImage(contentsOf: library.fileURL(for: first)) {
Image(nsImage: img).resizable().aspectRatio(contentMode: .fill)
.frame(height: 120).clipShape(RoundedRectangle(cornerRadius: 12))
if let first = members.first {
CollectionCover(url: library.fileURL(for: first), isVideo: first.kind != .image)
} else {
Image(systemName: "folder").font(.system(size: 34)).foregroundStyle(.tertiary)
}
Expand Down Expand Up @@ -804,14 +803,17 @@ struct HomeView: View {
guard panel.runModal() == .OK, let folder = panel.url else { return }
var saved = 0
for record in pdfImages where batchSelection.contains(record.id) {
guard let image = NSImage(contentsOf: library.fileURL(for: record)),
var rect = Optional(NSRect(origin: .zero, size: image.size)),
let cg = image.cgImage(forProposedRect: &rect, context: nil, hints: nil) else { continue }
let output = batchFramed ? ImagePolish.frame(cg) : cg
guard let data = Exporter.data(output, format: batchFormat) else { continue }
let safeName = record.title.replacingOccurrences(of: "/", with: "-")
let destination = folder.appendingPathComponent("\(safeName)-\(record.id.suffix(4)).\(batchFormat.ext)")
if (try? data.write(to: destination, options: .atomic)) != nil { saved += 1 }
// Release each full-size image before decoding the next.
autoreleasepool {
guard let image = NSImage(contentsOf: library.fileURL(for: record)),
var rect = Optional(NSRect(origin: .zero, size: image.size)),
let cg = image.cgImage(forProposedRect: &rect, context: nil, hints: nil) else { return }
let output = batchFramed ? ImagePolish.frame(cg) : cg
guard let data = Exporter.data(output, format: batchFormat) else { return }
let safeName = record.title.replacingOccurrences(of: "/", with: "-")
let destination = folder.appendingPathComponent("\(safeName)-\(record.id.suffix(4)).\(batchFormat.ext)")
if (try? data.write(to: destination, options: .atomic)) != nil { saved += 1 }
}
}
showBatchSheet = false
Toast.show("Exported \(saved) image\(saved == 1 ? "" : "s")", symbol: "checkmark.circle.fill")
Expand Down Expand Up @@ -846,7 +848,8 @@ private struct PDFPickCell: View {
}
.contentShape(Rectangle())
.onTapGesture { toggle() }
.task { thumb = NSImage(contentsOf: library.fileURL(for: record)) }
.task { thumb = await ThumbnailCache.shared.thumbnail(for: library.fileURL(for: record)) }
.onDisappear { thumb = nil }
}
}

Expand Down Expand Up @@ -985,14 +988,11 @@ private struct GalleryCard: View {
}
}
.onDrag { NSItemProvider(object: record.id as NSString) }
.task {
let url = library.fileURL(for: record)
if record.kind == .image {
thumb = NSImage(contentsOf: url)
} else if let cg = await VideoAnnotator.grabFrame(from: url) {
thumb = NSImage(cgImage: cg, size: NSSize(width: cg.width, height: cg.height))
}
.task(id: record.fileName) {
thumb = await ThumbnailCache.shared.thumbnail(for: library.fileURL(for: record),
isVideo: record.kind != .image)
}
.onDisappear { thumb = nil } // let the capped cache own off-screen previews
}

private func iconButton(_ icon: String, _ help: String, _ action: @escaping () -> Void) -> some View {
Expand Down Expand Up @@ -1026,3 +1026,23 @@ private struct GalleryCard: View {
.background(Theme.chipBG, in: RoundedRectangle(cornerRadius: 5))
}
}

/// A collection's cover tile, loaded as a cached thumbnail instead of decoding in `body`.
private struct CollectionCover: View {
let url: URL
let isVideo: Bool
@State private var image: NSImage?

var body: some View {
ZStack {
if let image {
Image(nsImage: image).resizable().aspectRatio(contentMode: .fill)
.frame(height: 120).clipShape(RoundedRectangle(cornerRadius: 12))
} else {
Image(systemName: "photo").font(.system(size: 28)).foregroundStyle(.tertiary)
}
}
.task(id: url) { image = await ThumbnailCache.shared.thumbnail(for: url, isVideo: isVideo) }
.onDisappear { image = nil }
}
}
1 change: 1 addition & 0 deletions App/LibraryStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ final class LibraryStore: ObservableObject {
func overwrite(id: String, image: CGImage) {
guard let idx = records.firstIndex(where: { $0.id == id }) else { return }
_ = writePNG(image, to: fileURL(for: records[idx]))
ThumbnailCache.shared.invalidate(fileURL(for: records[idx]))
records[idx].width = image.width
records[idx].height = image.height
let rec = records.remove(at: idx)
Expand Down
45 changes: 45 additions & 0 deletions App/ThumbnailCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import AppKit
import AVFoundation
import SnapCore

/// Small, downsampled previews for gallery, collection, picker and Recent tiles.
/// Decoding happens off the main thread; the cache is capped so browsing the whole library
/// can't grow memory without bound (macOS also purges it under memory pressure).
final class ThumbnailCache: @unchecked Sendable {
static let shared = ThumbnailCache()
/// Longest side in pixels — sharp on Retina for tiles up to ~320 pt wide.
static let tileSize = 640

private let cache: NSCache<NSURL, NSImage> = {
let cache = NSCache<NSURL, NSImage>()
cache.totalCostLimit = 120 * 1_048_576
return cache
}()

/// A cached preview, or nil if it still needs loading.
func cached(_ url: URL) -> NSImage? { cache.object(forKey: url as NSURL) }

/// Load (or reuse) a preview for an image or video file.
func thumbnail(for url: URL, isVideo: Bool = false) async -> NSImage? {
if let hit = cached(url) { return hit }
let image: CGImage? = isVideo
? await Self.videoFrame(url)
: await Task.detached(priority: .utility) { Thumbnail.make(at: url, maxPixelSize: Self.tileSize) }.value
guard let image else { return nil }
let preview = NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height))
cache.setObject(preview, forKey: url as NSURL, cost: Thumbnail.byteCost(of: image))
return preview
}

/// Drop a stale preview after the file was edited or replaced.
func invalidate(_ url: URL) { cache.removeObject(forKey: url as NSURL) }

private static func videoFrame(_ url: URL) async -> CGImage? {
let asset = AVURLAsset(url: url)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: tileSize, height: tileSize)
let seconds = (try? await asset.load(.duration).seconds) ?? 0
return try? await generator.image(at: CMTime(seconds: max(0, seconds / 2), preferredTimescale: 600)).image
}
}
24 changes: 24 additions & 0 deletions Sources/SnapCore/Thumbnail.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import CoreGraphics
import Foundation
import ImageIO

/// Downsampled image loading. Decoding a full Retina screenshot to fill a small tile costs
/// ~15 MB per tile once it's on screen; ImageIO can decode straight to the size we draw.
public enum Thumbnail {
/// Decode `url` so its longest side is at most `maxPixelSize` (never upscaled).
public static func make(at url: URL, maxPixelSize: Int) -> CGImage? {
guard maxPixelSize > 0,
let source = CGImageSourceCreateWithURL(url as CFURL, [kCGImageSourceShouldCache: false] as CFDictionary)
else { return nil }
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true,
]
return CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
}

/// Bytes a decoded image occupies — used as its cache cost.
public static func byteCost(of image: CGImage) -> Int { image.bytesPerRow * image.height }
}
36 changes: 36 additions & 0 deletions Sources/snapverify/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import SnapCore
import CoreGraphics
import Foundation
import AppKit
import ImageIO
import AVFoundation

var failures = 0
Expand Down Expand Up @@ -632,5 +633,40 @@ if let legacy = try? JSONDecoder().decode(EditorProject.self, from: Data(#"{"ver
check(legacy.cornerRadius == nil, "projects saved before rounded corners still load")
} else { check(false, "legacy project decodes") }

// MARK: Thumbnails
do {
let thumbDir = FileManager.default.temporaryDirectory.appendingPathComponent("snapverify-thumb-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: thumbDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: thumbDir) }
func writeFixture(_ name: String, width: Int, height: Int) -> URL? {
let url = thumbDir.appendingPathComponent(name)
guard let image = renderSolid(width: width, height: height),
let dest = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else { return nil }
CGImageDestinationAddImage(dest, image, nil)
return CGImageDestinationFinalize(dest) ? url : nil
}
if let wide = writeFixture("wide.png", width: 3000, height: 1500) {
let thumb = Thumbnail.make(at: wide, maxPixelSize: 600)
check(thumb?.width == 600 && thumb?.height == 300, "thumbnail fits the longest side, keeping aspect (got \(thumb?.width ?? 0)x\(thumb?.height ?? 0))")
if let thumb, let full = renderSolid(width: 3000, height: 1500) {
check(Thumbnail.byteCost(of: thumb) * 20 < Thumbnail.byteCost(of: full), "thumbnail costs a fraction of the full decode")
}
check(Thumbnail.make(at: wide, maxPixelSize: 0) == nil, "zero size is rejected")
}
if let tall = writeFixture("tall.png", width: 400, height: 2000) {
check(Thumbnail.make(at: tall, maxPixelSize: 500)?.height == 500, "tall images are limited by height")
}
if let small = writeFixture("small.png", width: 120, height: 80) {
let thumb = Thumbnail.make(at: small, maxPixelSize: 600)
check(thumb?.width == 120 && thumb?.height == 80, "small images are never upscaled")
}
check(Thumbnail.make(at: thumbDir.appendingPathComponent("missing.png"), maxPixelSize: 600) == nil, "missing file returns nil")
let notImage = thumbDir.appendingPathComponent("notes.txt")
try Data("hello".utf8).write(to: notImage)
check(Thumbnail.make(at: notImage, maxPixelSize: 600) == nil, "non-image file returns nil")
} catch {
check(false, "thumbnail fixtures threw: \(error)")
}

print(failures == 0 ? "ALL PASS" : "\(failures) FAILED")
exit(failures == 0 ? 0 : 1)
Loading