diff --git a/cull/Models/CullSession.swift b/cull/Models/CullSession.swift index cba211a..2a1cc1c 100644 --- a/cull/Models/CullSession.swift +++ b/cull/Models/CullSession.swift @@ -278,6 +278,38 @@ final class CullSession { return result } + /// Photos whose full-size previews should stay resident, most urgent first. + /// + /// Group representatives are interleaved with linear neighbors so both jumping + /// between scenes and walking through one feel instant. Entries are deduplicated: + /// neighbors and representatives overlap heavily once a filter hides photos, and + /// duplicates used to eat window slots that real photos needed. + func previewWindow(lookahead: Int = 20, lookbehind: Int = 12, groupCount: Int = 10) -> [Photo] { + guard let current = selectedPhoto else { return [] } + + let reps = adjacentGroupRepresentatives(groupCount: groupCount) + let ahead = photosAhead(lookahead) + let behind = photosBehind(lookbehind) + + var window: [Photo] = [current] + var seen: Set = [current.id] + func add(_ photo: Photo) { + guard seen.insert(photo.id).inserted else { return } + window.append(photo) + } + + var gi = 0 + var li = 0 + while gi < reps.count || li < max(ahead.count, behind.count) { + if gi < reps.count { add(reps[gi]); gi += 1 } + if gi < reps.count { add(reps[gi]); gi += 1 } + if li < ahead.count { add(ahead[li]) } + if li < behind.count { add(behind[li]) } + li += 1 + } + return window + } + // MARK: - Culling Actions private func applyPhotoState(_ photo: Photo, rating: Int, flag: PhotoFlag, actionName: String) { @@ -447,6 +479,11 @@ final class CullSession { guard let groupID = saved.groupID, let photo = photosByPath[saved.path] else { continue } photosByGroup[groupID, default: []].append(photo) } + // Photos come back in whatever order SQLite yields them, so restore + // chronological order to match a fresh import + for key in photosByGroup.keys { + photosByGroup[key]?.sort { ($0.captureDate ?? .distantPast) < ($1.captureDate ?? .distantPast) } + } var rebuiltGroups: [PhotoGroup] = [] for groupID in groupOrder { diff --git a/cull/Services/ThumbnailCache.swift b/cull/Services/ThumbnailCache.swift index 5818040..3bc5c33 100644 --- a/cull/Services/ThumbnailCache.swift +++ b/cull/Services/ThumbnailCache.swift @@ -8,7 +8,10 @@ final class ThumbnailCache { private let previewCache = NSCache() private var thumbnailKeys = Set() private var previewKeys = Set() - private var preloadTask: Task? + /// Previews still wanted, highest priority first. Replaced (not cancelled) when the window moves. + private var pendingPreviews: [(key: String, url: URL)] = [] + private var previewWorker: Task? + private var thumbnailSweep: Task? /// Bumped whenever cache state changes, to trigger SwiftUI re-renders for debug overlay private(set) var cacheGeneration = 0 /// Recent debug log entries (newest first), shown in debug overlay @@ -24,7 +27,12 @@ final class ThumbnailCache { memoryCache.countLimit = 500 memoryCache.totalCostLimit = 100 * 1024 * 1024 // 100 MB - previewCache.countLimit = 120 + // Previews are full-screen bitmaps (~17 MB each at 2560px), so the byte + // budget is what actually bounds them. Without it NSCache purges under + // memory pressure at unpredictable moments and the viewer falls back to + // thumbnails. Keep the navigation window smaller than this budget. + previewCache.countLimit = 72 + previewCache.totalCostLimit = 1024 * 1024 * 1024 // 1 GB try? FileManager.default.createDirectory(at: diskCacheURL, withIntermediateDirectories: true) } @@ -69,7 +77,7 @@ final class ThumbnailCache { }.value if let image { - memoryCache.setObject(image, forKey: key as NSString) + memoryCache.setObject(image, forKey: key as NSString, cost: Self.cost(of: image)) thumbnailKeys.insert(key) } return image @@ -89,7 +97,7 @@ final class ThumbnailCache { }.value if let image { - previewCache.setObject(image, forKey: key as NSString) + previewCache.setObject(image, forKey: key as NSString, cost: Self.cost(of: image)) previewKeys.insert(key) cacheGeneration += 1 } @@ -135,7 +143,7 @@ final class ThumbnailCache { var batchKeys: [String] = [] for await (key, image) in group { if let image { - mc.setObject(image, forKey: key as NSString) + mc.setObject(image, forKey: key as NSString, cost: Self.cost(of: image)) batchKeys.append(key) } completed += 1 @@ -149,7 +157,12 @@ final class ThumbnailCache { } } + /// Fire-and-forget thumbnail sweep, used after a filter change brings new photos into view. + /// Runs at background priority and stays cancellable so it can't starve preview loading: + /// an unbounded full-library sweep is what makes the viewer fall back to thumbnails. func preload(photos: [Photo]) { + thumbnailSweep?.cancel() + let work: [(String, URL, URL)] = photos.compactMap { photo in let key = photo.url.absoluteString guard memoryCache.object(forKey: key as NSString) == nil else { return nil } @@ -157,18 +170,21 @@ final class ThumbnailCache { } guard !work.isEmpty else { return } + log("sweep \(work.count) thumbs") + let pixelSize = maxPixelSize let diskCache = diskCacheURL let mc = memoryCache - Task.detached(priority: .utility) { + thumbnailSweep = Task.detached(priority: .background) { for batchStart in stride(from: 0, to: work.count, by: 8) { + guard !Task.isCancelled else { return } let batch = Array(work[batchStart.. 0 || evictedThumbs > 0 { - log("filter evict \(evictedPreviews)p \(evictedThumbs)t") + if evictedPreviews > 0 { + log("filter evict \(evictedPreviews)p") } cacheGeneration += 1 } @@ -335,7 +349,12 @@ final class ThumbnailCache { } func stats() -> CacheStats { - CacheStats( + // NSCache can drop entries on its own under memory pressure, so the key sets + // drift out of date. Prune before reporting or the overlay lies about coverage. + previewKeys = previewKeys.filter { previewCache.object(forKey: $0 as NSString) != nil } + thumbnailKeys = thumbnailKeys.filter { memoryCache.object(forKey: $0 as NSString) != nil } + + return CacheStats( thumbnailCount: thumbnailKeys.count, previewCount: previewKeys.count, previewLimit: previewCache.countLimit, @@ -375,6 +394,12 @@ final class ThumbnailCache { // MARK: - Utilities + /// Approximate decoded size in bytes, so NSCache's byte budget means something. + nonisolated private static func cost(of image: NSImage) -> Int { + guard let rep = image.representations.first else { return 1 } + return max(rep.pixelsWide * rep.pixelsHigh * 4, 1) + } + private func stableDiskKey(for url: URL) -> String { Self.stableDiskKey(for: url) } @@ -394,6 +419,11 @@ final class ThumbnailCache { } func clearCache() { + previewWorker?.cancel() + previewWorker = nil + thumbnailSweep?.cancel() + thumbnailSweep = nil + pendingPreviews.removeAll() memoryCache.removeAllObjects() previewCache.removeAllObjects() thumbnailKeys.removeAll() diff --git a/cull/Views/ContentView.swift b/cull/Views/ContentView.swift index 4ce96f8..2f682a0 100644 --- a/cull/Views/ContentView.swift +++ b/cull/Views/ContentView.swift @@ -133,9 +133,14 @@ struct ContentView: View { session.undoManager = windowUndoManager } .onChange(of: session.filterVersion) { - // Evict cache entries for filtered photos, preload newly visible ones + // Hiding photos reshapes the preview window, so re-prime it here too. + // Without this, previews outside the new visible set were evicted and + // nothing reloaded until the selection moved — leaving the viewer on + // thumbnails right after a filter toggle. let visible = session.allPhotos.filter { !session.isPhotoFiltered($0) } - cache.evictFiltered(keeping: visible) + let window = session.previewWindow() + cache.evictFiltered(keeping: window) + cache.preloadPreviews(photos: window) cache.preload(photos: visible) } } @@ -206,8 +211,8 @@ struct ContentView: View { } await MainActor.run { s.importStatus = "Loading previews..." } - let ahead = Array(visiblePhotos.prefix(30)) - let behind = Array(visiblePhotos.suffix(30)) + let ahead = Array(visiblePhotos.prefix(24)) + let behind = Array(visiblePhotos.suffix(12)) let initialPreviews = ahead + behind.reversed() await c.preloadAllPreviews(photos: initialPreviews) { p in await MainActor.run { @@ -296,16 +301,15 @@ struct ContentView: View { } } - // Rank photos within each group — best first (after analysis completes) + // Order photos within each group chronologically for group in groups { - let scored = group.photos.map { (photo: $0, score: Self.qualityScore($0, in: group)) } - group.photos = scored.sorted { $0.score > $1.score }.map(\.photo) + group.photos.sort { ($0.captureDate ?? .distantPast) < ($1.captureDate ?? .distantPast) } } - // Preload previews in sorted order so they match browse order + // Preload previews in browse order let sortedPhotos = groups.flatMap(\.photos) - let ahead = Array(sortedPhotos.prefix(30)) - let behind = Array(sortedPhotos.suffix(30)) + let ahead = Array(sortedPhotos.prefix(24)) + let behind = Array(sortedPhotos.suffix(12)) let initialPreviews = ahead + behind.reversed() await c.preloadAllPreviews(photos: initialPreviews) { p in previewProgress = p @@ -422,8 +426,8 @@ struct ContentView: View { } parallelGroup.addTask { - let ahead = Array(visiblePhotos.prefix(30)) - let behind = Array(visiblePhotos.suffix(30)) + let ahead = Array(visiblePhotos.prefix(24)) + let behind = Array(visiblePhotos.suffix(12)) let initialPreviews = ahead + behind.reversed() await c.preloadAllPreviews(photos: initialPreviews) { p in previewProgress = p @@ -432,10 +436,9 @@ struct ContentView: View { } } - // Rank photos within each group + // Order photos within each group chronologically for group in groups { - let scored = group.photos.map { (photo: $0, score: Self.qualityScore($0, in: group)) } - group.photos = scored.sorted { $0.score > $1.score }.map(\.photo) + group.photos.sort { ($0.captureDate ?? .distantPast) < ($1.captureDate ?? .distantPast) } } await MainActor.run { diff --git a/cull/Views/PhotoViewer.swift b/cull/Views/PhotoViewer.swift index 4daa8b3..ee723af 100644 --- a/cull/Views/PhotoViewer.swift +++ b/cull/Views/PhotoViewer.swift @@ -8,9 +8,6 @@ struct PhotoViewer: View { /// Tracks what quality level is currently displayed: "preview", "thumbnail", or "none" @State private var displayQuality: String = "none" - private let lookaheadCount = 30 - private let lookbehindCount = 30 - var body: some View { ZStack { @@ -303,22 +300,7 @@ struct PhotoViewer: View { // Preload window: group reps prioritized over linear neighbors guard !Task.isCancelled, displayedPhotoID == photoID else { return } - let groupReps = session.adjacentGroupRepresentatives(groupCount: 15) - let ahead = session.photosAhead(lookaheadCount) - let behind = session.photosBehind(lookbehindCount) - - var fanOut: [Photo] = [] - let maxLinear = max(ahead.count, behind.count) - let maxGroups = groupReps.count - var gi = 0, li = 0 - while gi < maxGroups || li < maxLinear { - if gi < maxGroups { fanOut.append(groupReps[gi]); gi += 1 } - if gi < maxGroups { fanOut.append(groupReps[gi]); gi += 1 } - if li < ahead.count { fanOut.append(ahead[li]) } - if li < behind.count { fanOut.append(behind[li]) } - li += 1 - } - let window = [photo] + fanOut + let window = session.previewWindow() cache.preloadPreviews(photos: window) cache.evictPreviews(keeping: window) } @@ -332,22 +314,7 @@ struct PhotoViewer: View { displayImage = thumb displayQuality = "thumbnail" } - // Preload initial window: group reps prioritized over linear neighbors - let groupReps = session.adjacentGroupRepresentatives(groupCount: 15) - let ahead = session.photosAhead(lookaheadCount) - let behind = session.photosBehind(lookbehindCount) - var fanOut: [Photo] = [] - let maxLinear = max(ahead.count, behind.count) - let maxGroups = groupReps.count - var gi = 0, li = 0 - while gi < maxGroups || li < maxLinear { - if gi < maxGroups { fanOut.append(groupReps[gi]); gi += 1 } - if gi < maxGroups { fanOut.append(groupReps[gi]); gi += 1 } - if li < ahead.count { fanOut.append(ahead[li]) } - if li < behind.count { fanOut.append(behind[li]) } - li += 1 - } - cache.preloadPreviews(photos: [photo] + fanOut) + cache.preloadPreviews(photos: session.previewWindow()) } } }