diff --git a/cull/CullApp.swift b/cull/CullApp.swift index 7e1324b..e911e8e 100644 --- a/cull/CullApp.swift +++ b/cull/CullApp.swift @@ -6,7 +6,7 @@ struct CullApp: App { @State private var thumbnailCache = ThumbnailCache() var body: some Scene { - WindowGroup { + WindowGroup("Cull") { ContentView() .environment(session) .environment(thumbnailCache) diff --git a/cull/Models/Photo.swift b/cull/Models/Photo.swift index 7907ef2..ae28246 100644 --- a/cull/Models/Photo.swift +++ b/cull/Models/Photo.swift @@ -38,6 +38,9 @@ final class Photo: Identifiable { // Populated by ShotGrouper var captureDate: Date? + /// URL to use for image loading (thumbnails, previews) — prefers JPEG for decode speed + var imageURL: URL { pairedURL ?? url } + var isRAW: Bool { guard let utType = UTType(filenameExtension: url.pathExtension) else { return false } return utType.conforms(to: .rawImage) diff --git a/cull/Services/PhotoImporter.swift b/cull/Services/PhotoImporter.swift index a50be4c..bd562dc 100644 --- a/cull/Services/PhotoImporter.swift +++ b/cull/Services/PhotoImporter.swift @@ -69,14 +69,35 @@ struct PhotoImporter { // Read EXIF dates + image metadata in parallel batches let formatter = exifDateFormatter - for batchStart in stride(from: 0, to: photos.count, by: 16) { - let batch = Array(photos[batchStart.. PhotoMetadata { + var meta = PhotoMetadata() // File size from filesystem if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), let size = attrs[.size] as? Int64 { - photo.fileSize = size + meta.fileSize = size } // Open image source once for date + dimensions if let source = CGImageSourceCreateWithURL(url as CFURL, nil), let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] { - // Dimensions if let width = properties[kCGImagePropertyPixelWidth as String] as? Int, let height = properties[kCGImagePropertyPixelHeight as String] as? Int { - photo.pixelWidth = width - photo.pixelHeight = height + meta.pixelWidth = width + meta.pixelHeight = height } - // EXIF date if let exif = properties[kCGImagePropertyExifDictionary as String] as? [String: Any], let dateString = exif[kCGImagePropertyExifDateTimeOriginal as String] as? String { - photo.captureDate = formatter.date(from: dateString) + meta.captureDate = formatter.date(from: dateString) } } // Paired file metadata - if let pairedURL = photo.pairedURL { + if let pairedURL { if let attrs = try? FileManager.default.attributesOfItem(atPath: pairedURL.path), let size = attrs[.size] as? Int64 { - photo.pairedFileSize = size + meta.pairedFileSize = size } if let source = CGImageSourceCreateWithURL(pairedURL as CFURL, nil), let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any], let width = properties[kCGImagePropertyPixelWidth as String] as? Int, let height = properties[kCGImagePropertyPixelHeight as String] as? Int { - photo.pairedPixelWidth = width - photo.pairedPixelHeight = height + meta.pairedPixelWidth = width + meta.pairedPixelHeight = height } } + + return meta } static func isRAWExtension(_ ext: String) -> Bool { diff --git a/cull/Services/QualityAnalyzer.swift b/cull/Services/QualityAnalyzer.swift index cec2da0..99ae4e4 100644 --- a/cull/Services/QualityAnalyzer.swift +++ b/cull/Services/QualityAnalyzer.swift @@ -184,7 +184,7 @@ struct QualityAnalyzer { } static func analyze(photo: Photo) async { - let url = photo.url + let url = photo.imageURL async let blur = analyzeBlur(imageURL: url) async let faces = analyzeFaces(imageURL: url) diff --git a/cull/Services/ShotGrouper.swift b/cull/Services/ShotGrouper.swift index b8e9bc5..f8993be 100644 --- a/cull/Services/ShotGrouper.swift +++ b/cull/Services/ShotGrouper.swift @@ -10,7 +10,8 @@ struct ShotGrouper { static let mergeTimeThreshold: TimeInterval = 5 /// Feature print distance threshold for visual similarity (Revision 2, macOS 14+) - static let similarityThreshold: Float = 0.35 + /// Higher = more lenient grouping (different framings of same scene) + static let similarityThreshold: Float = 0.6 /// Full grouping: temporal + visual similarity + merge close shots static func group(photos: [Photo], progress: (@Sendable (Double) async -> Void)? = nil) async -> [PhotoGroup] { @@ -22,7 +23,7 @@ struct ShotGrouper { var completed = 0.0 // Step 2: Generate feature prints for all photos (batched to report smooth progress) - let fpWork: [(UUID, URL)] = photos.map { ($0.id, $0.url) } + let fpWork: [(UUID, URL)] = photos.map { ($0.id, $0.imageURL) } var featurePrintMap: [UUID: VNFeaturePrintObservation] = [:] let batchSize = 8 for batchStart in stride(from: 0, to: fpWork.count, by: batchSize) { diff --git a/cull/Services/ThumbnailCache.swift b/cull/Services/ThumbnailCache.swift index ed31b69..0a2f8ca 100644 --- a/cull/Services/ThumbnailCache.swift +++ b/cull/Services/ThumbnailCache.swift @@ -38,20 +38,20 @@ final class ThumbnailCache { func thumbnail(for photo: Photo) async -> NSImage? { let key = photo.url.absoluteString - let url = photo.url + let loadURL = photo.imageURL if let cached = memoryCache.object(forKey: key as NSString) { return cached } - let diskPath = diskCacheURL.appendingPathComponent(stableDiskKey(for: url)) + let diskPath = diskCacheURL.appendingPathComponent(stableDiskKey(for: photo.url)) let pixelSize = maxPixelSize let image: NSImage? = await Task.detached(priority: .userInitiated) { () -> NSImage? in if let diskImage = NSImage(contentsOf: diskPath) { return diskImage } - guard let extracted = Self.extractThumbnailSync(from: url, maxPixelSize: pixelSize) else { return nil } + guard let extracted = Self.extractThumbnailSync(from: loadURL, maxPixelSize: pixelSize) else { return nil } Self.saveToDisk(extracted, at: diskPath) return extracted }.value @@ -69,10 +69,10 @@ final class ThumbnailCache { return cached } - let url = photo.url + let loadURL = photo.imageURL let image: NSImage? = await Task.detached(priority: .userInitiated) { () -> NSImage? in - Self.loadFullPreviewSync(from: url) + Self.loadFullPreviewSync(from: loadURL) }.value if let image { @@ -89,8 +89,9 @@ final class ThumbnailCache { photos: [Photo], progress: (@Sendable (Double) async -> Void)? = nil ) async { - let thumbWork: [(String, URL)] = photos.map { photo in - (photo.url.absoluteString, photo.url) + // key = RAW url for cache identity, loadURL = JPEG when available for fast decode + let thumbWork: [(String, URL, URL)] = photos.map { photo in + (photo.url.absoluteString, photo.imageURL, photo.url) } let totalItems = Double(thumbWork.count) @@ -104,13 +105,13 @@ final class ThumbnailCache { let batchEnd = min(batchStart + batchSize, thumbWork.count) let batch = Array(thumbWork[batchStart.. Void)? = nil ) async { let work: [(String, URL)] = photos.map { photo in - (photo.url.absoluteString, photo.url) + (photo.url.absoluteString, photo.imageURL) } let totalItems = Double(work.count) @@ -186,9 +187,9 @@ final class ThumbnailCache { for batchStart in stride(from: 0, to: work.count, by: batchSize) { let batch = Array(work[batchStart..