diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e121c89
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,19 @@
+# Xcode
+build/
+DerivedData/
+*.xcworkspace
+!*.xcodeproj/project.pbxproj
+xcuserdata/
+*.xcuserstate
+
+# Swift Package Manager
+.build/
+.swiftpm/
+
+# macOS
+.DS_Store
+*.swp
+*~
+
+# App-specific caches
+*.thumbnails/
diff --git a/SPEC.md b/SPEC.md
new file mode 100644
index 0000000..aaa3fa9
--- /dev/null
+++ b/SPEC.md
@@ -0,0 +1,185 @@
+# Building a macOS photo culling app in Swift is highly feasible
+
+**A Narrative Select–style photo culling app can be built almost entirely with Apple's native frameworks.** The critical path — RAW preview extraction, keyboard-driven UI, blur detection, and shot grouping — maps cleanly onto ImageIO, Vision, Metal Performance Shaders, and SwiftUI APIs that ship with macOS. Only two features require meaningful third-party effort: aesthetic quality scoring (Core ML model conversion) and robust XMP sidecar writing (no Apple API covers the full spec). On Apple Silicon, the entire analysis pipeline — thumbnail extraction, face detection, blur scoring, and feature-print generation — processes **~20–55ms per photo**, meaning a 2,000-image shoot can be fully analyzed in under two minutes.
+
+---
+
+## RAW preview extraction is effectively a solved problem
+
+Apple's **ImageIO** framework supports every major RAW format natively: CR2, CR3, ARW, NEF, DNG, RAF, and ORF. The key insight for performance is that nearly all RAW files embed full-resolution JPEG previews (the same image shown on the camera LCD), and ImageIO can extract these without triggering a RAW demosaic.
+
+The critical API is `CGImageSourceCreateThumbnailAtIndex` with the option `kCGImageSourceCreateThumbnailFromImageIfAbsent`. When an embedded preview exists — which it does in virtually all camera RAW files — this function extracts and downscales the JPEG in **~15–50ms per file** on Apple Silicon. Compare this to full RAW decoding via `CIRAWFilter`, which takes **~3 seconds** on first invocation (Metal shader compilation) and ~50–200ms on subsequent calls. The embedded preview path is 10–100× faster.
+
+For the dual import mode specifically, the architecture is straightforward. Scan the import directory for RAW+JPEG pairs by matching basenames. Display the sidecar JPEG or extracted embedded preview in the grid. Only invoke `CIRAWFilter` when the user opens a single image for detailed inspection. The `CIRAWFilter.previewImage` property (macOS 12+) also provides the embedded preview as a `CIImage`, but `CGImageSourceCreateThumbnailAtIndex` is faster for bulk thumbnail generation because it avoids Core Image pipeline overhead.
+
+**CR3 format support** arrived in macOS Catalina (10.15), but each specific Canon camera model requires Apple to add support incrementally. There is a known issue in macOS Sequoia 15.1 where CR3 files with HDR PQ–enabled HEVC previews cause excessive CPU usage in the system's `ImageThumbnailExtension` process. DNG has basic support even for unlisted cameras, making it a reliable fallback. Apple maintains a current list of supported RAW cameras that covers iOS 18, macOS Sequoia 15, and visionOS 2.
+
+**Difficulty: Easy.** Built-in APIs handle everything. Development estimate: 1–2 weeks for the RAW+JPEG pair manager and thumbnail extraction pipeline.
+
+---
+
+## Keyboard-driven culling maps well onto SwiftUI's focus system
+
+SwiftUI on macOS 14 (Sonoma) introduced `.onKeyPress`, the native modifier for handling keyboard input without AppKit bridging. It supports filtering by specific keys, character sets, and key phases (down, up, repeat), and returns `.handled` or `.ignored` to control event propagation. For a culling app, the mapping is direct:
+
+```swift
+.onKeyPress(keys: ["p"]) { _ in markAsPick(); return .handled }
+.onKeyPress(keys: ["x"]) { _ in markAsReject(); return .handled }
+.onKeyPress(characters: .decimalDigits) { press in
+ if let digit = Int(press.characters), (1...5).contains(digit) {
+ setRating(digit); return .handled
+ }
+ return .ignored
+}
+.onKeyPress(.rightArrow) { _ in nextPhoto(); return .handled }
+```
+
+**The critical requirement is that the view must be both `.focusable()` and `.focused()`.** This is the number-one debugging issue developers encounter — if no view has focus, `onKeyPress` never fires. Apply `.focusable()` *before* `.focused($isFocused)`, set focus on appear (sometimes requiring a short `DispatchQueue.main.asyncAfter` delay), and use `.focusEffectDisabled()` to suppress the blue focus ring that macOS draws around focused views.
+
+For apps that must support macOS versions prior to Sonoma, `NSEvent.addLocalMonitorForEvents(matching: .keyDown)` remains viable. This intercepts key events at the window level regardless of which SwiftUI view has focus, which is actually advantageous for a culling app where keyboard shortcuts should work globally. The tradeoff is that it bypasses SwiftUI's declarative event handling.
+
+Three SwiftUI-specific gotchas deserve attention. First, focus can be lost when the user clicks other UI elements like sidebars or toolbars — the viewer must reclaim focus programmatically. Second, mixing AppKit views via `NSViewRepresentable` can cause focus to get "stuck" because SwiftUI's focus system doesn't perfectly map to AppKit's first-responder chain. Third, on macOS, Tab and Shift+Tab navigate focus between focusable views only when "Use keyboard navigation" is enabled in System Preferences — keep the number of focusable views minimal to avoid confusing tab behavior.
+
+**Difficulty: Easy to moderate.** The core keyboard handling is straightforward; edge cases around focus management require testing. Development estimate: 1–2 weeks.
+
+---
+
+## XMP sidecars require careful schema work but no third-party libraries
+
+XMP sidecar files are XML documents following Adobe's XMP specification. The core metadata for a culling app uses the `xmp:` namespace (`http://ns.adobe.com/xap/1.0/`):
+
+- **Star ratings**: `xmp:Rating` as an integer, values **0–5** (0 = unrated, 1–5 = star ratings, **-1 = rejected** in Adobe Bridge)
+- **Color labels**: `xmp:Label` as text — Lightroom uses `"Red"`, `"Yellow"`, `"Green"`, `"Blue"`, `"Purple"`
+- **Pick/reject flags**: **Not stored in XMP at all.** Lightroom's pick/reject flags exist only in the Lightroom catalog database and are never exported to sidecar files. This is a critical discovery for cross-app compatibility — use `xmp:Rating = -1` for Bridge-compatible reject, or a specific color label like `"Red"` to indicate rejection in a Lightroom-importable way.
+
+A minimal valid XMP sidecar file is surprisingly small:
+
+```xml
+
+
+
+
+
+
+
+
+```
+
+Writing this in Swift requires no dependencies — a string template with interpolated values works perfectly. For reading, macOS provides `XMLDocument` with XPath support, making it trivial to parse existing sidecars. Apple also provides `CGImageMetadataCreateFromXMPData` and `CGImageMetadataCreateXMPData` for converting between XMP byte streams and structured metadata objects, though these APIs are documented inconsistently and developers report that "custom tags just disappear" when round-tripping through them.
+
+**File naming matters for compatibility.** Use `.xmp` (e.g., `IMG_1234.xmp`) for Lightroom compatibility. darktable uses `..xmp` (e.g., `IMG_1234.CR3.xmp`) and will read Lightroom-format sidecars on import but never write to them. For maximum cross-app compatibility, write `.xmp` files and let darktable create its own parallel sidecars. Always read existing sidecars before writing to avoid clobbering Camera Raw develop settings that Lightroom may have stored.
+
+**Difficulty: Moderate.** The schema is well-documented but the pick-flag gap and cross-app compatibility require careful design decisions. Development estimate: 1–2 weeks.
+
+---
+
+## Blur detection has a fast GPU path and a smart hybrid architecture
+
+The most effective blur detection strategy for a photo culling app combines two complementary approaches: **Metal Performance Shaders for global sharpness** and **Vision framework for face-specific quality**.
+
+**The MPS Laplacian path is the fastest option.** `MPSImageLaplacian` applies an optimized Laplacian edge-detection kernel on the GPU, and `MPSImageStatisticsMeanAndVariance` computes the variance of the result via GPU reduction, outputting a 2×1 pixel texture containing mean and variance. The entire pipeline — source texture → Laplacian → variance — executes in **~1–5ms on Apple Silicon** for typical photo resolutions. Sharp images produce high Laplacian variance; blurry images produce low variance. This classic approach (Pech-Pacheco et al., 2000) detects defocus blur excellently, handles motion blur moderately well, but struggles with intentional bokeh where sharp subjects coexist with blurred backgrounds.
+
+**Apple's `VNDetectFaceCaptureQualityRequest`** (macOS 10.15+) solves the bokeh problem for portrait photography. It returns a **0.0–1.0 quality score** per detected face, incorporating sharpness, lighting, pose, expression, and eye openness into a single trained metric. This is essentially Apple's built-in "best face" selector. It takes **~5–15ms per image** and handles the exact scenario where global Laplacian variance fails: a perfectly sharp portrait with creamy bokeh.
+
+The recommended hybrid architecture runs both in parallel:
+
+1. Extract embedded JPEG preview, downscale to ~512px
+2. Run `VNDetectFaceCaptureQualityRequest` → face quality scores (if faces exist)
+3. Run `MPSImageLaplacian` → `MPSImageStatisticsMeanAndVariance` on the full image or face-cropped regions
+4. Combine scores: face quality + Laplacian variance → composite quality metric
+5. Optionally run a NIMA Core ML model for aesthetic quality scoring
+
+For blink detection specifically, Vision framework has no dedicated API, but `VNDetectFaceLandmarksRequest` returns 76-point face landmarks including full eye contours. Computing the **Eye Aspect Ratio** (EAR = vertical eye distance / horizontal eye distance) from these landmarks detects closed eyes reliably — open eyes have EAR ≈ 0.2–0.3, closed eyes drop below 0.2. The legacy `CIDetector` also exposes `CIFaceFeatureLeftEyeClosed` and `CIFaceFeatureRightEyeClosed` booleans, though with lower accuracy.
+
+An alternative CPU path uses Apple's Accelerate framework: `vImageConvolve_PlanarF()` applies the Laplacian kernel, and `vDSP_normalize()` computes the standard deviation. Apple provides an official sample project, "Finding the Sharpest Image in a Sequence of Captured Images," demonstrating this exact approach. It's SIMD-optimized on Apple Silicon and runs in **~2–10ms** per image.
+
+For aesthetic quality beyond blur/sharpness, the **PhotoAssessment** project on GitHub provides a pre-converted NIMA (Neural Image Assessment) Core ML model that scores images on a 1–10 quality scale. MobileNet-based NIMA inference takes **~2–5ms** on the Neural Engine. More sophisticated models like MUSIQ exist but require complex Core ML conversion due to variable input sizes and custom position encodings.
+
+**Difficulty: Easy for basic blur detection** (MPS path is ~20 lines of code), **moderate for the full hybrid pipeline**, **hard for aesthetic quality scoring** (model conversion and threshold tuning). Development estimate: 2–4 weeks for the complete quality assessment system.
+
+---
+
+## Shot grouping works best with temporal clustering plus Vision feature prints
+
+The most production-proven approach, validated by the ShutterSlim app (which reached #2 in the German App Store processing 35,000-photo libraries), combines EXIF timestamp clustering with Apple's `VNGenerateImageFeaturePrintRequest` for visual similarity.
+
+**Step 1: Temporal clustering.** Read `kCGImagePropertyExifDateTimeOriginal` via `CGImageSourceCopyPropertiesAtIndex` (~2–4ms per file, no pixel decoding). Sort by timestamp and cluster using a simple gap threshold. A **10-minute gap** works well for grouping shots from a photo shoot — in ShutterSlim's testing on 35,000 photos, this produced ~5,300 clusters with a median size of 2–3 photos. For burst-shot detection specifically, a 1–5 second threshold identifies rapid-fire sequences.
+
+**Step 2: Visual similarity within time clusters.** `VNGenerateImageFeaturePrintRequest` generates a dense semantic embedding per image. The critical implementation detail is that **Revision 2** (macOS 14+) produces normalized **768-dimensional** vectors with distances in the 0.0–~2.0 range, while **Revision 1** (macOS 10.15+) produces **2048-dimensional** unnormalized vectors with distances in the 0.0–~40.0 range. Production-tested threshold for Revision 2: **~0.35** for near-duplicate grouping. The `computeDistance(_:to:)` method uses Euclidean distance internally (confirmed by framework decompilation).
+
+Feature print generation takes **~15–50ms per image** on Apple Silicon — the neural network inference is the bottleneck. For 2,000 images, expect ~30–100 seconds for initial generation, but results should be cached to SQLite or Core Data keyed by photo ID and modification date. Subsequent launches only process new images.
+
+For a fast pre-filter, **dHash (difference hash)** identifies exact duplicates in under 1ms per image. The CocoaImageHashing library provides a native Swift implementation supporting dHash, pHash, and aHash with built-in data parallelism. A Hamming distance threshold of **2 bits** (128-bit hash) catches compression and resize variants with minimal false positives. This catches trivially identical images before the heavier Vision pipeline runs.
+
+**CLIP embeddings via Core ML are overkill for duplicate detection.** Apple's own MobileCLIP models are available pre-converted on HuggingFace (`apple/coreml-mobileclip`) and run at 3–10ms per image, but they add 11–173MB to app size and provide cross-modal understanding (text↔image) that a culling app doesn't need. VNFeaturePrintObservation achieves comparable visual similarity detection with zero dependencies.
+
+**Difficulty: Moderate.** The temporal clustering is trivial. Feature print generation and threshold tuning require experimentation. Caching adds implementation surface. Development estimate: 2–3 weeks.
+
+---
+
+## The thumbnail pipeline needs a three-tier cache and careful memory management
+
+Displaying 1,000–2,000 RAW thumbnails smoothly in a SwiftUI `LazyVGrid` is achievable but requires deliberate architecture. The recommended approach uses three tiers:
+
+**Tier 1 — In-memory cache** via `NSCache` with a 500-item count limit and 100MB total cost limit. `NSCache` is thread-safe and auto-evicts under memory pressure. This serves thumbnails for visible and recently-visible cells.
+
+**Tier 2 — Disk cache** storing generated thumbnails as JPEG files (~20–50KB each at 0.7 quality, 400px) in the app's Caches directory, keyed by a hash of the source file URL. This survives app restarts.
+
+**Tier 3 — On-demand extraction** via `CGImageSourceCreateThumbnailAtIndex` from the original RAW file. Use `TaskGroup` with 8–16 concurrent tasks for parallel generation across Apple Silicon's performance cores.
+
+Realistic benchmarks on M-series chips for 1,000 RAW files:
+
+| Operation | Per image | 1,000 images (8-way parallel) |
+|-----------|-----------|-------------------------------|
+| EXIF metadata read | 2–4ms | ~0.3–0.5s |
+| Embedded JPEG preview (400px) | 15–50ms | **~2–6s** |
+| Full RAW decode (CIRAWFilter) | 50–3,000ms | Minutes (impractical) |
+| QLThumbnailGenerator (cached) | <5ms | <1s |
+
+**LazyVGrid handles 1,000+ items smoothly** when cell views are simple, but it has a critical difference from UICollectionView: **it does not implement cell reuse**. Images loaded into cells that scroll off-screen remain in memory. The fix is explicit: set the image to `nil` in `.onDisappear` and reload in `.task`. Use `kCGImageSourceShouldCache: false` when creating image sources to prevent ImageIO from retaining full decoded images. The `.task` modifier on SwiftUI views automatically cancels when the view disappears, preventing wasted work for cells that scroll off-screen before loading completes.
+
+For progressive rendering, show a gray placeholder immediately, then the low-resolution embedded thumbnail (~128px, extracted in ~5ms), then the full-quality thumbnail (~512px). `QLThumbnailGenerator.generateRepresentations(for:update:)` provides this natively with three quality tiers (icon → low-quality → full), but direct `CGImageSourceCreateThumbnailAtIndex` is faster for bulk RAW processing because it avoids IPC overhead (QLThumbnailGenerator runs out-of-process).
+
+**Difficulty: Moderate.** The individual pieces are straightforward, but making the full pipeline feel instant and managing memory correctly across thousands of images requires careful engineering. Development estimate: 2–3 weeks.
+
+---
+
+## What's easy, what's moderate, and what's hard
+
+| Feature | Difficulty | Dependencies | Dev estimate | Key APIs |
+|---------|-----------|-------------|-------------|----------|
+| RAW preview extraction | **Easy** | None (built-in) | 1–2 weeks | `CGImageSourceCreateThumbnailAtIndex`, `CIRAWFilter.previewImage` |
+| Keyboard culling UI | **Easy–Moderate** | None | 1–2 weeks | `.onKeyPress` (macOS 14+), `@FocusState`, `.focusable()` |
+| XMP sidecar read/write | **Moderate** | None | 1–2 weeks | `XMLDocument`, string templates, `CGImageMetadataCreateFromXMPData` |
+| Global blur detection | **Easy** | None | 1 week | `MPSImageLaplacian`, `MPSImageStatisticsMeanAndVariance` |
+| Face quality scoring | **Easy** | None | 3–5 days | `VNDetectFaceCaptureQualityRequest` |
+| Blink detection | **Moderate** | None | 1 week | `VNDetectFaceLandmarksRequest` + EAR calculation |
+| Near-duplicate detection | **Moderate** | None | 2–3 weeks | `VNGenerateImageFeaturePrintRequest`, `computeDistance` |
+| Shot/scene grouping | **Moderate** | None | 2–3 weeks | EXIF timestamp clustering + feature print similarity |
+| Thumbnail pipeline | **Moderate** | None | 2–3 weeks | `CGImageSource`, `NSCache`, `LazyVGrid`, `TaskGroup` |
+| Aesthetic quality scoring | **Hard** | Core ML model (PhotoAssessment/NIMA) | 2–4 weeks | `coremltools` conversion, `MLModel` inference |
+| CLIP-based features | **Hard** | MobileCLIP model (~11–173MB) | 3–4 weeks | `apple/coreml-mobileclip` from HuggingFace |
+
+**Total estimated development time for a competent Swift developer: 12–20 weeks** for a full-featured MVP, with the core culling workflow (import, display, keyboard-flag, export XMP) achievable in 4–6 weeks.
+
+---
+
+## Gotchas and recommendations that will save you weeks
+
+**The pick-flag gap is the biggest workflow surprise.** Lightroom's pick/reject flags are catalog-only and never appear in XMP. Design the app to use `xmp:Rating = -1` for Adobe Bridge–compatible rejection, and document that Lightroom users should use color labels or star ratings as their pick/reject signal. This is a user-education issue, not a technical one.
+
+**VNFeaturePrint revision differences can silently break duplicate detection.** Revision 1 (macOS 10.15–13) produces 2,048-float vectors with distances ~0–40; Revision 2 (macOS 14+) produces 768-float normalized vectors with distances ~0–2. Thresholds from one revision are meaningless for the other. Pin to a specific revision with `request.revision` or detect the OS version and adjust thresholds accordingly.
+
+**CR3 files with HDR PQ cause known system issues** on macOS Sequoia 15.1, triggering excessive CPU usage in `ImageThumbnailExtension`. Test with Canon R5 Mark II files specifically.
+
+**SwiftUI's focus system is fragile on macOS.** Invest in a robust focus management layer early — create a single "keyboard target" view that always reclaims focus after any UI interaction. Consider keeping `NSEvent.addLocalMonitorForEvents` as a fallback even if targeting macOS 14+.
+
+**Cache feature prints aggressively.** Neural network inference at ~15–50ms per image is the most expensive per-image operation in the pipeline. Store feature print vectors in SQLite (768 floats × 4 bytes = ~3KB per image, or ~6MB for 2,000 images). Re-scan only processes new or modified files.
+
+**Key WWDC sessions for reference**: "Capture and Process ProRAW Images" (2021, session 10160) for RAW handling; "Demystify SwiftUI Performance" (2023) for grid optimization; "Images and Graphics Best Practices" (2018) for image downsampling; "Optimize your Core ML usage" (2022) for Vision/ML profiling. The Apple sample project "Finding the Sharpest Image in a Sequence of Captured Images" provides a complete Accelerate-based blur detection implementation.
+
+The bottom line: **every core feature of a Narrative Select competitor can be built with zero third-party dependencies** using ImageIO, Vision, MPS, Core Image, and SwiftUI. The only feature that benefits from an external model is aesthetic quality scoring (NIMA via Core ML), and even that has an open-source pre-converted model available in the PhotoAssessment GitHub project.
\ No newline at end of file
diff --git a/cull/Assets.xcassets/AccentColor.colorset/Contents.json b/cull/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/cull/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/cull/Assets.xcassets/AppIcon.appiconset/Contents.json b/cull/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..3f00db4
--- /dev/null
+++ b/cull/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,58 @@
+{
+ "images" : [
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "16x16"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "16x16"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "32x32"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "32x32"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "128x128"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "128x128"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "256x256"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "256x256"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "512x512"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "512x512"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/cull/Assets.xcassets/Contents.json b/cull/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/cull/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/cull/CullApp.swift b/cull/CullApp.swift
new file mode 100644
index 0000000..bec8848
--- /dev/null
+++ b/cull/CullApp.swift
@@ -0,0 +1,16 @@
+import SwiftUI
+
+@main
+struct CullApp: App {
+ @State private var session = CullSession()
+ @State private var thumbnailCache = ThumbnailCache()
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ .environment(session)
+ .environment(thumbnailCache)
+ }
+ .windowStyle(.automatic)
+ }
+}
diff --git a/cull/Models/CullSession.swift b/cull/Models/CullSession.swift
new file mode 100644
index 0000000..1eb010d
--- /dev/null
+++ b/cull/Models/CullSession.swift
@@ -0,0 +1,132 @@
+import Foundation
+import SwiftUI
+
+@Observable
+final class CullSession {
+ var sourceFolder: URL?
+ var groups: [PhotoGroup] = []
+ var selectedGroupIndex: Int = 0
+ var selectedPhotoIndex: Int = 0
+
+ var isImporting: Bool = false
+ var importProgress: Double = 0
+
+ var selectedGroup: PhotoGroup? {
+ guard groups.indices.contains(selectedGroupIndex) else { return nil }
+ return groups[selectedGroupIndex]
+ }
+
+ var selectedPhoto: Photo? {
+ guard let group = selectedGroup,
+ group.photos.indices.contains(selectedPhotoIndex) else { return nil }
+ return group.photos[selectedPhotoIndex]
+ }
+
+ var allPhotos: [Photo] {
+ groups.flatMap(\.photos)
+ }
+
+ // MARK: - Navigation
+
+ func moveToNextGroup() {
+ guard !groups.isEmpty else { return }
+ selectedGroupIndex = (selectedGroupIndex + 1) % groups.count
+ selectedPhotoIndex = 0
+ }
+
+ func moveToPreviousGroup() {
+ guard !groups.isEmpty else { return }
+ selectedGroupIndex = (selectedGroupIndex - 1 + groups.count) % groups.count
+ selectedPhotoIndex = 0
+ }
+
+ func moveToNextPhoto() {
+ guard let group = selectedGroup else { return }
+ if selectedPhotoIndex < group.photos.count - 1 {
+ selectedPhotoIndex += 1
+ } else {
+ moveToNextGroup()
+ }
+ }
+
+ func moveToPreviousPhoto() {
+ if selectedPhotoIndex > 0 {
+ selectedPhotoIndex -= 1
+ } else {
+ moveToPreviousGroup()
+ selectedPhotoIndex = max(0, (selectedGroup?.photos.count ?? 1) - 1)
+ }
+ }
+
+ func selectGroup(at index: Int) {
+ guard groups.indices.contains(index) else { return }
+ selectedGroupIndex = index
+ selectedPhotoIndex = 0
+ }
+
+ func selectPhoto(at index: Int) {
+ guard let group = selectedGroup, group.photos.indices.contains(index) else { return }
+ selectedPhotoIndex = index
+ }
+
+ // MARK: - Lookahead
+
+ /// Returns the next N photos from the current position across group boundaries
+ func photosAhead(_ count: Int) -> [Photo] {
+ var result: [Photo] = []
+ var gi = selectedGroupIndex
+ var pi = selectedPhotoIndex + 1
+
+ while result.count < count && gi < groups.count {
+ let group = groups[gi]
+ while pi < group.photos.count && result.count < count {
+ result.append(group.photos[pi])
+ pi += 1
+ }
+ gi += 1
+ pi = 0
+ }
+ return result
+ }
+
+ /// Returns the previous N photos from the current position across group boundaries
+ func photosBehind(_ count: Int) -> [Photo] {
+ var result: [Photo] = []
+ var gi = selectedGroupIndex
+ var pi = selectedPhotoIndex - 1
+
+ while result.count < count && gi >= 0 {
+ let group = groups[gi]
+ while pi >= 0 && result.count < count {
+ result.append(group.photos[pi])
+ pi -= 1
+ }
+ gi -= 1
+ if gi >= 0 { pi = groups[gi].photos.count - 1 }
+ }
+ return result
+ }
+
+ // MARK: - Culling Actions
+
+ func setRating(_ rating: Int) {
+ guard (1...5).contains(rating) else { return }
+ selectedPhoto?.rating = rating
+ }
+
+ func togglePick() {
+ guard let photo = selectedPhoto else { return }
+ photo.flag = photo.flag == .pick ? .none : .pick
+ }
+
+ func toggleReject() {
+ guard let photo = selectedPhoto else { return }
+ photo.flag = photo.flag == .reject ? .none : .reject
+ }
+
+ func clearRatingAndFlag() {
+ guard let photo = selectedPhoto else { return }
+ photo.rating = 0
+ photo.flag = .none
+ }
+}
diff --git a/cull/Models/Photo.swift b/cull/Models/Photo.swift
new file mode 100644
index 0000000..c649c50
--- /dev/null
+++ b/cull/Models/Photo.swift
@@ -0,0 +1,49 @@
+import Foundation
+import UniformTypeIdentifiers
+
+enum PhotoFlag: Equatable {
+ case none
+ case pick
+ case reject
+}
+
+@Observable
+final class Photo: Identifiable {
+ let id: UUID
+ let url: URL
+ let basename: String
+
+ /// Paired file — e.g. if this is a RAW, pairedURL points to the JPEG (and vice versa)
+ var pairedURL: URL?
+
+ var rating: Int = 0 // 0 = unrated, 1–5
+ var flag: PhotoFlag = .none
+
+ // Populated asynchronously by QualityAnalyzer
+ var blurScore: Double?
+ var faceQualityScore: Double?
+
+ // Populated by ShotGrouper
+ var captureDate: Date?
+
+ var isRAW: Bool {
+ guard let utType = UTType(filenameExtension: url.pathExtension) else { return false }
+ return utType.conforms(to: .rawImage)
+ }
+
+ var isJPEG: Bool {
+ guard let utType = UTType(filenameExtension: url.pathExtension) else { return false }
+ return utType.conforms(to: .jpeg)
+ }
+
+ init(url: URL) {
+ self.id = UUID()
+ self.url = url
+ self.basename = url.deletingPathExtension().lastPathComponent
+ }
+}
+
+extension Photo: Hashable {
+ static func == (lhs: Photo, rhs: Photo) -> Bool { lhs.id == rhs.id }
+ func hash(into hasher: inout Hasher) { hasher.combine(id) }
+}
diff --git a/cull/Models/PhotoGroup.swift b/cull/Models/PhotoGroup.swift
new file mode 100644
index 0000000..c781dfa
--- /dev/null
+++ b/cull/Models/PhotoGroup.swift
@@ -0,0 +1,23 @@
+import Foundation
+
+@Observable
+final class PhotoGroup: Identifiable {
+ let id: UUID
+ var photos: [Photo]
+
+ var representativePhoto: Photo? { photos.first }
+
+ var earliestDate: Date? {
+ photos.compactMap(\.captureDate).min()
+ }
+
+ init(photos: [Photo]) {
+ self.id = UUID()
+ self.photos = photos
+ }
+}
+
+extension PhotoGroup: Hashable {
+ static func == (lhs: PhotoGroup, rhs: PhotoGroup) -> Bool { lhs.id == rhs.id }
+ func hash(into hasher: inout Hasher) { hasher.combine(id) }
+}
diff --git a/cull/Services/PhotoExporter.swift b/cull/Services/PhotoExporter.swift
new file mode 100644
index 0000000..d093236
--- /dev/null
+++ b/cull/Services/PhotoExporter.swift
@@ -0,0 +1,86 @@
+import Foundation
+
+enum ExportFileType: String, CaseIterable, Identifiable {
+ case raw = "RAW Only"
+ case jpeg = "JPEG Only"
+ case both = "RAW + JPEG"
+
+ var id: String { rawValue }
+}
+
+enum ExportMode: String, CaseIterable, Identifiable {
+ case copy = "Copy"
+ case move = "Move"
+
+ var id: String { rawValue }
+}
+
+struct ExportOptions {
+ var destination: URL
+ var fileType: ExportFileType = .both
+ var mode: ExportMode = .copy
+ var minimumRating: Int = 1 // export photos rated >= this
+ var includePickedOnly: Bool = false
+}
+
+struct ExportResult {
+ let exported: Int
+ let skipped: Int
+ let errors: [String]
+}
+
+struct PhotoExporter {
+ static func export(photos: [Photo], options: ExportOptions) async throws -> ExportResult {
+ let fm = FileManager.default
+ try fm.createDirectory(at: options.destination, withIntermediateDirectories: true)
+
+ var exported = 0
+ var skipped = 0
+ var errors: [String] = []
+
+ let eligible = photos.filter { photo in
+ if photo.flag == .reject { return false }
+ if options.includePickedOnly { return photo.flag == .pick }
+ return photo.rating >= options.minimumRating
+ }
+
+ for photo in eligible {
+ let urlsToExport = urlsForExport(photo: photo, fileType: options.fileType)
+
+ for sourceURL in urlsToExport {
+ let destURL = options.destination.appendingPathComponent(sourceURL.lastPathComponent)
+ do {
+ if fm.fileExists(atPath: destURL.path) {
+ try fm.removeItem(at: destURL)
+ }
+ switch options.mode {
+ case .copy:
+ try fm.copyItem(at: sourceURL, to: destURL)
+ case .move:
+ try fm.moveItem(at: sourceURL, to: destURL)
+ }
+ exported += 1
+ } catch {
+ errors.append("\(sourceURL.lastPathComponent): \(error.localizedDescription)")
+ }
+ }
+
+ skipped += urlsToExport.isEmpty ? 1 : 0
+ }
+
+ return ExportResult(exported: exported, skipped: skipped, errors: errors)
+ }
+
+ private static func urlsForExport(photo: Photo, fileType: ExportFileType) -> [URL] {
+ switch fileType {
+ case .both:
+ var urls = [photo.url]
+ if let paired = photo.pairedURL { urls.append(paired) }
+ return urls
+ case .raw:
+ return photo.isRAW ? [photo.url] : (photo.pairedURL.map { [$0] } ?? [])
+ case .jpeg:
+ return photo.isJPEG ? [photo.url] : (photo.pairedURL.map { [$0] } ?? [])
+ }
+ }
+}
diff --git a/cull/Services/PhotoImporter.swift b/cull/Services/PhotoImporter.swift
new file mode 100644
index 0000000..0023013
--- /dev/null
+++ b/cull/Services/PhotoImporter.swift
@@ -0,0 +1,106 @@
+import Foundation
+import ImageIO
+import UniformTypeIdentifiers
+
+struct PhotoImporter {
+ static let supportedExtensions: Set = [
+ "cr2", "cr3", "arw", "nef", "dng", "raf", "orf", "rw2",
+ "jpg", "jpeg", "heic", "heif", "tiff", "tif", "png"
+ ]
+
+ struct ImportResult {
+ let photos: [Photo]
+ let paired: Int // count of RAW+JPEG pairs found
+ }
+
+ static func importFolder(_ url: URL) async throws -> ImportResult {
+ let resourceKeys: Set = [.isRegularFileKey, .contentTypeKey]
+ guard let enumerator = FileManager.default.enumerator(
+ at: url,
+ includingPropertiesForKeys: Array(resourceKeys),
+ options: [.skipsHiddenFiles, .skipsPackageDescendants]
+ ) else {
+ throw ImportError.cannotReadFolder
+ }
+
+ var filesByBasename: [String: [URL]] = [:]
+ var allURLs: [URL] = []
+
+ let urls: [URL] = enumerator.compactMap { $0 as? URL }
+ for fileURL in urls {
+ let ext = fileURL.pathExtension.lowercased()
+ guard supportedExtensions.contains(ext) else { continue }
+ allURLs.append(fileURL)
+ let basename = fileURL.deletingPathExtension().lastPathComponent
+ filesByBasename[basename, default: []].append(fileURL)
+ }
+
+ // Build photo objects (no I/O yet)
+ var photos: [Photo] = []
+ var pairedCount = 0
+ var processed: Set = []
+
+ for (_, urls) in filesByBasename {
+ let rawURLs = urls.filter { isRAWExtension($0.pathExtension) }
+ let jpegURLs = urls.filter { isJPEGExtension($0.pathExtension) }
+
+ if let rawURL = rawURLs.first, let jpegURL = jpegURLs.first {
+ let photo = Photo(url: rawURL)
+ photo.pairedURL = jpegURL
+ photos.append(photo)
+ processed.insert(rawURL)
+ processed.insert(jpegURL)
+ pairedCount += 1
+ }
+
+ for url in urls where !processed.contains(url) {
+ let photo = Photo(url: url)
+ photos.append(photo)
+ processed.insert(url)
+ }
+ }
+
+ // Read EXIF dates sequentially (header-only reads are fast, ~1ms each)
+ for photo in photos {
+ let dateURL = photo.pairedURL ?? photo.url
+ photo.captureDate = readCaptureDate(from: dateURL)
+ }
+
+ photos.sort { ($0.captureDate ?? .distantPast) < ($1.captureDate ?? .distantPast) }
+
+ return ImportResult(photos: photos, paired: pairedCount)
+ }
+
+ nonisolated static func readCaptureDate(from url: URL) -> Date? {
+ guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
+ let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any],
+ let exif = properties[kCGImagePropertyExifDictionary as String] as? [String: Any],
+ let dateString = exif[kCGImagePropertyExifDateTimeOriginal as String] as? String
+ else { return nil }
+
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy:MM:dd HH:mm:ss"
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ return formatter.date(from: dateString)
+ }
+
+ private static func isRAWExtension(_ ext: String) -> Bool {
+ let raw: Set = ["cr2", "cr3", "arw", "nef", "dng", "raf", "orf", "rw2"]
+ return raw.contains(ext.lowercased())
+ }
+
+ private static func isJPEGExtension(_ ext: String) -> Bool {
+ let jpeg: Set = ["jpg", "jpeg"]
+ return jpeg.contains(ext.lowercased())
+ }
+}
+
+enum ImportError: LocalizedError {
+ case cannotReadFolder
+
+ var errorDescription: String? {
+ switch self {
+ case .cannotReadFolder: "Could not read the selected folder."
+ }
+ }
+}
diff --git a/cull/Services/QualityAnalyzer.swift b/cull/Services/QualityAnalyzer.swift
new file mode 100644
index 0000000..3974185
--- /dev/null
+++ b/cull/Services/QualityAnalyzer.swift
@@ -0,0 +1,119 @@
+import CoreImage
+import Metal
+import MetalPerformanceShaders
+import Vision
+
+struct QualityAnalyzer {
+ static func analyzeBlur(imageURL: URL) async -> Double? {
+ guard let device = MTLCreateSystemDefaultDevice() else { return nil }
+
+ let ciImage: CIImage?
+ if let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil) {
+ let options: [CFString: Any] = [
+ kCGImageSourceCreateThumbnailFromImageIfAbsent: true,
+ kCGImageSourceThumbnailMaxPixelSize: 512,
+ kCGImageSourceShouldCache: false,
+ kCGImageSourceCreateThumbnailWithTransform: true
+ ]
+ if let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) {
+ ciImage = CIImage(cgImage: cgImage)
+ } else {
+ ciImage = nil
+ }
+ } else {
+ ciImage = nil
+ }
+
+ guard let ci = ciImage,
+ let cgImage = CIContext().createCGImage(ci, from: ci.extent)
+ else { return nil }
+
+ let width = cgImage.width
+ let height = cgImage.height
+
+ let textureDescriptor = MTLTextureDescriptor.texture2DDescriptor(
+ pixelFormat: .r32Float, width: width, height: height, mipmapped: false
+ )
+ textureDescriptor.usage = [.shaderRead, .shaderWrite]
+
+ guard let sourceTexture = device.makeTexture(descriptor: textureDescriptor),
+ let laplacianTexture = device.makeTexture(descriptor: textureDescriptor)
+ else { return nil }
+
+ // Convert to grayscale float texture
+ let colorSpace = CGColorSpaceCreateDeviceGray()
+ guard let context = CGContext(
+ data: nil, width: width, height: height,
+ bitsPerComponent: 32, bytesPerRow: width * 4,
+ space: colorSpace, bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue | CGBitmapInfo.floatComponents.rawValue | CGBitmapInfo.byteOrder32Little.rawValue).rawValue
+ ) else { return nil }
+ context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
+
+ guard let data = context.data else { return nil }
+ sourceTexture.replace(
+ region: MTLRegionMake2D(0, 0, width, height),
+ mipmapLevel: 0,
+ withBytes: data,
+ bytesPerRow: width * 4
+ )
+
+ // Laplacian + variance
+ guard let commandQueue = device.makeCommandQueue(),
+ let commandBuffer = commandQueue.makeCommandBuffer()
+ else { return nil }
+
+ let laplacian = MPSImageLaplacian(device: device)
+ laplacian.encode(commandBuffer: commandBuffer, sourceTexture: sourceTexture, destinationTexture: laplacianTexture)
+
+ let varianceDesc = MTLTextureDescriptor.texture2DDescriptor(
+ pixelFormat: .r32Float, width: 2, height: 1, mipmapped: false
+ )
+ varianceDesc.usage = [.shaderRead, .shaderWrite]
+ guard let varianceTexture = device.makeTexture(descriptor: varianceDesc) else { return nil }
+
+ let stats = MPSImageStatisticsMeanAndVariance(device: device)
+ stats.encode(commandBuffer: commandBuffer, sourceTexture: laplacianTexture, destinationTexture: varianceTexture)
+
+ commandBuffer.commit()
+ await commandBuffer.completed()
+
+ var result = [Float](repeating: 0, count: 2)
+ varianceTexture.getBytes(
+ &result,
+ bytesPerRow: 8,
+ from: MTLRegionMake2D(0, 0, 2, 1),
+ mipmapLevel: 0
+ )
+
+ return Double(result[1]) // variance = sharpness score
+ }
+
+ static func analyzeFaceQuality(imageURL: URL) async -> Double? {
+ guard let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil) else { return nil }
+ let options: [CFString: Any] = [
+ kCGImageSourceCreateThumbnailFromImageIfAbsent: true,
+ kCGImageSourceThumbnailMaxPixelSize: 1024,
+ kCGImageSourceShouldCache: false,
+ kCGImageSourceCreateThumbnailWithTransform: true
+ ]
+ guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil }
+
+ let request = VNDetectFaceCaptureQualityRequest()
+ let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
+ try? handler.perform([request])
+
+ guard let results = request.results, !results.isEmpty else { return nil }
+ return results.map { Double($0.faceCaptureQuality ?? 0) }.max()
+ }
+
+ static func analyze(photo: Photo) async {
+ async let blur = analyzeBlur(imageURL: photo.pairedURL ?? photo.url)
+ async let face = analyzeFaceQuality(imageURL: photo.pairedURL ?? photo.url)
+
+ let (blurResult, faceResult) = await (blur, face)
+ await MainActor.run {
+ photo.blurScore = blurResult
+ photo.faceQualityScore = faceResult
+ }
+ }
+}
diff --git a/cull/Services/ShotGrouper.swift b/cull/Services/ShotGrouper.swift
new file mode 100644
index 0000000..0f94700
--- /dev/null
+++ b/cull/Services/ShotGrouper.swift
@@ -0,0 +1,194 @@
+import Vision
+import ImageIO
+
+struct ShotGrouper {
+ /// Time gap threshold for temporal clustering (seconds)
+ static let timeGapThreshold: TimeInterval = 30
+
+ /// Threshold for merging adjacent temporal clusters (seconds)
+ /// Groups within this time window get merged if visually similar
+ static let mergeTimeThreshold: TimeInterval = 5
+
+ /// Feature print distance threshold for visual similarity (Revision 2, macOS 14+)
+ static let similarityThreshold: Float = 0.35
+
+ /// 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 [] }
+
+ // Step 1: Temporal clustering
+ let timeClusters = clusterByTime(photos)
+ let totalWork = Double(photos.count)
+ var completed = 0.0
+
+ // Step 2: Generate feature prints for all photos
+ var featurePrintMap: [UUID: VNFeaturePrintObservation] = [:]
+ await withTaskGroup(of: (UUID, VNFeaturePrintObservation?).self) { group in
+ for photo in photos {
+ let id = photo.id
+ group.addTask {
+ let fp = await generateFeaturePrint(for: photo)
+ return (id, fp)
+ }
+ }
+ for await (id, fp) in group {
+ if let fp { featurePrintMap[id] = fp }
+ completed += 1
+ if let progress {
+ await progress(completed / totalWork)
+ }
+ }
+ }
+
+ // Step 3: Sub-cluster by visual similarity within each time cluster
+ var groups: [PhotoGroup] = []
+ for cluster in timeClusters {
+ if cluster.count <= 1 {
+ groups.append(PhotoGroup(photos: cluster))
+ continue
+ }
+
+ let fps = cluster.compactMap { photo -> (Photo, VNFeaturePrintObservation)? in
+ guard let fp = featurePrintMap[photo.id] else { return nil }
+ return (photo, fp)
+ }
+
+ if fps.isEmpty {
+ groups.append(PhotoGroup(photos: cluster))
+ continue
+ }
+
+ let subGroups = clusterByVisualSimilarity(fps, allPhotos: cluster)
+ groups.append(contentsOf: subGroups)
+ }
+
+ // Step 4: Merge adjacent groups that are very close in time AND visually similar
+ groups = mergeAdjacentGroups(groups, featurePrintMap: featurePrintMap)
+
+ return groups
+ }
+
+ private static func clusterByTime(_ photos: [Photo]) -> [[Photo]] {
+ let sorted = photos.sorted { ($0.captureDate ?? .distantPast) < ($1.captureDate ?? .distantPast) }
+ var clusters: [[Photo]] = []
+ var current: [Photo] = []
+
+ for photo in sorted {
+ if let last = current.last,
+ let lastDate = last.captureDate,
+ let thisDate = photo.captureDate,
+ thisDate.timeIntervalSince(lastDate) > timeGapThreshold {
+ clusters.append(current)
+ current = []
+ }
+ current.append(photo)
+ }
+ if !current.isEmpty { clusters.append(current) }
+ return clusters
+ }
+
+ private static func clusterByVisualSimilarity(
+ _ featurePrints: [(Photo, VNFeaturePrintObservation)],
+ allPhotos: [Photo]
+ ) -> [PhotoGroup] {
+ var assigned = Set()
+ var groups: [PhotoGroup] = []
+
+ for (i, (photo, fp)) in featurePrints.enumerated() {
+ guard !assigned.contains(photo.id) else { continue }
+
+ var cluster = [photo]
+ assigned.insert(photo.id)
+
+ for j in (i + 1).. [PhotoGroup] {
+ guard groups.count > 1 else { return groups }
+
+ var merged: [PhotoGroup] = [groups[0]]
+
+ for i in 1.. Bool {
+ guard let aLast = a.photos.last?.captureDate,
+ let bFirst = b.photos.first?.captureDate else { return false }
+ return abs(bFirst.timeIntervalSince(aLast)) <= mergeTimeThreshold
+ }
+
+ private static func areGroupsVisuallySimilar(
+ _ a: PhotoGroup,
+ _ b: PhotoGroup,
+ featurePrintMap: [UUID: VNFeaturePrintObservation]
+ ) -> Bool {
+ // Compare representative photos (first of each group)
+ guard let aRep = a.photos.first, let bRep = b.photos.first,
+ let aFP = featurePrintMap[aRep.id], let bFP = featurePrintMap[bRep.id]
+ else { return false }
+
+ var distance: Float = 0
+ try? aFP.computeDistance(&distance, to: bFP)
+ return distance < similarityThreshold
+ }
+
+ private static func generateFeaturePrint(for photo: Photo) async -> VNFeaturePrintObservation? {
+ let url = photo.pairedURL ?? photo.url
+ guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) 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 }
+
+ let request = VNGenerateImageFeaturePrintRequest()
+ let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
+ try? handler.perform([request])
+
+ return request.results?.first
+ }
+}
diff --git a/cull/Services/ThumbnailCache.swift b/cull/Services/ThumbnailCache.swift
new file mode 100644
index 0000000..72fde26
--- /dev/null
+++ b/cull/Services/ThumbnailCache.swift
@@ -0,0 +1,306 @@
+import AppKit
+import CryptoKit
+import ImageIO
+
+@MainActor @Observable
+final class ThumbnailCache {
+ private let memoryCache = NSCache()
+ private let previewCache = NSCache()
+ private var previewKeys = Set()
+ private let diskCacheURL: URL
+ private let maxPixelSize: Int
+
+ init(maxPixelSize: Int = 400) {
+ self.maxPixelSize = maxPixelSize
+ self.diskCacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
+ .appendingPathComponent("sh.dunkirk.Cull.thumbnails", isDirectory: true)
+
+ memoryCache.countLimit = 500
+ memoryCache.totalCostLimit = 100 * 1024 * 1024 // 100 MB
+
+ previewCache.countLimit = 110
+ previewCache.totalCostLimit = 512 * 1024 * 1024 // 512 MB
+
+ try? FileManager.default.createDirectory(at: diskCacheURL, withIntermediateDirectories: true)
+ }
+
+ // MARK: - Synchronous lookups (instant, memory only)
+
+ func cachedThumbnail(for photo: Photo) -> NSImage? {
+ memoryCache.object(forKey: photo.url.absoluteString as NSString)
+ }
+
+ func cachedPreview(for photo: Photo) -> NSImage? {
+ previewCache.object(forKey: photo.url.absoluteString as NSString)
+ }
+
+ // MARK: - Async loading
+
+ func thumbnail(for photo: Photo) async -> NSImage? {
+ let key = photo.url.absoluteString
+ let sourceURL = photo.pairedURL ?? photo.url
+
+ if let cached = memoryCache.object(forKey: key as NSString) {
+ return cached
+ }
+
+ 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: sourceURL, maxPixelSize: pixelSize) else { return nil }
+ Self.saveToDisk(extracted, at: diskPath)
+ return extracted
+ }.value
+
+ if let image {
+ memoryCache.setObject(image, forKey: key as NSString)
+ }
+ return image
+ }
+
+ func previewImage(for photo: Photo) async -> NSImage? {
+ let key = photo.url.absoluteString
+
+ if let cached = previewCache.object(forKey: key as NSString) {
+ return cached
+ }
+
+ let url = photo.pairedURL ?? photo.url
+
+ let image: NSImage? = await Task.detached(priority: .userInitiated) { () -> NSImage? in
+ Self.loadFullPreviewSync(from: url)
+ }.value
+
+ if let image {
+ previewCache.setObject(image, forKey: key as NSString)
+ previewKeys.insert(key)
+ }
+ return image
+ }
+
+ // MARK: - Preloading
+
+ /// Load all thumbnails into memory, awaiting completion. Reports progress.
+ func preloadAllThumbnails(
+ 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 totalItems = Double(thumbWork.count)
+ var completed = 0.0
+ let pixelSize = maxPixelSize
+ let diskCache = diskCacheURL
+ let mc = memoryCache
+ let batchSize = 8
+
+ for batchStart in stride(from: 0, to: thumbWork.count, by: batchSize) {
+ 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)
+ }
+
+ let totalItems = Double(work.count)
+ var completed = 0.0
+ let pc = previewCache
+ let batchSize = 4
+
+ for batchStart in stride(from: 0, to: work.count, by: batchSize) {
+ let batch = Array(work[batchStart.. NSImage? {
+ let options: [CFString: Any] = [
+ kCGImageSourceCreateThumbnailFromImageIfAbsent: true,
+ kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
+ kCGImageSourceShouldCache: false,
+ kCGImageSourceCreateThumbnailWithTransform: true
+ ]
+ guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
+ let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
+ else { return nil }
+ return NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
+ }
+
+ nonisolated private static func loadFullPreviewSync(from url: URL) -> NSImage? {
+ // Use the thumbnail API with kCGImageSourceCreateThumbnailFromImageAlways
+ // to force full decode + downscale while respecting EXIF orientation
+ let options: [CFString: Any] = [
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
+ kCGImageSourceThumbnailMaxPixelSize: 2560,
+ kCGImageSourceShouldCache: false,
+ kCGImageSourceCreateThumbnailWithTransform: true
+ ]
+ guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
+ let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
+ else { return nil }
+ return NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
+ }
+
+ // MARK: - Utilities
+
+ private func stableDiskKey(for url: URL) -> String {
+ Self.stableDiskKey(for: url)
+ }
+
+ nonisolated private static func stableDiskKey(for url: URL) -> String {
+ let data = Data(url.absoluteString.utf8)
+ let digest = SHA256.hash(data: data)
+ return digest.map { String(format: "%02x", $0) }.joined() + ".jpg"
+ }
+
+ nonisolated private static func saveToDisk(_ image: NSImage, at url: URL) {
+ guard let tiff = image.tiffRepresentation,
+ let bitmap = NSBitmapImageRep(data: tiff),
+ let jpegData = bitmap.representation(using: .jpeg, properties: [.compressionFactor: 0.7])
+ else { return }
+ try? jpegData.write(to: url)
+ }
+
+ func clearCache() {
+ memoryCache.removeAllObjects()
+ previewCache.removeAllObjects()
+ try? FileManager.default.removeItem(at: diskCacheURL)
+ try? FileManager.default.createDirectory(at: diskCacheURL, withIntermediateDirectories: true)
+ }
+}
diff --git a/cull/Views/ContentView.swift b/cull/Views/ContentView.swift
new file mode 100644
index 0000000..9257cee
--- /dev/null
+++ b/cull/Views/ContentView.swift
@@ -0,0 +1,130 @@
+import SwiftUI
+
+struct ContentView: View {
+ @Environment(CullSession.self) private var session
+ @State private var showExportSheet = false
+ @FocusState private var isViewerFocused: Bool
+
+ var body: some View {
+ Group {
+ if session.sourceFolder == nil {
+ ImportView()
+ } else if session.isImporting {
+ VStack(spacing: 16) {
+ Text("Analyzing photos...")
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ ProgressView(value: session.importProgress)
+ .frame(width: 300)
+ Text("\(Int(session.importProgress * 100))%")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else if session.groups.isEmpty {
+ VStack(spacing: 12) {
+ Image(systemName: "photo.badge.exclamationmark")
+ .font(.system(size: 48))
+ .foregroundStyle(.secondary)
+ Text("No supported photos found")
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ Button("Choose Another Folder") { session.sourceFolder = nil }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ cullingView
+ }
+ }
+ .frame(minWidth: 1000, minHeight: 600)
+ }
+
+ private var cullingView: some View {
+ HStack(spacing: 0) {
+ // Left: Groups column
+ GroupListView()
+ .frame(width: 120)
+
+ Divider()
+
+ // Middle: Photos in selected group
+ GroupDetailView()
+ .frame(width: 160)
+
+ Divider()
+
+ // Right: Large preview
+ PhotoViewer()
+ }
+ .focusable()
+ .focused($isViewerFocused)
+ .focusEffectDisabled()
+ // Narrative-style: ↑/↓ = photos, ←/→ = scenes/groups
+ .onKeyPress(.upArrow) { session.moveToPreviousPhoto(); return .handled }
+ .onKeyPress(.downArrow) { session.moveToNextPhoto(); return .handled }
+ .onKeyPress(.leftArrow) { session.moveToPreviousGroup(); return .handled }
+ .onKeyPress(.rightArrow) { session.moveToNextGroup(); return .handled }
+ .onKeyPress(keys: ["p"]) { _ in session.togglePick(); return .handled }
+ .onKeyPress(keys: ["x"]) { _ in session.toggleReject(); return .handled }
+ .onKeyPress(keys: ["0"]) { _ in session.clearRatingAndFlag(); return .handled }
+ .onKeyPress(characters: .decimalDigits) { press in
+ if let digit = Int(press.characters), (1...5).contains(digit) {
+ session.setRating(digit)
+ return .handled
+ }
+ return .ignored
+ }
+ .onKeyPress(keys: ["e"]) { _ in showExportSheet = true; return .handled }
+ .onAppear { isViewerFocused = true }
+ .onChange(of: session.selectedGroupIndex) { isViewerFocused = true }
+ .onChange(of: session.selectedPhotoIndex) { isViewerFocused = true }
+ .sheet(isPresented: $showExportSheet) {
+ ExportSheet()
+ }
+ .toolbar {
+ ToolbarItem(placement: .automatic) {
+ HStack(spacing: 4) {
+ Button { session.togglePick() } label: {
+ Image(systemName: "checkmark.circle")
+ }
+ .help("Pick (P)")
+
+ Button { session.toggleReject() } label: {
+ Image(systemName: "xmark.circle")
+ }
+ .help("Reject (X)")
+ }
+ }
+
+ ToolbarItem(placement: .automatic) {
+ HStack(spacing: 2) {
+ ForEach(1...5, id: \.self) { star in
+ Button { session.setRating(star) } label: {
+ Image(systemName: star <= (session.selectedPhoto?.rating ?? 0) ? "star.fill" : "star")
+ .foregroundStyle(star <= (session.selectedPhoto?.rating ?? 0) ? .yellow : .secondary)
+ }
+ .help("Rate \(star)")
+ }
+ }
+ }
+
+ ToolbarItem(placement: .automatic) {
+ Spacer()
+ }
+
+ ToolbarItem(placement: .automatic) {
+ Button { showExportSheet = true } label: {
+ Image(systemName: "square.and.arrow.up")
+ }
+ .help("Export (E)")
+ }
+
+ ToolbarItem(placement: .automatic) {
+ Button { session.sourceFolder = nil } label: {
+ Image(systemName: "folder")
+ }
+ .help("Open Folder")
+ }
+ }
+ }
+}
diff --git a/cull/Views/ExportSheet.swift b/cull/Views/ExportSheet.swift
new file mode 100644
index 0000000..6e005b8
--- /dev/null
+++ b/cull/Views/ExportSheet.swift
@@ -0,0 +1,133 @@
+import SwiftUI
+
+struct ExportSheet: View {
+ @Environment(CullSession.self) private var session
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var fileType: ExportFileType = .both
+ @State private var exportMode: ExportMode = .copy
+ @State private var minimumRating: Int = 1
+ @State private var pickedOnly: Bool = false
+ @State private var destination: URL?
+ @State private var isExporting: Bool = false
+ @State private var result: ExportResult?
+
+ private var eligibleCount: Int {
+ session.allPhotos.filter { photo in
+ if pickedOnly && photo.flag != .pick { return false }
+ if photo.flag == .reject { return false }
+ return photo.rating >= minimumRating
+ }.count
+ }
+
+ var body: some View {
+ VStack(spacing: 20) {
+ Text("Export Photos")
+ .font(.title2.bold())
+
+ Form {
+ Picker("File Type", selection: $fileType) {
+ ForEach(ExportFileType.allCases) { type in
+ Text(type.rawValue).tag(type)
+ }
+ }
+
+ Picker("Mode", selection: $exportMode) {
+ ForEach(ExportMode.allCases) { mode in
+ Text(mode.rawValue).tag(mode)
+ }
+ }
+
+ Picker("Minimum Rating", selection: $minimumRating) {
+ Text("All (unrated included)").tag(0)
+ ForEach(1...5, id: \.self) { rating in
+ HStack(spacing: 1) {
+ ForEach(1...rating, id: \.self) { _ in
+ Image(systemName: "star.fill")
+ .font(.caption2)
+ }
+ }
+ .tag(rating)
+ }
+ }
+
+ Toggle("Picked only", isOn: $pickedOnly)
+
+ HStack {
+ if let destination {
+ Text(destination.lastPathComponent)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ } else {
+ Text("No destination selected")
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Button("Choose...") { chooseDestination() }
+ }
+ }
+ .formStyle(.grouped)
+
+ Text("\(eligibleCount) photos will be \(exportMode == .move ? "moved" : "copied")")
+ .foregroundStyle(.secondary)
+
+ if let result {
+ VStack(spacing: 4) {
+ Text("Exported \(result.exported) files")
+ .foregroundStyle(.green)
+ if !result.errors.isEmpty {
+ Text("\(result.errors.count) errors")
+ .foregroundStyle(.red)
+ }
+ }
+ }
+
+ HStack {
+ Button("Cancel") { dismiss() }
+ .keyboardShortcut(.cancelAction)
+
+ Button("Export") { runExport() }
+ .buttonStyle(.borderedProminent)
+ .disabled(destination == nil || isExporting || eligibleCount == 0)
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ .padding(20)
+ .frame(width: 400)
+ }
+
+ private func chooseDestination() {
+ let panel = NSOpenPanel()
+ panel.canChooseDirectories = true
+ panel.canChooseFiles = false
+ panel.canCreateDirectories = true
+ panel.message = "Choose export destination"
+
+ if panel.runModal() == .OK {
+ destination = panel.url
+ }
+ }
+
+ private func runExport() {
+ guard let destination else { return }
+ isExporting = true
+
+ Task {
+ let options = ExportOptions(
+ destination: destination,
+ fileType: fileType,
+ mode: exportMode,
+ minimumRating: minimumRating,
+ includePickedOnly: pickedOnly
+ )
+ let exportResult = try? await PhotoExporter.export(
+ photos: session.allPhotos,
+ options: options
+ )
+ await MainActor.run {
+ result = exportResult
+ isExporting = false
+ }
+ }
+ }
+}
diff --git a/cull/Views/GroupDetailView.swift b/cull/Views/GroupDetailView.swift
new file mode 100644
index 0000000..e1766d6
--- /dev/null
+++ b/cull/Views/GroupDetailView.swift
@@ -0,0 +1,94 @@
+import SwiftUI
+
+struct GroupDetailView: View {
+ @Environment(CullSession.self) private var session
+ @Environment(ThumbnailCache.self) private var cache
+
+ var body: some View {
+ ScrollViewReader { proxy in
+ ScrollView {
+ if let group = session.selectedGroup {
+ LazyVStack(spacing: 2) {
+ ForEach(Array(group.photos.enumerated()), id: \.element.id) { index, photo in
+ PhotoThumbnail(
+ photo: photo,
+ isSelected: index == session.selectedPhotoIndex
+ )
+ .id(photo.id)
+ .onTapGesture {
+ session.selectPhoto(at: index)
+ }
+ }
+ }
+ .padding(4)
+ }
+ }
+ .onChange(of: session.selectedPhotoIndex) { _, _ in
+ if let photo = session.selectedPhoto {
+ withAnimation {
+ proxy.scrollTo(photo.id, anchor: .center)
+ }
+ }
+ }
+ }
+ }
+}
+
+private struct PhotoThumbnail: View {
+ let photo: Photo
+ let isSelected: Bool
+ @Environment(ThumbnailCache.self) private var cache
+ @State private var thumbnail: NSImage?
+
+ var body: some View {
+ VStack(spacing: 2) {
+ ZStack(alignment: .topLeading) {
+ if let thumbnail {
+ Image(nsImage: thumbnail)
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: 148, height: 100)
+ .clipped()
+ } else {
+ Rectangle()
+ .fill(.quaternary)
+ .frame(width: 148, height: 100)
+ }
+
+ // Flag badge
+ if photo.flag != .none {
+ Image(systemName: photo.flag == .pick ? "checkmark.circle.fill" : "xmark.circle.fill")
+ .foregroundStyle(photo.flag == .pick ? .green : .red)
+ .font(.caption)
+ .padding(4)
+ }
+ }
+
+ // Rating stars
+ if photo.rating > 0 {
+ HStack(spacing: 1) {
+ ForEach(1...5, id: \.self) { star in
+ Image(systemName: star <= photo.rating ? "star.fill" : "star")
+ .font(.system(size: 8))
+ .foregroundStyle(star <= photo.rating ? Color.yellow : Color.gray)
+ }
+ }
+ }
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ .overlay {
+ RoundedRectangle(cornerRadius: 6)
+ .strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2)
+ }
+ .opacity(photo.flag == .reject ? 0.5 : 1.0)
+ .onAppear {
+ if let cached = cache.cachedThumbnail(for: photo) {
+ thumbnail = cached
+ }
+ }
+ .task(id: photo.id) {
+ guard thumbnail == nil else { return }
+ thumbnail = await cache.thumbnail(for: photo)
+ }
+ }
+}
diff --git a/cull/Views/GroupListView.swift b/cull/Views/GroupListView.swift
new file mode 100644
index 0000000..1e1d7ac
--- /dev/null
+++ b/cull/Views/GroupListView.swift
@@ -0,0 +1,86 @@
+import SwiftUI
+
+struct GroupListView: View {
+ @Environment(CullSession.self) private var session
+ @Environment(ThumbnailCache.self) private var cache
+
+ var body: some View {
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(spacing: 2) {
+ ForEach(Array(session.groups.enumerated()), id: \.element.id) { index, group in
+ GroupThumbnail(
+ group: group,
+ index: index,
+ isSelected: index == session.selectedGroupIndex
+ )
+ .id(group.id)
+ .onTapGesture {
+ session.selectGroup(at: index)
+ }
+ }
+ }
+ .padding(4)
+ }
+ .onChange(of: session.selectedGroupIndex) { _, newIndex in
+ if let group = session.groups[safe: newIndex] {
+ withAnimation {
+ proxy.scrollTo(group.id, anchor: .center)
+ }
+ }
+ }
+ }
+ }
+}
+
+private struct GroupThumbnail: View {
+ let group: PhotoGroup
+ let index: Int
+ let isSelected: Bool
+ @Environment(ThumbnailCache.self) private var cache
+ @State private var thumbnail: NSImage?
+
+ var body: some View {
+ ZStack(alignment: .bottomTrailing) {
+ if let thumbnail {
+ Image(nsImage: thumbnail)
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: 112, height: 80)
+ .clipped()
+ } else {
+ Rectangle()
+ .fill(.quaternary)
+ .frame(width: 112, height: 80)
+ }
+
+ Text("\(group.photos.count)")
+ .font(.caption2.bold())
+ .padding(.horizontal, 5)
+ .padding(.vertical, 2)
+ .background(.ultraThinMaterial, in: Capsule())
+ .padding(4)
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ .overlay {
+ RoundedRectangle(cornerRadius: 6)
+ .strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2)
+ }
+ .onAppear {
+ guard let photo = group.representativePhoto else { return }
+ if let cached = cache.cachedThumbnail(for: photo) {
+ thumbnail = cached
+ }
+ }
+ .task(id: group.representativePhoto?.id) {
+ guard thumbnail == nil, let photo = group.representativePhoto else { return }
+ thumbnail = await cache.thumbnail(for: photo)
+ }
+ }
+}
+
+extension Collection {
+ subscript(safe index: Index) -> Element? {
+ indices.contains(index) ? self[index] : nil
+ }
+}
diff --git a/cull/Views/ImportView.swift b/cull/Views/ImportView.swift
new file mode 100644
index 0000000..8304da7
--- /dev/null
+++ b/cull/Views/ImportView.swift
@@ -0,0 +1,153 @@
+import SwiftUI
+import UniformTypeIdentifiers
+
+struct ImportView: View {
+ @Environment(CullSession.self) private var session
+ @Environment(ThumbnailCache.self) private var cache
+ @State private var isDragging = false
+
+ var body: some View {
+ VStack(spacing: 20) {
+ Image(systemName: "photo.on.rectangle.angled")
+ .font(.system(size: 64))
+ .foregroundStyle(.secondary)
+
+ Text("Open a folder of photos to start culling")
+ .font(.title2)
+ .foregroundStyle(.secondary)
+
+ Button("Choose Folder") {
+ openFolder()
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+
+ Text("or drag a folder here")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background {
+ RoundedRectangle(cornerRadius: 12)
+ .strokeBorder(isDragging ? Color.accentColor : Color.clear, lineWidth: 3)
+ .padding(20)
+ }
+ .onDrop(of: [.fileURL], isTargeted: $isDragging) { providers in
+ guard let provider = providers.first else { return false }
+ _ = provider.loadObject(ofClass: URL.self) { url, _ in
+ guard let url, url.hasDirectoryPath else { return }
+ Task { @MainActor in
+ startImport(url)
+ }
+ }
+ return true
+ }
+ }
+
+ private func openFolder() {
+ let panel = NSOpenPanel()
+ panel.canChooseDirectories = true
+ panel.canChooseFiles = false
+ panel.allowsMultipleSelection = false
+ panel.message = "Select a folder containing photos"
+
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+ startImport(url)
+ }
+
+ @MainActor
+ private func startImport(_ url: URL) {
+ session.sourceFolder = url
+ session.isImporting = true
+ session.importProgress = 0.02 // small initial bump so bar is visible
+
+ let s = session
+ let c = cache
+
+ Task {
+ do {
+ let result = try await PhotoImporter.importFolder(url)
+
+ // Feature print grouping — run off main actor
+ var lastReported = 0.0
+ let groups = await ShotGrouper.group(photos: result.photos) { p in
+ let mapped = p * 0.95
+ guard mapped - lastReported > 0.02 else { return }
+ lastReported = mapped
+ await MainActor.run {
+ withAnimation(.linear(duration: 0.3)) {
+ s.importProgress = mapped
+ }
+ }
+ }
+
+ // Phase 2: Load thumbnails into memory (95-98%)
+ let allPhotos = groups.flatMap(\.photos)
+ var lastCacheReported = 0.95
+ await c.preloadAllThumbnails(photos: allPhotos) { p in
+ let mapped = 0.95 + p * 0.03
+ guard mapped - lastCacheReported > 0.005 else { return }
+ lastCacheReported = mapped
+ await MainActor.run {
+ withAnimation(.linear(duration: 0.2)) {
+ s.importProgress = mapped
+ }
+ }
+ }
+
+ // Phase 3: Preload first 50 full-res previews (98-100%)
+ let initialPreviews = Array(allPhotos.prefix(50))
+ var lastPreviewReported = 0.98
+ await c.preloadAllPreviews(photos: initialPreviews) { p in
+ let mapped = 0.98 + p * 0.02
+ guard mapped - lastPreviewReported > 0.005 else { return }
+ lastPreviewReported = mapped
+ await MainActor.run {
+ withAnimation(.linear(duration: 0.2)) {
+ s.importProgress = mapped
+ }
+ }
+ }
+
+ await MainActor.run {
+ s.importProgress = 1.0
+ s.groups = groups
+ s.selectedGroupIndex = 0
+ s.selectedPhotoIndex = 0
+ s.isImporting = false
+ }
+
+ // Quality analysis in background — batched to avoid overwhelming GPU
+ let analysisWork: [(UUID, URL)] = allPhotos.map { ($0.id, $0.pairedURL ?? $0.url) }
+ let photosByID: [UUID: Photo] = Dictionary(uniqueKeysWithValues: allPhotos.map { ($0.id, $0) })
+ Task.detached(priority: .background) {
+ for batchStart in stride(from: 0, to: analysisWork.count, by: 4) {
+ let batch = Array(analysisWork[batchStart..