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 jpg src and add timestamp

+86 -45
+1 -1
cull/CullApp.swift
··· 6 6 @State private var thumbnailCache = ThumbnailCache() 7 7 8 8 var body: some Scene { 9 - WindowGroup { 9 + WindowGroup("Cull") { 10 10 ContentView() 11 11 .environment(session) 12 12 .environment(thumbnailCache)
+3
cull/Models/Photo.swift
··· 38 38 // Populated by ShotGrouper 39 39 var captureDate: Date? 40 40 41 + /// URL to use for image loading (thumbnails, previews) — prefers JPEG for decode speed 42 + var imageURL: URL { pairedURL ?? url } 43 + 41 44 var isRAW: Bool { 42 45 guard let utType = UTType(filenameExtension: url.pathExtension) else { return false } 43 46 return utType.conforms(to: .rawImage)
+48 -17
cull/Services/PhotoImporter.swift
··· 69 69 70 70 // Read EXIF dates + image metadata in parallel batches 71 71 let formatter = exifDateFormatter 72 - for batchStart in stride(from: 0, to: photos.count, by: 16) { 73 - let batch = Array(photos[batchStart..<min(batchStart + 16, photos.count)]) 74 - await withTaskGroup(of: Void.self) { group in 75 - for photo in batch { 72 + let metadataInputs: [(Int, URL, URL?)] = photos.enumerated().map { (i, photo) in 73 + (i, photo.url, photo.pairedURL) 74 + } 75 + 76 + for batchStart in stride(from: 0, to: metadataInputs.count, by: 16) { 77 + let batch = Array(metadataInputs[batchStart..<min(batchStart + 16, metadataInputs.count)]) 78 + let results = await withTaskGroup(of: (Int, PhotoMetadata).self, returning: [(Int, PhotoMetadata)].self) { group in 79 + for (index, url, pairedURL) in batch { 76 80 group.addTask { 77 - readAllMetadata(photo: photo, formatter: formatter) 81 + let meta = readAllMetadata(url: url, pairedURL: pairedURL, formatter: formatter) 82 + return (index, meta) 78 83 } 79 84 } 85 + var collected: [(Int, PhotoMetadata)] = [] 86 + for await result in group { 87 + collected.append(result) 88 + } 89 + return collected 90 + } 91 + // Apply on main actor 92 + for (index, meta) in results { 93 + let photo = photos[index] 94 + photo.captureDate = meta.captureDate 95 + photo.pixelWidth = meta.pixelWidth 96 + photo.pixelHeight = meta.pixelHeight 97 + photo.fileSize = meta.fileSize 98 + photo.pairedPixelWidth = meta.pairedPixelWidth 99 + photo.pairedPixelHeight = meta.pairedPixelHeight 100 + photo.pairedFileSize = meta.pairedFileSize 80 101 } 81 102 } 82 103 ··· 85 106 return ImportResult(photos: photos, paired: pairedCount) 86 107 } 87 108 109 + struct PhotoMetadata: Sendable { 110 + var captureDate: Date? 111 + var pixelWidth: Int = 0 112 + var pixelHeight: Int = 0 113 + var fileSize: Int64 = 0 114 + var pairedPixelWidth: Int = 0 115 + var pairedPixelHeight: Int = 0 116 + var pairedFileSize: Int64 = 0 117 + } 118 + 88 119 /// Read all metadata from a single CGImageSource open — date, dimensions, file size, paired metadata 89 - nonisolated private static func readAllMetadata(photo: Photo, formatter: DateFormatter) { 90 - let url = photo.url 120 + nonisolated private static func readAllMetadata(url: URL, pairedURL: URL?, formatter: DateFormatter) -> PhotoMetadata { 121 + var meta = PhotoMetadata() 91 122 92 123 // File size from filesystem 93 124 if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), 94 125 let size = attrs[.size] as? Int64 { 95 - photo.fileSize = size 126 + meta.fileSize = size 96 127 } 97 128 98 129 // Open image source once for date + dimensions 99 130 if let source = CGImageSourceCreateWithURL(url as CFURL, nil), 100 131 let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] { 101 - // Dimensions 102 132 if let width = properties[kCGImagePropertyPixelWidth as String] as? Int, 103 133 let height = properties[kCGImagePropertyPixelHeight as String] as? Int { 104 - photo.pixelWidth = width 105 - photo.pixelHeight = height 134 + meta.pixelWidth = width 135 + meta.pixelHeight = height 106 136 } 107 - // EXIF date 108 137 if let exif = properties[kCGImagePropertyExifDictionary as String] as? [String: Any], 109 138 let dateString = exif[kCGImagePropertyExifDateTimeOriginal as String] as? String { 110 - photo.captureDate = formatter.date(from: dateString) 139 + meta.captureDate = formatter.date(from: dateString) 111 140 } 112 141 } 113 142 114 143 // Paired file metadata 115 - if let pairedURL = photo.pairedURL { 144 + if let pairedURL { 116 145 if let attrs = try? FileManager.default.attributesOfItem(atPath: pairedURL.path), 117 146 let size = attrs[.size] as? Int64 { 118 - photo.pairedFileSize = size 147 + meta.pairedFileSize = size 119 148 } 120 149 if let source = CGImageSourceCreateWithURL(pairedURL as CFURL, nil), 121 150 let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any], 122 151 let width = properties[kCGImagePropertyPixelWidth as String] as? Int, 123 152 let height = properties[kCGImagePropertyPixelHeight as String] as? Int { 124 - photo.pairedPixelWidth = width 125 - photo.pairedPixelHeight = height 153 + meta.pairedPixelWidth = width 154 + meta.pairedPixelHeight = height 126 155 } 127 156 } 157 + 158 + return meta 128 159 } 129 160 130 161 static func isRAWExtension(_ ext: String) -> Bool {
+1 -1
cull/Services/QualityAnalyzer.swift
··· 184 184 } 185 185 186 186 static func analyze(photo: Photo) async { 187 - let url = photo.url 187 + let url = photo.imageURL 188 188 async let blur = analyzeBlur(imageURL: url) 189 189 async let faces = analyzeFaces(imageURL: url) 190 190
+3 -2
cull/Services/ShotGrouper.swift
··· 10 10 static let mergeTimeThreshold: TimeInterval = 5 11 11 12 12 /// Feature print distance threshold for visual similarity (Revision 2, macOS 14+) 13 - static let similarityThreshold: Float = 0.35 13 + /// Higher = more lenient grouping (different framings of same scene) 14 + static let similarityThreshold: Float = 0.6 14 15 15 16 /// Full grouping: temporal + visual similarity + merge close shots 16 17 static func group(photos: [Photo], progress: (@Sendable (Double) async -> Void)? = nil) async -> [PhotoGroup] { ··· 22 23 var completed = 0.0 23 24 24 25 // Step 2: Generate feature prints for all photos (batched to report smooth progress) 25 - let fpWork: [(UUID, URL)] = photos.map { ($0.id, $0.url) } 26 + let fpWork: [(UUID, URL)] = photos.map { ($0.id, $0.imageURL) } 26 27 var featurePrintMap: [UUID: VNFeaturePrintObservation] = [:] 27 28 let batchSize = 8 28 29 for batchStart in stride(from: 0, to: fpWork.count, by: batchSize) {
+22 -21
cull/Services/ThumbnailCache.swift
··· 38 38 39 39 func thumbnail(for photo: Photo) async -> NSImage? { 40 40 let key = photo.url.absoluteString 41 - let url = photo.url 41 + let loadURL = photo.imageURL 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: url)) 47 + let diskPath = diskCacheURL.appendingPathComponent(stableDiskKey(for: photo.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: url, maxPixelSize: pixelSize) else { return nil } 54 + guard let extracted = Self.extractThumbnailSync(from: loadURL, 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.url 72 + let loadURL = photo.imageURL 73 73 74 74 let image: NSImage? = await Task.detached(priority: .userInitiated) { () -> NSImage? in 75 - Self.loadFullPreviewSync(from: url) 75 + Self.loadFullPreviewSync(from: loadURL) 76 76 }.value 77 77 78 78 if let image { ··· 89 89 photos: [Photo], 90 90 progress: (@Sendable (Double) async -> Void)? = nil 91 91 ) async { 92 - let thumbWork: [(String, URL)] = photos.map { photo in 93 - (photo.url.absoluteString, photo.url) 92 + // key = RAW url for cache identity, loadURL = JPEG when available for fast decode 93 + let thumbWork: [(String, URL, URL)] = photos.map { photo in 94 + (photo.url.absoluteString, photo.imageURL, photo.url) 94 95 } 95 96 96 97 let totalItems = Double(thumbWork.count) ··· 104 105 let batchEnd = min(batchStart + batchSize, thumbWork.count) 105 106 let batch = Array(thumbWork[batchStart..<batchEnd]) 106 107 await withTaskGroup(of: (String, NSImage?).self) { group in 107 - for (key, url) in batch { 108 - let diskPath = diskCache.appendingPathComponent(Self.stableDiskKey(for: url)) 108 + for (key, loadURL, cacheKeyURL) in batch { 109 + let diskPath = diskCache.appendingPathComponent(Self.stableDiskKey(for: cacheKeyURL)) 109 110 group.addTask { 110 111 if let diskImage = NSImage(contentsOf: diskPath) { 111 112 return (key, diskImage) 112 113 } 113 - guard let extracted = Self.extractThumbnailSync(from: url, maxPixelSize: pixelSize) else { 114 + guard let extracted = Self.extractThumbnailSync(from: loadURL, maxPixelSize: pixelSize) else { 114 115 return (key, nil) 115 116 } 116 117 Self.saveToDisk(extracted, at: diskPath) ··· 131 132 } 132 133 133 134 func preload(photos: [Photo]) { 134 - let work: [(String, URL)] = photos.compactMap { photo in 135 + let work: [(String, URL, URL)] = photos.compactMap { photo in 135 136 let key = photo.url.absoluteString 136 137 guard memoryCache.object(forKey: key as NSString) == nil else { return nil } 137 - return (key, photo.url) 138 + return (key, photo.imageURL, photo.url) 138 139 } 139 140 guard !work.isEmpty else { return } 140 141 ··· 146 147 for batchStart in stride(from: 0, to: work.count, by: 8) { 147 148 let batch = Array(work[batchStart..<min(batchStart + 8, work.count)]) 148 149 await withTaskGroup(of: (String, NSImage?).self) { group in 149 - for (key, url) in batch { 150 - let diskPath = diskCache.appendingPathComponent(Self.stableDiskKey(for: url)) 150 + for (key, loadURL, cacheKeyURL) in batch { 151 + let diskPath = diskCache.appendingPathComponent(Self.stableDiskKey(for: cacheKeyURL)) 151 152 group.addTask { 152 153 if let diskImage = NSImage(contentsOf: diskPath) { 153 154 return (key, diskImage) 154 155 } 155 - guard let extracted = Self.extractThumbnailSync(from: url, maxPixelSize: pixelSize) else { 156 + guard let extracted = Self.extractThumbnailSync(from: loadURL, maxPixelSize: pixelSize) else { 156 157 return (key, nil) 157 158 } 158 159 Self.saveToDisk(extracted, at: diskPath) ··· 175 176 progress: (@Sendable (Double) async -> Void)? = nil 176 177 ) async { 177 178 let work: [(String, URL)] = photos.map { photo in 178 - (photo.url.absoluteString, photo.url) 179 + (photo.url.absoluteString, photo.imageURL) 179 180 } 180 181 181 182 let totalItems = Double(work.count) ··· 186 187 for batchStart in stride(from: 0, to: work.count, by: batchSize) { 187 188 let batch = Array(work[batchStart..<min(batchStart + batchSize, work.count)]) 188 189 await withTaskGroup(of: (String, NSImage?).self) { group in 189 - for (key, url) in batch { 190 + for (key, loadURL) in batch { 190 191 group.addTask { 191 - (key, Self.loadFullPreviewSync(from: url)) 192 + (key, Self.loadFullPreviewSync(from: loadURL)) 192 193 } 193 194 } 194 195 for await (key, image) in group { ··· 213 214 let work: [(String, URL)] = photos.compactMap { photo in 214 215 let key = photo.url.absoluteString 215 216 guard previewCache.object(forKey: key as NSString) == nil else { return nil } 216 - return (key, photo.url) 217 + return (key, photo.imageURL) 217 218 } 218 219 guard !work.isEmpty else { return } 219 220 ··· 224 225 guard !Task.isCancelled else { return } 225 226 let batch = Array(work[batchStart..<min(batchStart + 4, work.count)]) 226 227 await withTaskGroup(of: (String, NSImage?).self) { group in 227 - for (key, url) in batch { 228 + for (key, loadURL) in batch { 228 229 group.addTask { 229 230 guard !Task.isCancelled else { return (key, nil) } 230 - return (key, Self.loadFullPreviewSync(from: url)) 231 + return (key, Self.loadFullPreviewSync(from: loadURL)) 231 232 } 232 233 } 233 234 for await (key, image) in group {
+8 -3
cull/Views/PhotoViewer.swift
··· 137 137 138 138 Spacer() 139 139 140 - Text(photo.url.lastPathComponent) 141 - .foregroundStyle(.white.opacity(0.6)) 142 - .font(.caption) 140 + HStack(spacing: 6) { 141 + Text(photo.url.lastPathComponent) 142 + if let date = photo.captureDate { 143 + Text(date, format: .dateTime.month().day().year().hour().minute().second()) 144 + } 145 + } 146 + .foregroundStyle(.white.opacity(0.6)) 147 + .font(.caption) 143 148 } 144 149 .padding(.horizontal, 16) 145 150 .padding(.vertical, 10)