diff --git a/cull/CullApp.swift b/cull/CullApp.swift index 8403f96..21efc96 100644 --- a/cull/CullApp.swift +++ b/cull/CullApp.swift @@ -122,6 +122,13 @@ struct CullApp: App { } .keyboardShortcut(.space, modifiers: []) .disabled(session.selectedPhoto == nil) + + Divider() + + Toggle("Debug Cache Overlay", isOn: Binding( + get: { session.debugCacheOverlay }, + set: { session.debugCacheOverlay = $0 } + )) } // Navigate menu diff --git a/cull/Models/CullSession.swift b/cull/Models/CullSession.swift index c21db35..0b9e099 100644 --- a/cull/Models/CullSession.swift +++ b/cull/Models/CullSession.swift @@ -16,6 +16,7 @@ final class CullSession { var importProgress: Double = 0 var importStatus: String = "" + var debugCacheOverlay: Bool = false var undoManager: UndoManager? var workspace: WorkspaceDB? private var saveTask: Task? diff --git a/cull/Services/ThumbnailCache.swift b/cull/Services/ThumbnailCache.swift index 729e50b..55771e9 100644 --- a/cull/Services/ThumbnailCache.swift +++ b/cull/Services/ThumbnailCache.swift @@ -6,8 +6,11 @@ import ImageIO final class ThumbnailCache { private let memoryCache = NSCache() private let previewCache = NSCache() + private var thumbnailKeys = Set() private var previewKeys = Set() private var preloadTask: Task? + /// Bumped whenever cache state changes, to trigger SwiftUI re-renders for debug overlay + private(set) var cacheGeneration = 0 private let diskCacheURL: URL private let maxPixelSize: Int @@ -19,7 +22,7 @@ final class ThumbnailCache { memoryCache.countLimit = 500 memoryCache.totalCostLimit = 100 * 1024 * 1024 // 100 MB - previewCache.countLimit = 70 + previewCache.countLimit = 120 try? FileManager.default.createDirectory(at: diskCacheURL, withIntermediateDirectories: true) } @@ -58,6 +61,7 @@ final class ThumbnailCache { if let image { memoryCache.setObject(image, forKey: key as NSString) + thumbnailKeys.insert(key) } return image } @@ -78,6 +82,7 @@ final class ThumbnailCache { if let image { previewCache.setObject(image, forKey: key as NSString) previewKeys.insert(key) + cacheGeneration += 1 } return image } @@ -118,15 +123,19 @@ final class ThumbnailCache { return (key, extracted) } } + var batchKeys: [String] = [] for await (key, image) in group { if let image { mc.setObject(image, forKey: key as NSString) + batchKeys.append(key) } completed += 1 if let progress { await progress(completed / totalItems) } } + for k in batchKeys { thumbnailKeys.insert(k) } + if !batchKeys.isEmpty { cacheGeneration += 1 } } } } @@ -197,6 +206,7 @@ final class ThumbnailCache { if let image { pc.setObject(image, forKey: key as NSString) previewKeys.insert(key) + cacheGeneration += 1 } completed += 1 if let progress { @@ -208,7 +218,8 @@ final class ThumbnailCache { } /// Fire-and-forget: preload previews in background. Used during navigation. - /// Cancels any previous preload so stale work doesn't compete. + /// Cancels previous preload to prevent cache thrashing from multiple + /// concurrent windows competing for the 70-entry preview cache. func preloadPreviews(photos: [Photo]) { preloadTask?.cancel() @@ -246,6 +257,7 @@ final class ThumbnailCache { for key in batchKeys { self.previewKeys.insert(key) } + self.cacheGeneration += 1 } } } @@ -261,6 +273,24 @@ final class ThumbnailCache { } } + // MARK: - Stats + + struct CacheStats { + let thumbnailCount: Int + let previewCount: Int + let previewLimit: Int + let thumbnailLimit: Int + } + + func stats() -> CacheStats { + CacheStats( + thumbnailCount: thumbnailKeys.count, + previewCount: previewKeys.count, + previewLimit: previewCache.countLimit, + thumbnailLimit: memoryCache.countLimit + ) + } + // MARK: - Sync image extraction nonisolated private static func extractThumbnailSync(from url: URL, maxPixelSize: Int) -> NSImage? { @@ -314,6 +344,8 @@ final class ThumbnailCache { func clearCache() { memoryCache.removeAllObjects() previewCache.removeAllObjects() + thumbnailKeys.removeAll() + previewKeys.removeAll() try? FileManager.default.removeItem(at: diskCacheURL) try? FileManager.default.createDirectory(at: diskCacheURL, withIntermediateDirectories: true) } diff --git a/cull/Views/ContentView.swift b/cull/Views/ContentView.swift index 1a681ba..4fa892b 100644 --- a/cull/Views/ContentView.swift +++ b/cull/Views/ContentView.swift @@ -172,8 +172,9 @@ struct ContentView: View { } } + // Analysis + Thumbnails in parallel (previews loaded after sorting) await withTaskGroup(of: Void.self) { parallelGroup in - // Stream 1: Quality analysis (blur + faces) — low priority to not starve preview/thumbnail loading + // Stream 1: Quality analysis (blur + faces) — low priority parallelGroup.addTask { var completed = 0.0 for batchStart in stride(from: 0, to: allPhotos.count, by: 8) { @@ -191,24 +192,13 @@ struct ContentView: View { } } - // Stream 2: Thumbnails — high priority + // Stream 2: Thumbnails parallelGroup.addTask { await c.preloadAllThumbnails(photos: allPhotos) { p in thumbProgress = p await reportProgress() } } - - // Stream 3: Initial full-res previews — high priority - parallelGroup.addTask { - let ahead = Array(allPhotos.prefix(30)) - let behind = Array(allPhotos.suffix(30)) - let initialPreviews = ahead + behind.reversed() - await c.preloadAllPreviews(photos: initialPreviews) { p in - previewProgress = p - await reportProgress() - } - } } // Rank photos within each group — best first (after analysis completes) @@ -217,6 +207,16 @@ struct ContentView: View { group.photos = scored.sorted { $0.score > $1.score }.map(\.photo) } + // Preload previews in sorted order so they match browse order + let sortedPhotos = groups.flatMap(\.photos) + let ahead = Array(sortedPhotos.prefix(30)) + let behind = Array(sortedPhotos.suffix(30)) + let initialPreviews = ahead + behind.reversed() + await c.preloadAllPreviews(photos: initialPreviews) { p in + previewProgress = p + await reportProgress() + } + await MainActor.run { s.importProgress = 1.0 s.groups = groups diff --git a/cull/Views/GroupDetailView.swift b/cull/Views/GroupDetailView.swift index 8101adb..733acc6 100644 --- a/cull/Views/GroupDetailView.swift +++ b/cull/Views/GroupDetailView.swift @@ -41,6 +41,7 @@ private struct PhotoThumbnail: View { let photo: Photo let group: PhotoGroup let isSelected: Bool + @Environment(CullSession.self) private var session @Environment(ThumbnailCache.self) private var cache @State private var thumbnail: NSImage? @@ -124,6 +125,18 @@ private struct PhotoThumbnail: View { RoundedRectangle(cornerRadius: 6) .strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2) } + .overlay(alignment: .topLeading) { + if session.debugCacheOverlay { + let _ = cache.cacheGeneration + let hasPreview = cache.cachedPreview(for: photo) != nil + let hasThumb = cache.cachedThumbnail(for: photo) != nil + let debugColor: Color = hasPreview ? .green : (hasThumb ? .yellow : .red) + Circle() + .fill(debugColor) + .frame(width: 6, height: 6) + .padding(4) + } + } .opacity(photo.flag == .reject ? 0.5 : 1.0) .onAppear { if let cached = cache.cachedThumbnail(for: photo) { diff --git a/cull/Views/GroupListView.swift b/cull/Views/GroupListView.swift index 61c3b91..0824c62 100644 --- a/cull/Views/GroupListView.swift +++ b/cull/Views/GroupListView.swift @@ -41,6 +41,7 @@ private struct GroupThumbnail: View { let index: Int let isSelected: Bool let visibleCount: Int + @Environment(CullSession.self) private var session @Environment(ThumbnailCache.self) private var cache @State private var thumbnail: NSImage? @@ -70,6 +71,18 @@ private struct GroupThumbnail: View { RoundedRectangle(cornerRadius: 6) .strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2) } + .overlay(alignment: .topLeading) { + if session.debugCacheOverlay { + let _ = cache.cacheGeneration + let allCached = group.photos.allSatisfy { cache.cachedPreview(for: $0) != nil } + let anyCached = group.photos.contains { cache.cachedPreview(for: $0) != nil } + let debugColor: Color = allCached ? .green : (anyCached ? .yellow : .red) + Circle() + .fill(debugColor) + .frame(width: 6, height: 6) + .padding(4) + } + } .onAppear { guard let photo = group.representativePhoto else { return } if let cached = cache.cachedThumbnail(for: photo) { diff --git a/cull/Views/PhotoViewer.swift b/cull/Views/PhotoViewer.swift index 6ed5c46..7e3c88f 100644 --- a/cull/Views/PhotoViewer.swift +++ b/cull/Views/PhotoViewer.swift @@ -5,6 +5,8 @@ struct PhotoViewer: View { @Environment(ThumbnailCache.self) private var cache @State private var displayImage: NSImage? @State private var displayedPhotoID: UUID? + /// 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 @@ -40,6 +42,90 @@ struct PhotoViewer: View { } } + // Debug cache overlay + if session.debugCacheOverlay, let photo = session.selectedPhoto { + let _ = cache.cacheGeneration // observe changes + let s = cache.stats() + let allPhotos = session.allPhotos + let currentFlatIndex = allPhotos.firstIndex(where: { $0.id == photo.id }) + + HStack(alignment: .top, spacing: 0) { + Spacer() + + // Stats panel + VStack(alignment: .leading, spacing: 3) { + let hasPreview = cache.cachedPreview(for: photo) != nil + let hasThumb = cache.cachedThumbnail(for: photo) != nil + + Text("Current Photo") + .fontWeight(.semibold) + HStack(spacing: 4) { + Circle().fill(hasPreview ? .green : .red).frame(width: 8, height: 8) + Text("Preview") + } + HStack(spacing: 4) { + Circle().fill(hasThumb ? .green : .red).frame(width: 8, height: 8) + Text("Thumbnail") + } + Text("Displaying: \(displayQuality)") + + Divider().overlay(Color.white.opacity(0.3)) + + Text("Cache") + .fontWeight(.semibold) + Text("Thumbs: \(s.thumbnailCount)/\(s.thumbnailLimit)") + Text("Previews: \(s.previewCount)/\(s.previewLimit)") + + Divider().overlay(Color.white.opacity(0.3)) + + Text("Session") + .fontWeight(.semibold) + Text("Groups: \(session.groups.count)") + Text("Photos: \(allPhotos.count)") + } + .font(.caption2) + .foregroundStyle(.white) + .padding(8) + .background(.black.opacity(0.7), in: RoundedRectangle(cornerRadius: 6)) + + // Cache minimap — precompute states so SwiftUI can diff + let cacheStates: [Int] = allPhotos.map { p in + if cache.cachedPreview(for: p) != nil { return 2 } + if cache.cachedThumbnail(for: p) != nil { return 1 } + return 0 + } + + GeometryReader { geo in + let totalPhotos = cacheStates.count + let height = geo.size.height - 16 + let rowH = totalPhotos > 0 ? max(height / CGFloat(totalPhotos), 1) : 1 + + Canvas { context, size in + let colors: [Color] = [.red, .yellow, .green] + for (i, state) in cacheStates.enumerated() { + let y = 8 + CGFloat(i) * rowH + context.fill( + Path(CGRect(x: 0, y: y, width: size.width, height: max(rowH - 0.5, 0.5))), + with: .color(colors[state].opacity(0.8)) + ) + } + + if let idx = currentFlatIndex { + let y = 8 + CGFloat(idx) * rowH + context.fill( + Path(CGRect(x: -2, y: y - 1, width: size.width + 4, height: max(rowH + 2, 3))), + with: .color(.white) + ) + } + } + .frame(width: 14) + } + .frame(width: 14) + .background(.black.opacity(0.5), in: RoundedRectangle(cornerRadius: 3)) + } + .padding(8) + } + // Bottom bar overlay if let photo = session.selectedPhoto { VStack { @@ -173,8 +259,12 @@ struct PhotoViewer: View { // Instant: show whatever we have cached synchronously if let cached = cache.cachedPreview(for: photo) { displayImage = cached + displayQuality = "preview" } else if let thumb = cache.cachedThumbnail(for: photo) { displayImage = thumb + displayQuality = "thumbnail" + } else { + displayQuality = "none" } } .task(id: session.selectedPhoto?.id) { @@ -184,6 +274,7 @@ struct PhotoViewer: View { // If full-res is already cached, show it immediately if let cached = cache.cachedPreview(for: photo) { displayImage = cached + displayQuality = "preview" } // Wait for user to stop navigating before doing any loading @@ -195,6 +286,7 @@ struct PhotoViewer: View { if let full = await cache.previewImage(for: photo) { guard displayedPhotoID == photoID else { return } displayImage = full + displayQuality = "preview" } } @@ -217,8 +309,10 @@ struct PhotoViewer: View { displayedPhotoID = photo.id if let cached = cache.cachedPreview(for: photo) { displayImage = cached + displayQuality = "preview" } else if let thumb = cache.cachedThumbnail(for: photo) { displayImage = thumb + displayQuality = "thumbnail" } // Preload initial window let ahead = session.photosAhead(lookaheadCount)