diff --git a/App/EditorModel.swift b/App/EditorModel.swift index 9b82497..1a8ae53 100644 --- a/App/EditorModel.swift +++ b/App/EditorModel.swift @@ -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 diff --git a/App/EditorView.swift b/App/EditorView.swift index e177fd0..05c9ce8 100644 --- a/App/EditorView.swift +++ b/App/EditorView.swift @@ -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() { diff --git a/App/HomeView.swift b/App/HomeView.swift index 11e3307..f36ba56 100644 --- a/App/HomeView.swift +++ b/App/HomeView.swift @@ -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) } @@ -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") @@ -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 } } } @@ -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 { @@ -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 } + } +} diff --git a/App/LibraryStore.swift b/App/LibraryStore.swift index f5161e8..f8c93bd 100644 --- a/App/LibraryStore.swift +++ b/App/LibraryStore.swift @@ -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) diff --git a/App/ThumbnailCache.swift b/App/ThumbnailCache.swift new file mode 100644 index 0000000..21980fb --- /dev/null +++ b/App/ThumbnailCache.swift @@ -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 = { + let cache = NSCache() + 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 + } +} diff --git a/Sources/SnapCore/Thumbnail.swift b/Sources/SnapCore/Thumbnail.swift new file mode 100644 index 0000000..6d4208a --- /dev/null +++ b/Sources/SnapCore/Thumbnail.swift @@ -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 } +} diff --git a/Sources/snapverify/main.swift b/Sources/snapverify/main.swift index ac3f38c..ce91437 100644 --- a/Sources/snapverify/main.swift +++ b/Sources/snapverify/main.swift @@ -2,6 +2,7 @@ import SnapCore import CoreGraphics import Foundation import AppKit +import ImageIO import AVFoundation var failures = 0 @@ -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)