its whats on the tin; culls raw photos
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add nicer progress bar

+68 -37
+1
cull/Models/CullSession.swift
··· 10 10 11 11 var isImporting: Bool = false 12 12 var importProgress: Double = 0 13 + var importStatus: String = "" 13 14 14 15 var selectedGroup: PhotoGroup? { 15 16 guard groups.indices.contains(selectedGroupIndex) else { return nil }
+18 -15
cull/Services/ShotGrouper.swift
··· 21 21 let totalWork = Double(photos.count) 22 22 var completed = 0.0 23 23 24 - // Step 2: Generate feature prints for all photos 24 + // Step 2: Generate feature prints for all photos (batched to report smooth progress) 25 + let fpWork: [(UUID, URL)] = photos.map { ($0.id, $0.pairedURL ?? $0.url) } 25 26 var featurePrintMap: [UUID: VNFeaturePrintObservation] = [:] 26 - await withTaskGroup(of: (UUID, VNFeaturePrintObservation?).self) { group in 27 - for photo in photos { 28 - let id = photo.id 29 - group.addTask { 30 - let fp = await generateFeaturePrint(for: photo) 31 - return (id, fp) 27 + let batchSize = 8 28 + for batchStart in stride(from: 0, to: fpWork.count, by: batchSize) { 29 + let batch = Array(fpWork[batchStart..<min(batchStart + batchSize, fpWork.count)]) 30 + await withTaskGroup(of: (UUID, VNFeaturePrintObservation?).self) { group in 31 + for (id, url) in batch { 32 + group.addTask { 33 + let fp = await generateFeaturePrint(url: url) 34 + return (id, fp) 35 + } 32 36 } 33 - } 34 - for await (id, fp) in group { 35 - if let fp { featurePrintMap[id] = fp } 36 - completed += 1 37 - if let progress { 38 - await progress(completed / totalWork) 37 + for await (id, fp) in group { 38 + if let fp { featurePrintMap[id] = fp } 39 + completed += 1 40 + if let progress { 41 + await progress(completed / totalWork) 42 + } 39 43 } 40 44 } 41 45 } ··· 173 177 return distance < similarityThreshold 174 178 } 175 179 176 - private static func generateFeaturePrint(for photo: Photo) async -> VNFeaturePrintObservation? { 177 - let url = photo.pairedURL ?? photo.url 180 + private static func generateFeaturePrint(url: URL) async -> VNFeaturePrintObservation? { 178 181 guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } 179 182 180 183 let options: [CFString: Any] = [
+41 -21
cull/Views/ContentView.swift
··· 11 11 if session.sourceFolder == nil { 12 12 ImportView() 13 13 } else if session.isImporting { 14 - VStack(spacing: 16) { 15 - Text("Analyzing photos...") 16 - .font(.title3) 17 - .foregroundStyle(.secondary) 18 - ProgressView(value: session.importProgress) 19 - .frame(width: 300) 20 - Text("\(Int(session.importProgress * 100))%") 21 - .font(.caption) 22 - .foregroundStyle(.tertiary) 23 - } 24 - .frame(maxWidth: .infinity, maxHeight: .infinity) 14 + ImportProgressView(status: session.importStatus, progress: session.importProgress) 15 + .frame(maxWidth: .infinity, maxHeight: .infinity) 25 16 } else if session.groups.isEmpty { 26 17 VStack(spacing: 12) { 27 18 Image(systemName: "photo.badge.exclamationmark") ··· 59 50 60 51 Task { 61 52 do { 53 + await MainActor.run { s.importStatus = "Scanning photos..." } 62 54 let result = try await PhotoImporter.importFolder(url) 63 55 56 + await MainActor.run { s.importStatus = "Grouping similar shots..." } 57 + // Phase 1: Feature print grouping (0-30%) 64 58 var lastReported = 0.0 65 59 let groups = await ShotGrouper.group(photos: result.photos) { p in 66 - let mapped = p * 0.95 67 - guard mapped - lastReported > 0.02 else { return } 60 + let mapped = p * 0.30 61 + guard mapped - lastReported > 0.01 else { return } 68 62 lastReported = mapped 69 63 await MainActor.run { 70 64 withAnimation(.linear(duration: 0.3)) { ··· 73 67 } 74 68 } 75 69 70 + await MainActor.run { s.importStatus = "Generating thumbnails..." } 71 + // Phase 2: Thumbnails (30-60%) 76 72 let allPhotos = groups.flatMap(\.photos) 77 - var lastCacheReported = 0.95 78 73 await c.preloadAllThumbnails(photos: allPhotos) { p in 79 - let mapped = 0.95 + p * 0.03 80 - guard mapped - lastCacheReported > 0.005 else { return } 81 - lastCacheReported = mapped 74 + let mapped = 0.30 + p * 0.30 82 75 await MainActor.run { 83 76 withAnimation(.linear(duration: 0.2)) { 84 77 s.importProgress = mapped ··· 86 79 } 87 80 } 88 81 82 + await MainActor.run { s.importStatus = "Loading previews..." } 83 + // Phase 3: Initial full-res previews (60-100%) 89 84 let ahead = Array(allPhotos.prefix(30)) 90 85 let behind = Array(allPhotos.suffix(30)) 91 86 let initialPreviews = ahead + behind.reversed() 92 - var lastPreviewReported = 0.98 93 87 await c.preloadAllPreviews(photos: initialPreviews) { p in 94 - let mapped = 0.98 + p * 0.02 95 - guard mapped - lastPreviewReported > 0.005 else { return } 96 - lastPreviewReported = mapped 88 + let mapped = 0.60 + p * 0.40 97 89 await MainActor.run { 98 90 withAnimation(.linear(duration: 0.2)) { 99 91 s.importProgress = mapped ··· 231 223 } 232 224 } 233 225 } 226 + 227 + struct ImportProgressView: View { 228 + let status: String 229 + let progress: Double 230 + 231 + var body: some View { 232 + TimelineView(.periodic(from: .now, by: 0.4)) { timeline in 233 + let base = status.replacingOccurrences(of: "...", with: "") 234 + let dotCount = base.isEmpty ? 0 : Int(timeline.date.timeIntervalSinceReferenceDate / 0.4) % 4 235 + let visible = String(repeating: ".", count: dotCount) 236 + let invisible = String(repeating: ".", count: 3 - dotCount) 237 + 238 + VStack(spacing: 16) { 239 + HStack(spacing: 0) { 240 + Text(base + visible) 241 + Text(invisible).hidden() 242 + } 243 + .font(.title3) 244 + .foregroundStyle(.secondary) 245 + ProgressView(value: progress) 246 + .frame(width: 300) 247 + Text("\(Int(progress * 100))%") 248 + .font(.caption) 249 + .foregroundStyle(.tertiary) 250 + } 251 + } 252 + } 253 + }
+8 -1
cull/Views/ImportView.swift
··· 14 14 .font(.title2) 15 15 .foregroundStyle(.secondary) 16 16 17 - Button("Choose Folder") { 17 + Button { 18 18 openFolder() 19 + } label: { 20 + HStack(spacing: 8) { 21 + Text("Open Folder") 22 + Text("\u{2318}O") 23 + .foregroundStyle(.secondary) 24 + } 19 25 } 20 26 .buttonStyle(.borderedProminent) 21 27 .controlSize(.large) 28 + .keyboardShortcut("o") 22 29 23 30 Text("or drag a folder here") 24 31 .font(.caption)