diff --git a/cull/Models/CullSession.swift b/cull/Models/CullSession.swift --- a/cull/Models/CullSession.swift +++ b/cull/Models/CullSession.swift @@ -10,6 +10,7 @@ var selectedPhotoIndex: Int = 0 var isImporting: Bool = false var importProgress: Double = 0 + var importStatus: String = "" var selectedGroup: PhotoGroup? { guard groups.indices.contains(selectedGroupIndex) else { return nil } diff --git a/cull/Services/ShotGrouper.swift b/cull/Services/ShotGrouper.swift --- a/cull/Services/ShotGrouper.swift +++ b/cull/Services/ShotGrouper.swift @@ -21,21 +21,25 @@ let timeClusters = clusterByTime(photos) let totalWork = Double(photos.count) var completed = 0.0 - // Step 2: Generate feature prints for all photos + // Step 2: Generate feature prints for all photos (batched to report smooth progress) + let fpWork: [(UUID, URL)] = photos.map { ($0.id, $0.pairedURL ?? $0.url) } 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) + let batchSize = 8 + for batchStart in stride(from: 0, to: fpWork.count, by: batchSize) { + let batch = Array(fpWork[batchStart.. VNFeaturePrintObservation? { - let url = photo.pairedURL ?? photo.url + private static func generateFeaturePrint(url: URL) async -> VNFeaturePrintObservation? { guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } let options: [CFString: Any] = [ diff --git a/cull/Views/ContentView.swift b/cull/Views/ContentView.swift --- a/cull/Views/ContentView.swift +++ b/cull/Views/ContentView.swift @@ -11,17 +11,8 @@ 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) + ImportProgressView(status: session.importStatus, progress: session.importProgress) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else if session.groups.isEmpty { VStack(spacing: 12) { Image(systemName: "photo.badge.exclamationmark") @@ -59,12 +50,15 @@ let c = cache Task { do { + await MainActor.run { s.importStatus = "Scanning photos..." } let result = try await PhotoImporter.importFolder(url) + await MainActor.run { s.importStatus = "Grouping similar shots..." } + // Phase 1: Feature print grouping (0-30%) 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 } + let mapped = p * 0.30 + guard mapped - lastReported > 0.01 else { return } lastReported = mapped await MainActor.run { withAnimation(.linear(duration: 0.3)) { @@ -73,12 +67,11 @@ } } } + await MainActor.run { s.importStatus = "Generating thumbnails..." } + // Phase 2: Thumbnails (30-60%) 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 + let mapped = 0.30 + p * 0.30 await MainActor.run { withAnimation(.linear(duration: 0.2)) { s.importProgress = mapped @@ -86,14 +79,13 @@ } } } + await MainActor.run { s.importStatus = "Loading previews..." } + // Phase 3: Initial full-res previews (60-100%) let ahead = Array(allPhotos.prefix(30)) let behind = Array(allPhotos.suffix(30)) let initialPreviews = ahead + behind.reversed() - 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 + let mapped = 0.60 + p * 0.40 await MainActor.run { withAnimation(.linear(duration: 0.2)) { s.importProgress = mapped @@ -231,3 +223,31 @@ } } } } + +struct ImportProgressView: View { + let status: String + let progress: Double + + var body: some View { + TimelineView(.periodic(from: .now, by: 0.4)) { timeline in + let base = status.replacingOccurrences(of: "...", with: "") + let dotCount = base.isEmpty ? 0 : Int(timeline.date.timeIntervalSinceReferenceDate / 0.4) % 4 + let visible = String(repeating: ".", count: dotCount) + let invisible = String(repeating: ".", count: 3 - dotCount) + + VStack(spacing: 16) { + HStack(spacing: 0) { + Text(base + visible) + Text(invisible).hidden() + } + .font(.title3) + .foregroundStyle(.secondary) + ProgressView(value: progress) + .frame(width: 300) + Text("\(Int(progress * 100))%") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } +} diff --git a/cull/Views/ImportView.swift b/cull/Views/ImportView.swift --- a/cull/Views/ImportView.swift +++ b/cull/Views/ImportView.swift @@ -14,11 +14,18 @@ Text("Open a folder of photos to start culling") .font(.title2) .foregroundStyle(.secondary) - Button("Choose Folder") { + Button { openFolder() + } label: { + HStack(spacing: 8) { + Text("Open Folder") + Text("\u{2318}O") + .foregroundStyle(.secondary) + } } .buttonStyle(.borderedProminent) .controlSize(.large) + .keyboardShortcut("o") Text("or drag a folder here") .font(.caption)