diff --git a/cull/Services/QualityAnalyzer.swift b/cull/Services/QualityAnalyzer.swift --- a/cull/Services/QualityAnalyzer.swift +++ b/cull/Services/QualityAnalyzer.swift @@ -6,30 +6,9 @@ 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. + /// Delegates to ShotGrouper.bestImageSource which finds the largest embedded preview. 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) + ShotGrouper.bestImageSource(for: url) } /// Laplacian variance sharpness detection using Accelerate (vDSP). diff --git a/cull/Services/ShotGrouper.swift b/cull/Services/ShotGrouper.swift --- a/cull/Services/ShotGrouper.swift +++ b/cull/Services/ShotGrouper.swift @@ -13,6 +13,31 @@ /// Feature print distance threshold for visual similarity (Revision 2, macOS 14+) /// Higher = more lenient grouping (different framings of same scene) static let similarityThreshold: Float = 0.6 + /// For RAW files with embedded previews, find the largest image index. + /// Returns (source, imageIndex). Shared with QualityAnalyzer. + static func bestImageSource(for url: URL) -> (CGImageSource, Int)? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } + + let count = CGImageSourceGetCount(source) + if count > 1 { + var bestIndex = 0 + var bestPixels = 0 + for i in 0.. bestPixels { + bestPixels = pixels + bestIndex = i + } + } + } + return (source, bestIndex) + } + return (source, 0) + } + /// Full grouping: temporal + visual similarity + merge close shots static func group(photos: [Photo], progress: (@Sendable (Double) async -> Void)? = nil) async -> [PhotoGroup] { guard !photos.isEmpty else { return [] } @@ -110,7 +135,11 @@ let (otherPhoto, otherFP) = featurePrints[j] guard !assigned.contains(otherPhoto.id) else { continue } var distance: Float = 0 - try? fp.computeDistance(&distance, to: otherFP) + do { + try fp.computeDistance(&distance, to: otherFP) + } catch { + continue // Incompatible feature prints — treat as not similar + } if distance < similarityThreshold { cluster.append(otherPhoto) @@ -175,12 +204,16 @@ let aFP = featurePrintMap[aRep.id], let bFP = featurePrintMap[bRep.id] else { return false } var distance: Float = 0 - try? aFP.computeDistance(&distance, to: bFP) + do { + try aFP.computeDistance(&distance, to: bFP) + } catch { + return false // Incompatible feature prints — treat as not similar + } return distance < similarityThreshold } private static func generateFeaturePrint(url: URL) async -> VNFeaturePrintObservation? { - guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } + guard let (source, imageIndex) = bestImageSource(for: url) else { return nil } let options: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageIfAbsent: true, @@ -188,7 +221,7 @@ 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 } let request = VNGenerateImageFeaturePrintRequest() let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])