From 0a3cef8cc38ec91e8137be5f61d128c86c72fe7c Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Tue, 24 Mar 2026 14:51:57 +0800 Subject: [PATCH] feat: use raw and fallback to jpg --- cull/Services/PhotoImporter.swift | 2 +- cull/Services/QualityAnalyzer.swift | 37 +++++++++++++++++++++++++---- cull/Services/ThumbnailCache.swift | 32 ++++++++++++------------- cull/Views/ContentView.swift | 8 +++---- 4 files changed, 53 insertions(+), 26 deletions(-) diff --git a/cull/Services/PhotoImporter.swift b/cull/Services/PhotoImporter.swift index d8961a8..439e7e3 100644 --- a/cull/Services/PhotoImporter.swift +++ b/cull/Services/PhotoImporter.swift @@ -62,7 +62,7 @@ struct PhotoImporter { // Read EXIF dates + image metadata sequentially (header-only reads are fast, ~1ms each) for photo in photos { - let dateURL = photo.pairedURL ?? photo.url + let dateURL = photo.url photo.captureDate = readCaptureDate(from: dateURL) readImageMetadata(from: photo.url, into: photo) if let pairedURL = photo.pairedURL { diff --git a/cull/Services/QualityAnalyzer.swift b/cull/Services/QualityAnalyzer.swift index 311128b..cec2da0 100644 --- a/cull/Services/QualityAnalyzer.swift +++ b/cull/Services/QualityAnalyzer.swift @@ -5,17 +5,44 @@ import Vision struct QualityAnalyzer { + /// For RAW files, find the best image index to analyze. + /// RAW files embed JPEG previews (with camera sharpening) as secondary images. + /// Returns (source, imageIndex) so the thumbnail API can extract from the right image. + private static func sourceForAnalysis(_ url: URL) -> (CGImageSource, Int)? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } + + let count = CGImageSourceGetCount(source) + if count > 1 { + // Find the largest embedded preview (usually a camera-processed JPEG) + var bestIndex = 0 + var bestPixels = 0 + for i in 0.. bestPixels { + bestPixels = pixels + bestIndex = i + } + } + } + return (source, bestIndex) + } + return (source, 0) + } + /// Laplacian variance sharpness detection using Accelerate (vDSP). /// Uses Apple's recommended 8-connected Laplacian kernel for better edge sensitivity. static func analyzeBlur(imageURL: URL) async -> Double? { - guard let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil) else { return nil } + guard let (source, imageIndex) = sourceForAnalysis(imageURL) else { return nil } let options: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageIfAbsent: true, kCGImageSourceThumbnailMaxPixelSize: 512, kCGImageSourceShouldCache: false, kCGImageSourceCreateThumbnailWithTransform: true ] - guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil } + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, imageIndex, options as CFDictionary) else { return nil } // Read ISO for noise compensation let iso = readISO(from: source) @@ -95,7 +122,7 @@ struct QualityAnalyzer { } static func analyzeFaces(imageURL: URL) async -> FaceResult { - guard let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil) else { + guard let (source, imageIndex) = sourceForAnalysis(imageURL) else { return FaceResult(sharpness: nil, regions: []) } let options: [CFString: Any] = [ @@ -104,7 +131,7 @@ struct QualityAnalyzer { kCGImageSourceShouldCache: false, kCGImageSourceCreateThumbnailWithTransform: true ] - guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, imageIndex, options as CFDictionary) else { return FaceResult(sharpness: nil, regions: []) } @@ -157,7 +184,7 @@ struct QualityAnalyzer { } static func analyze(photo: Photo) async { - let url = photo.pairedURL ?? photo.url + let url = photo.url async let blur = analyzeBlur(imageURL: url) async let faces = analyzeFaces(imageURL: url) diff --git a/cull/Services/ThumbnailCache.swift b/cull/Services/ThumbnailCache.swift index 187e82c..ed31b69 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 sourceURL = photo.pairedURL ?? photo.url + let url = photo.url if let cached = memoryCache.object(forKey: key as NSString) { return cached } - let diskPath = diskCacheURL.appendingPathComponent(stableDiskKey(for: photo.url)) + let diskPath = diskCacheURL.appendingPathComponent(stableDiskKey(for: 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: sourceURL, maxPixelSize: pixelSize) else { return nil } + guard let extracted = Self.extractThumbnailSync(from: url, maxPixelSize: pixelSize) else { return nil } Self.saveToDisk(extracted, at: diskPath) return extracted }.value @@ -69,7 +69,7 @@ final class ThumbnailCache { return cached } - let url = photo.pairedURL ?? photo.url + let url = photo.url let image: NSImage? = await Task.detached(priority: .userInitiated) { () -> NSImage? in Self.loadFullPreviewSync(from: url) @@ -89,8 +89,8 @@ final class ThumbnailCache { photos: [Photo], progress: (@Sendable (Double) async -> Void)? = nil ) async { - let thumbWork: [(String, URL, URL)] = photos.map { photo in - (photo.url.absoluteString, photo.pairedURL ?? photo.url, photo.url) + let thumbWork: [(String, URL)] = photos.map { photo in + (photo.url.absoluteString, photo.url) } let totalItems = Double(thumbWork.count) @@ -104,13 +104,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.pairedURL ?? photo.url) + (photo.url.absoluteString, photo.url) } let totalItems = Double(work.count) @@ -213,7 +213,7 @@ final class ThumbnailCache { let work: [(String, URL)] = photos.compactMap { photo in let key = photo.url.absoluteString guard previewCache.object(forKey: key as NSString) == nil else { return nil } - return (key, photo.pairedURL ?? photo.url) + return (key, photo.url) } guard !work.isEmpty else { return } diff --git a/cull/Views/ContentView.swift b/cull/Views/ContentView.swift index 5973f46..14f0e78 100644 --- a/cull/Views/ContentView.swift +++ b/cull/Views/ContentView.swift @@ -92,14 +92,14 @@ struct ContentView: View { } await withTaskGroup(of: Void.self) { parallelGroup in - // Stream 1: Quality analysis (blur + faces) + // Stream 1: Quality analysis (blur + faces) — low priority to not starve preview/thumbnail loading parallelGroup.addTask { var completed = 0.0 for batchStart in stride(from: 0, to: allPhotos.count, by: 8) { let batch = Array(allPhotos[batchStart..