its whats on the tin; culls raw photos
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add cache diagnostics

+175 -15
+7
cull/CullApp.swift
··· 122 122 } 123 123 .keyboardShortcut(.space, modifiers: []) 124 124 .disabled(session.selectedPhoto == nil) 125 + 126 + Divider() 127 + 128 + Toggle("Debug Cache Overlay", isOn: Binding( 129 + get: { session.debugCacheOverlay }, 130 + set: { session.debugCacheOverlay = $0 } 131 + )) 125 132 } 126 133 127 134 // Navigate menu
+1
cull/Models/CullSession.swift
··· 16 16 var importProgress: Double = 0 17 17 var importStatus: String = "" 18 18 19 + var debugCacheOverlay: Bool = false 19 20 var undoManager: UndoManager? 20 21 var workspace: WorkspaceDB? 21 22 private var saveTask: Task<Void, Never>?
+34 -2
cull/Services/ThumbnailCache.swift
··· 6 6 final class ThumbnailCache { 7 7 private let memoryCache = NSCache<NSString, NSImage>() 8 8 private let previewCache = NSCache<NSString, NSImage>() 9 + private var thumbnailKeys = Set<String>() 9 10 private var previewKeys = Set<String>() 10 11 private var preloadTask: Task<Void, Never>? 12 + /// Bumped whenever cache state changes, to trigger SwiftUI re-renders for debug overlay 13 + private(set) var cacheGeneration = 0 11 14 private let diskCacheURL: URL 12 15 private let maxPixelSize: Int 13 16 ··· 19 22 memoryCache.countLimit = 500 20 23 memoryCache.totalCostLimit = 100 * 1024 * 1024 // 100 MB 21 24 22 - previewCache.countLimit = 70 25 + previewCache.countLimit = 120 23 26 24 27 try? FileManager.default.createDirectory(at: diskCacheURL, withIntermediateDirectories: true) 25 28 } ··· 58 61 59 62 if let image { 60 63 memoryCache.setObject(image, forKey: key as NSString) 64 + thumbnailKeys.insert(key) 61 65 } 62 66 return image 63 67 } ··· 78 82 if let image { 79 83 previewCache.setObject(image, forKey: key as NSString) 80 84 previewKeys.insert(key) 85 + cacheGeneration += 1 81 86 } 82 87 return image 83 88 } ··· 118 123 return (key, extracted) 119 124 } 120 125 } 126 + var batchKeys: [String] = [] 121 127 for await (key, image) in group { 122 128 if let image { 123 129 mc.setObject(image, forKey: key as NSString) 130 + batchKeys.append(key) 124 131 } 125 132 completed += 1 126 133 if let progress { 127 134 await progress(completed / totalItems) 128 135 } 129 136 } 137 + for k in batchKeys { thumbnailKeys.insert(k) } 138 + if !batchKeys.isEmpty { cacheGeneration += 1 } 130 139 } 131 140 } 132 141 } ··· 197 206 if let image { 198 207 pc.setObject(image, forKey: key as NSString) 199 208 previewKeys.insert(key) 209 + cacheGeneration += 1 200 210 } 201 211 completed += 1 202 212 if let progress { ··· 208 218 } 209 219 210 220 /// Fire-and-forget: preload previews in background. Used during navigation. 211 - /// Cancels any previous preload so stale work doesn't compete. 221 + /// Cancels previous preload to prevent cache thrashing from multiple 222 + /// concurrent windows competing for the 70-entry preview cache. 212 223 func preloadPreviews(photos: [Photo]) { 213 224 preloadTask?.cancel() 214 225 ··· 246 257 for key in batchKeys { 247 258 self.previewKeys.insert(key) 248 259 } 260 + self.cacheGeneration += 1 249 261 } 250 262 } 251 263 } ··· 261 273 } 262 274 } 263 275 276 + // MARK: - Stats 277 + 278 + struct CacheStats { 279 + let thumbnailCount: Int 280 + let previewCount: Int 281 + let previewLimit: Int 282 + let thumbnailLimit: Int 283 + } 284 + 285 + func stats() -> CacheStats { 286 + CacheStats( 287 + thumbnailCount: thumbnailKeys.count, 288 + previewCount: previewKeys.count, 289 + previewLimit: previewCache.countLimit, 290 + thumbnailLimit: memoryCache.countLimit 291 + ) 292 + } 293 + 264 294 // MARK: - Sync image extraction 265 295 266 296 nonisolated private static func extractThumbnailSync(from url: URL, maxPixelSize: Int) -> NSImage? { ··· 314 344 func clearCache() { 315 345 memoryCache.removeAllObjects() 316 346 previewCache.removeAllObjects() 347 + thumbnailKeys.removeAll() 348 + previewKeys.removeAll() 317 349 try? FileManager.default.removeItem(at: diskCacheURL) 318 350 try? FileManager.default.createDirectory(at: diskCacheURL, withIntermediateDirectories: true) 319 351 }
+13 -13
cull/Views/ContentView.swift
··· 172 172 } 173 173 } 174 174 175 + // Analysis + Thumbnails in parallel (previews loaded after sorting) 175 176 await withTaskGroup(of: Void.self) { parallelGroup in 176 - // Stream 1: Quality analysis (blur + faces) — low priority to not starve preview/thumbnail loading 177 + // Stream 1: Quality analysis (blur + faces) — low priority 177 178 parallelGroup.addTask { 178 179 var completed = 0.0 179 180 for batchStart in stride(from: 0, to: allPhotos.count, by: 8) { ··· 191 192 } 192 193 } 193 194 194 - // Stream 2: Thumbnails — high priority 195 + // Stream 2: Thumbnails 195 196 parallelGroup.addTask { 196 197 await c.preloadAllThumbnails(photos: allPhotos) { p in 197 198 thumbProgress = p 198 199 await reportProgress() 199 200 } 200 201 } 201 - 202 - // Stream 3: Initial full-res previews — high priority 203 - parallelGroup.addTask { 204 - let ahead = Array(allPhotos.prefix(30)) 205 - let behind = Array(allPhotos.suffix(30)) 206 - let initialPreviews = ahead + behind.reversed() 207 - await c.preloadAllPreviews(photos: initialPreviews) { p in 208 - previewProgress = p 209 - await reportProgress() 210 - } 211 - } 212 202 } 213 203 214 204 // Rank photos within each group — best first (after analysis completes) 215 205 for group in groups { 216 206 let scored = group.photos.map { (photo: $0, score: Self.qualityScore($0, in: group)) } 217 207 group.photos = scored.sorted { $0.score > $1.score }.map(\.photo) 208 + } 209 + 210 + // Preload previews in sorted order so they match browse order 211 + let sortedPhotos = groups.flatMap(\.photos) 212 + let ahead = Array(sortedPhotos.prefix(30)) 213 + let behind = Array(sortedPhotos.suffix(30)) 214 + let initialPreviews = ahead + behind.reversed() 215 + await c.preloadAllPreviews(photos: initialPreviews) { p in 216 + previewProgress = p 217 + await reportProgress() 218 218 } 219 219 220 220 await MainActor.run {
+13
cull/Views/GroupDetailView.swift
··· 41 41 let photo: Photo 42 42 let group: PhotoGroup 43 43 let isSelected: Bool 44 + @Environment(CullSession.self) private var session 44 45 @Environment(ThumbnailCache.self) private var cache 45 46 @State private var thumbnail: NSImage? 46 47 ··· 123 124 .overlay { 124 125 RoundedRectangle(cornerRadius: 6) 125 126 .strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2) 127 + } 128 + .overlay(alignment: .topLeading) { 129 + if session.debugCacheOverlay { 130 + let _ = cache.cacheGeneration 131 + let hasPreview = cache.cachedPreview(for: photo) != nil 132 + let hasThumb = cache.cachedThumbnail(for: photo) != nil 133 + let debugColor: Color = hasPreview ? .green : (hasThumb ? .yellow : .red) 134 + Circle() 135 + .fill(debugColor) 136 + .frame(width: 6, height: 6) 137 + .padding(4) 138 + } 126 139 } 127 140 .opacity(photo.flag == .reject ? 0.5 : 1.0) 128 141 .onAppear {
+13
cull/Views/GroupListView.swift
··· 41 41 let index: Int 42 42 let isSelected: Bool 43 43 let visibleCount: Int 44 + @Environment(CullSession.self) private var session 44 45 @Environment(ThumbnailCache.self) private var cache 45 46 @State private var thumbnail: NSImage? 46 47 ··· 69 70 .overlay { 70 71 RoundedRectangle(cornerRadius: 6) 71 72 .strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2) 73 + } 74 + .overlay(alignment: .topLeading) { 75 + if session.debugCacheOverlay { 76 + let _ = cache.cacheGeneration 77 + let allCached = group.photos.allSatisfy { cache.cachedPreview(for: $0) != nil } 78 + let anyCached = group.photos.contains { cache.cachedPreview(for: $0) != nil } 79 + let debugColor: Color = allCached ? .green : (anyCached ? .yellow : .red) 80 + Circle() 81 + .fill(debugColor) 82 + .frame(width: 6, height: 6) 83 + .padding(4) 84 + } 72 85 } 73 86 .onAppear { 74 87 guard let photo = group.representativePhoto else { return }
+94
cull/Views/PhotoViewer.swift
··· 5 5 @Environment(ThumbnailCache.self) private var cache 6 6 @State private var displayImage: NSImage? 7 7 @State private var displayedPhotoID: UUID? 8 + /// Tracks what quality level is currently displayed: "preview", "thumbnail", or "none" 9 + @State private var displayQuality: String = "none" 8 10 9 11 private let lookaheadCount = 30 10 12 private let lookbehindCount = 30 ··· 40 42 } 41 43 } 42 44 45 + // Debug cache overlay 46 + if session.debugCacheOverlay, let photo = session.selectedPhoto { 47 + let _ = cache.cacheGeneration // observe changes 48 + let s = cache.stats() 49 + let allPhotos = session.allPhotos 50 + let currentFlatIndex = allPhotos.firstIndex(where: { $0.id == photo.id }) 51 + 52 + HStack(alignment: .top, spacing: 0) { 53 + Spacer() 54 + 55 + // Stats panel 56 + VStack(alignment: .leading, spacing: 3) { 57 + let hasPreview = cache.cachedPreview(for: photo) != nil 58 + let hasThumb = cache.cachedThumbnail(for: photo) != nil 59 + 60 + Text("Current Photo") 61 + .fontWeight(.semibold) 62 + HStack(spacing: 4) { 63 + Circle().fill(hasPreview ? .green : .red).frame(width: 8, height: 8) 64 + Text("Preview") 65 + } 66 + HStack(spacing: 4) { 67 + Circle().fill(hasThumb ? .green : .red).frame(width: 8, height: 8) 68 + Text("Thumbnail") 69 + } 70 + Text("Displaying: \(displayQuality)") 71 + 72 + Divider().overlay(Color.white.opacity(0.3)) 73 + 74 + Text("Cache") 75 + .fontWeight(.semibold) 76 + Text("Thumbs: \(s.thumbnailCount)/\(s.thumbnailLimit)") 77 + Text("Previews: \(s.previewCount)/\(s.previewLimit)") 78 + 79 + Divider().overlay(Color.white.opacity(0.3)) 80 + 81 + Text("Session") 82 + .fontWeight(.semibold) 83 + Text("Groups: \(session.groups.count)") 84 + Text("Photos: \(allPhotos.count)") 85 + } 86 + .font(.caption2) 87 + .foregroundStyle(.white) 88 + .padding(8) 89 + .background(.black.opacity(0.7), in: RoundedRectangle(cornerRadius: 6)) 90 + 91 + // Cache minimap — precompute states so SwiftUI can diff 92 + let cacheStates: [Int] = allPhotos.map { p in 93 + if cache.cachedPreview(for: p) != nil { return 2 } 94 + if cache.cachedThumbnail(for: p) != nil { return 1 } 95 + return 0 96 + } 97 + 98 + GeometryReader { geo in 99 + let totalPhotos = cacheStates.count 100 + let height = geo.size.height - 16 101 + let rowH = totalPhotos > 0 ? max(height / CGFloat(totalPhotos), 1) : 1 102 + 103 + Canvas { context, size in 104 + let colors: [Color] = [.red, .yellow, .green] 105 + for (i, state) in cacheStates.enumerated() { 106 + let y = 8 + CGFloat(i) * rowH 107 + context.fill( 108 + Path(CGRect(x: 0, y: y, width: size.width, height: max(rowH - 0.5, 0.5))), 109 + with: .color(colors[state].opacity(0.8)) 110 + ) 111 + } 112 + 113 + if let idx = currentFlatIndex { 114 + let y = 8 + CGFloat(idx) * rowH 115 + context.fill( 116 + Path(CGRect(x: -2, y: y - 1, width: size.width + 4, height: max(rowH + 2, 3))), 117 + with: .color(.white) 118 + ) 119 + } 120 + } 121 + .frame(width: 14) 122 + } 123 + .frame(width: 14) 124 + .background(.black.opacity(0.5), in: RoundedRectangle(cornerRadius: 3)) 125 + } 126 + .padding(8) 127 + } 128 + 43 129 // Bottom bar overlay 44 130 if let photo = session.selectedPhoto { 45 131 VStack { ··· 173 259 // Instant: show whatever we have cached synchronously 174 260 if let cached = cache.cachedPreview(for: photo) { 175 261 displayImage = cached 262 + displayQuality = "preview" 176 263 } else if let thumb = cache.cachedThumbnail(for: photo) { 177 264 displayImage = thumb 265 + displayQuality = "thumbnail" 266 + } else { 267 + displayQuality = "none" 178 268 } 179 269 } 180 270 .task(id: session.selectedPhoto?.id) { ··· 184 274 // If full-res is already cached, show it immediately 185 275 if let cached = cache.cachedPreview(for: photo) { 186 276 displayImage = cached 277 + displayQuality = "preview" 187 278 } 188 279 189 280 // Wait for user to stop navigating before doing any loading ··· 195 286 if let full = await cache.previewImage(for: photo) { 196 287 guard displayedPhotoID == photoID else { return } 197 288 displayImage = full 289 + displayQuality = "preview" 198 290 } 199 291 } 200 292 ··· 217 309 displayedPhotoID = photo.id 218 310 if let cached = cache.cachedPreview(for: photo) { 219 311 displayImage = cached 312 + displayQuality = "preview" 220 313 } else if let thumb = cache.cachedThumbnail(for: photo) { 221 314 displayImage = thumb 315 + displayQuality = "thumbnail" 222 316 } 223 317 // Preload initial window 224 318 let ahead = session.photosAhead(lookaheadCount)