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: use raw and fallback to jpg

+53 -26
+1 -1
cull/Services/PhotoImporter.swift
··· 62 62 63 63 // Read EXIF dates + image metadata sequentially (header-only reads are fast, ~1ms each) 64 64 for photo in photos { 65 - let dateURL = photo.pairedURL ?? photo.url 65 + let dateURL = photo.url 66 66 photo.captureDate = readCaptureDate(from: dateURL) 67 67 readImageMetadata(from: photo.url, into: photo) 68 68 if let pairedURL = photo.pairedURL {
+32 -5
cull/Services/QualityAnalyzer.swift
··· 5 5 6 6 struct QualityAnalyzer { 7 7 8 + /// For RAW files, find the best image index to analyze. 9 + /// RAW files embed JPEG previews (with camera sharpening) as secondary images. 10 + /// Returns (source, imageIndex) so the thumbnail API can extract from the right image. 11 + private static func sourceForAnalysis(_ url: URL) -> (CGImageSource, Int)? { 12 + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } 13 + 14 + let count = CGImageSourceGetCount(source) 15 + if count > 1 { 16 + // Find the largest embedded preview (usually a camera-processed JPEG) 17 + var bestIndex = 0 18 + var bestPixels = 0 19 + for i in 0..<count { 20 + if let props = CGImageSourceCopyPropertiesAtIndex(source, i, nil) as? [String: Any], 21 + let w = props[kCGImagePropertyPixelWidth as String] as? Int, 22 + let h = props[kCGImagePropertyPixelHeight as String] as? Int { 23 + let pixels = w * h 24 + if pixels > bestPixels { 25 + bestPixels = pixels 26 + bestIndex = i 27 + } 28 + } 29 + } 30 + return (source, bestIndex) 31 + } 32 + return (source, 0) 33 + } 34 + 8 35 /// Laplacian variance sharpness detection using Accelerate (vDSP). 9 36 /// Uses Apple's recommended 8-connected Laplacian kernel for better edge sensitivity. 10 37 static func analyzeBlur(imageURL: URL) async -> Double? { 11 - guard let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil) else { return nil } 38 + guard let (source, imageIndex) = sourceForAnalysis(imageURL) else { return nil } 12 39 let options: [CFString: Any] = [ 13 40 kCGImageSourceCreateThumbnailFromImageIfAbsent: true, 14 41 kCGImageSourceThumbnailMaxPixelSize: 512, 15 42 kCGImageSourceShouldCache: false, 16 43 kCGImageSourceCreateThumbnailWithTransform: true 17 44 ] 18 - guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil } 45 + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, imageIndex, options as CFDictionary) else { return nil } 19 46 20 47 // Read ISO for noise compensation 21 48 let iso = readISO(from: source) ··· 95 122 } 96 123 97 124 static func analyzeFaces(imageURL: URL) async -> FaceResult { 98 - guard let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil) else { 125 + guard let (source, imageIndex) = sourceForAnalysis(imageURL) else { 99 126 return FaceResult(sharpness: nil, regions: []) 100 127 } 101 128 let options: [CFString: Any] = [ ··· 104 131 kCGImageSourceShouldCache: false, 105 132 kCGImageSourceCreateThumbnailWithTransform: true 106 133 ] 107 - guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { 134 + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, imageIndex, options as CFDictionary) else { 108 135 return FaceResult(sharpness: nil, regions: []) 109 136 } 110 137 ··· 157 184 } 158 185 159 186 static func analyze(photo: Photo) async { 160 - let url = photo.pairedURL ?? photo.url 187 + let url = photo.url 161 188 async let blur = analyzeBlur(imageURL: url) 162 189 async let faces = analyzeFaces(imageURL: url) 163 190
+16 -16
cull/Services/ThumbnailCache.swift
··· 38 38 39 39 func thumbnail(for photo: Photo) async -> NSImage? { 40 40 let key = photo.url.absoluteString 41 - let sourceURL = photo.pairedURL ?? photo.url 41 + let url = photo.url 42 42 43 43 if let cached = memoryCache.object(forKey: key as NSString) { 44 44 return cached 45 45 } 46 46 47 - let diskPath = diskCacheURL.appendingPathComponent(stableDiskKey(for: photo.url)) 47 + let diskPath = diskCacheURL.appendingPathComponent(stableDiskKey(for: url)) 48 48 let pixelSize = maxPixelSize 49 49 50 50 let image: NSImage? = await Task.detached(priority: .userInitiated) { () -> NSImage? in 51 51 if let diskImage = NSImage(contentsOf: diskPath) { 52 52 return diskImage 53 53 } 54 - guard let extracted = Self.extractThumbnailSync(from: sourceURL, maxPixelSize: pixelSize) else { return nil } 54 + guard let extracted = Self.extractThumbnailSync(from: url, maxPixelSize: pixelSize) else { return nil } 55 55 Self.saveToDisk(extracted, at: diskPath) 56 56 return extracted 57 57 }.value ··· 69 69 return cached 70 70 } 71 71 72 - let url = photo.pairedURL ?? photo.url 72 + let url = photo.url 73 73 74 74 let image: NSImage? = await Task.detached(priority: .userInitiated) { () -> NSImage? in 75 75 Self.loadFullPreviewSync(from: url) ··· 89 89 photos: [Photo], 90 90 progress: (@Sendable (Double) async -> Void)? = nil 91 91 ) async { 92 - let thumbWork: [(String, URL, URL)] = photos.map { photo in 93 - (photo.url.absoluteString, photo.pairedURL ?? photo.url, photo.url) 92 + let thumbWork: [(String, URL)] = photos.map { photo in 93 + (photo.url.absoluteString, photo.url) 94 94 } 95 95 96 96 let totalItems = Double(thumbWork.count) ··· 104 104 let batchEnd = min(batchStart + batchSize, thumbWork.count) 105 105 let batch = Array(thumbWork[batchStart..<batchEnd]) 106 106 await withTaskGroup(of: (String, NSImage?).self) { group in 107 - for (key, sourceURL, photoURL) in batch { 108 - let diskPath = diskCache.appendingPathComponent(Self.stableDiskKey(for: photoURL)) 107 + for (key, url) in batch { 108 + let diskPath = diskCache.appendingPathComponent(Self.stableDiskKey(for: url)) 109 109 group.addTask { 110 110 if let diskImage = NSImage(contentsOf: diskPath) { 111 111 return (key, diskImage) 112 112 } 113 - guard let extracted = Self.extractThumbnailSync(from: sourceURL, maxPixelSize: pixelSize) else { 113 + guard let extracted = Self.extractThumbnailSync(from: url, maxPixelSize: pixelSize) else { 114 114 return (key, nil) 115 115 } 116 116 Self.saveToDisk(extracted, at: diskPath) ··· 131 131 } 132 132 133 133 func preload(photos: [Photo]) { 134 - let work: [(String, URL, URL)] = photos.compactMap { photo in 134 + let work: [(String, URL)] = photos.compactMap { photo in 135 135 let key = photo.url.absoluteString 136 136 guard memoryCache.object(forKey: key as NSString) == nil else { return nil } 137 - return (key, photo.pairedURL ?? photo.url, photo.url) 137 + return (key, photo.url) 138 138 } 139 139 guard !work.isEmpty else { return } 140 140 ··· 146 146 for batchStart in stride(from: 0, to: work.count, by: 8) { 147 147 let batch = Array(work[batchStart..<min(batchStart + 8, work.count)]) 148 148 await withTaskGroup(of: (String, NSImage?).self) { group in 149 - for (key, sourceURL, photoURL) in batch { 150 - let diskPath = diskCache.appendingPathComponent(Self.stableDiskKey(for: photoURL)) 149 + for (key, url) in batch { 150 + let diskPath = diskCache.appendingPathComponent(Self.stableDiskKey(for: url)) 151 151 group.addTask { 152 152 if let diskImage = NSImage(contentsOf: diskPath) { 153 153 return (key, diskImage) 154 154 } 155 - guard let extracted = Self.extractThumbnailSync(from: sourceURL, maxPixelSize: pixelSize) else { 155 + guard let extracted = Self.extractThumbnailSync(from: url, maxPixelSize: pixelSize) else { 156 156 return (key, nil) 157 157 } 158 158 Self.saveToDisk(extracted, at: diskPath) ··· 175 175 progress: (@Sendable (Double) async -> Void)? = nil 176 176 ) async { 177 177 let work: [(String, URL)] = photos.map { photo in 178 - (photo.url.absoluteString, photo.pairedURL ?? photo.url) 178 + (photo.url.absoluteString, photo.url) 179 179 } 180 180 181 181 let totalItems = Double(work.count) ··· 213 213 let work: [(String, URL)] = photos.compactMap { photo in 214 214 let key = photo.url.absoluteString 215 215 guard previewCache.object(forKey: key as NSString) == nil else { return nil } 216 - return (key, photo.pairedURL ?? photo.url) 216 + return (key, photo.url) 217 217 } 218 218 guard !work.isEmpty else { return } 219 219
+4 -4
cull/Views/ContentView.swift
··· 92 92 } 93 93 94 94 await withTaskGroup(of: Void.self) { parallelGroup in 95 - // Stream 1: Quality analysis (blur + faces) 95 + // Stream 1: Quality analysis (blur + faces) — low priority to not starve preview/thumbnail loading 96 96 parallelGroup.addTask { 97 97 var completed = 0.0 98 98 for batchStart in stride(from: 0, to: allPhotos.count, by: 8) { 99 99 let batch = Array(allPhotos[batchStart..<min(batchStart + 8, allPhotos.count)]) 100 100 await withTaskGroup(of: Void.self) { group in 101 101 for photo in batch { 102 - group.addTask { 102 + group.addTask(priority: .background) { 103 103 await QualityAnalyzer.analyze(photo: photo) 104 104 } 105 105 } ··· 110 110 } 111 111 } 112 112 113 - // Stream 2: Thumbnails 113 + // Stream 2: Thumbnails — high priority 114 114 parallelGroup.addTask { 115 115 await c.preloadAllThumbnails(photos: allPhotos) { p in 116 116 thumbProgress = p ··· 118 118 } 119 119 } 120 120 121 - // Stream 3: Initial full-res previews 121 + // Stream 3: Initial full-res previews — high priority 122 122 parallelGroup.addTask { 123 123 let ahead = Array(allPhotos.prefix(30)) 124 124 let behind = Array(allPhotos.suffix(30))