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 menu bar

+208 -102
+103
cull/CullApp.swift
··· 12 12 .environment(thumbnailCache) 13 13 } 14 14 .windowStyle(.automatic) 15 + .commands { 16 + // Replace default File menu items 17 + CommandGroup(replacing: .newItem) { 18 + Button("Open Folder...") { 19 + openFolder() 20 + } 21 + .keyboardShortcut("o") 22 + 23 + Divider() 24 + 25 + Button("Export...") { 26 + NotificationCenter.default.post(name: .showExport, object: nil) 27 + } 28 + .keyboardShortcut("e") 29 + .disabled(session.groups.isEmpty) 30 + 31 + Divider() 32 + 33 + Button("Close Folder") { 34 + session.sourceFolder = nil 35 + session.groups = [] 36 + thumbnailCache.clearCache() 37 + } 38 + .keyboardShortcut("w") 39 + .disabled(session.sourceFolder == nil) 40 + } 41 + 42 + // Photo menu 43 + CommandMenu("Photo") { 44 + Button("Pick") { 45 + session.togglePick() 46 + } 47 + .keyboardShortcut("p", modifiers: []) 48 + .disabled(session.selectedPhoto == nil) 49 + 50 + Button("Reject") { 51 + session.toggleReject() 52 + } 53 + .keyboardShortcut("x", modifiers: []) 54 + .disabled(session.selectedPhoto == nil) 55 + 56 + Button("Clear Rating & Flag") { 57 + session.clearRatingAndFlag() 58 + } 59 + .keyboardShortcut("0", modifiers: []) 60 + .disabled(session.selectedPhoto == nil) 61 + 62 + Divider() 63 + 64 + ForEach(1...5, id: \.self) { star in 65 + Button("Rate \(star) Star\(star > 1 ? "s" : "")") { 66 + session.setRating(star) 67 + } 68 + .keyboardShortcut(KeyEquivalent(Character("\(star)")), modifiers: []) 69 + .disabled(session.selectedPhoto == nil) 70 + } 71 + } 72 + 73 + // Navigate menu 74 + CommandMenu("Navigate") { 75 + Button("Next Photo") { 76 + session.moveToNextPhoto() 77 + } 78 + .keyboardShortcut(.downArrow, modifiers: []) 79 + .disabled(session.groups.isEmpty) 80 + 81 + Button("Previous Photo") { 82 + session.moveToPreviousPhoto() 83 + } 84 + .keyboardShortcut(.upArrow, modifiers: []) 85 + .disabled(session.groups.isEmpty) 86 + 87 + Divider() 88 + 89 + Button("Next Group") { 90 + session.moveToNextGroup() 91 + } 92 + .keyboardShortcut(.rightArrow, modifiers: []) 93 + .disabled(session.groups.isEmpty) 94 + 95 + Button("Previous Group") { 96 + session.moveToPreviousGroup() 97 + } 98 + .keyboardShortcut(.leftArrow, modifiers: []) 99 + .disabled(session.groups.isEmpty) 100 + } 101 + } 15 102 } 103 + 104 + private func openFolder() { 105 + let panel = NSOpenPanel() 106 + panel.canChooseDirectories = true 107 + panel.canChooseFiles = false 108 + panel.allowsMultipleSelection = false 109 + panel.message = "Select a folder containing photos" 110 + 111 + guard panel.runModal() == .OK, let url = panel.url else { return } 112 + NotificationCenter.default.post(name: .openFolder, object: url) 113 + } 114 + } 115 + 116 + extension Notification.Name { 117 + static let openFolder = Notification.Name("openFolder") 118 + static let showExport = Notification.Name("showExport") 16 119 }
+103
cull/Views/ContentView.swift
··· 2 2 3 3 struct ContentView: View { 4 4 @Environment(CullSession.self) private var session 5 + @Environment(ThumbnailCache.self) private var cache 5 6 @State private var showExportSheet = false 6 7 @FocusState private var isViewerFocused: Bool 7 8 ··· 37 38 } 38 39 } 39 40 .frame(minWidth: 1000, minHeight: 600) 41 + .onReceive(NotificationCenter.default.publisher(for: .openFolder)) { notification in 42 + guard let url = notification.object as? URL else { return } 43 + startImport(url) 44 + } 45 + .onReceive(NotificationCenter.default.publisher(for: .showExport)) { _ in 46 + showExportSheet = true 47 + } 48 + } 49 + 50 + @MainActor 51 + private func startImport(_ url: URL) { 52 + session.sourceFolder = url 53 + session.isImporting = true 54 + session.importProgress = 0.02 55 + cache.clearCache() 56 + 57 + let s = session 58 + let c = cache 59 + 60 + Task { 61 + do { 62 + let result = try await PhotoImporter.importFolder(url) 63 + 64 + var lastReported = 0.0 65 + let groups = await ShotGrouper.group(photos: result.photos) { p in 66 + let mapped = p * 0.95 67 + guard mapped - lastReported > 0.02 else { return } 68 + lastReported = mapped 69 + await MainActor.run { 70 + withAnimation(.linear(duration: 0.3)) { 71 + s.importProgress = mapped 72 + } 73 + } 74 + } 75 + 76 + let allPhotos = groups.flatMap(\.photos) 77 + var lastCacheReported = 0.95 78 + 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 82 + await MainActor.run { 83 + withAnimation(.linear(duration: 0.2)) { 84 + s.importProgress = mapped 85 + } 86 + } 87 + } 88 + 89 + let ahead = Array(allPhotos.prefix(30)) 90 + let behind = Array(allPhotos.suffix(30)) 91 + let initialPreviews = ahead + behind.reversed() 92 + var lastPreviewReported = 0.98 93 + 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 97 + await MainActor.run { 98 + withAnimation(.linear(duration: 0.2)) { 99 + s.importProgress = mapped 100 + } 101 + } 102 + } 103 + 104 + await MainActor.run { 105 + s.importProgress = 1.0 106 + s.groups = groups 107 + s.selectedGroupIndex = 0 108 + s.selectedPhotoIndex = 0 109 + s.isImporting = false 110 + } 111 + 112 + let analysisWork: [(UUID, URL)] = allPhotos.map { ($0.id, $0.pairedURL ?? $0.url) } 113 + let photosByID: [UUID: Photo] = Dictionary(uniqueKeysWithValues: allPhotos.map { ($0.id, $0) }) 114 + Task.detached(priority: .background) { 115 + for batchStart in stride(from: 0, to: analysisWork.count, by: 4) { 116 + let batch = Array(analysisWork[batchStart..<min(batchStart + 4, analysisWork.count)]) 117 + await withTaskGroup(of: (UUID, Double?, Double?).self) { group in 118 + for (id, url) in batch { 119 + group.addTask { 120 + let blur = await QualityAnalyzer.analyzeBlur(imageURL: url) 121 + let face = await QualityAnalyzer.analyzeFaceQuality(imageURL: url) 122 + return (id, blur, face) 123 + } 124 + } 125 + for await (id, blur, face) in group { 126 + await MainActor.run { 127 + if let photo = photosByID[id] { 128 + photo.blurScore = blur 129 + photo.faceQualityScore = face 130 + } 131 + } 132 + } 133 + } 134 + } 135 + } 136 + } catch { 137 + await MainActor.run { 138 + s.sourceFolder = nil 139 + s.isImporting = false 140 + } 141 + } 142 + } 40 143 } 41 144 42 145 private var cullingView: some View {
+2 -102
cull/Views/ImportView.swift
··· 2 2 import UniformTypeIdentifiers 3 3 4 4 struct ImportView: View { 5 - @Environment(CullSession.self) private var session 6 - @Environment(ThumbnailCache.self) private var cache 7 5 @State private var isDragging = false 8 6 9 7 var body: some View { ··· 37 35 _ = provider.loadObject(ofClass: URL.self) { url, _ in 38 36 guard let url, url.hasDirectoryPath else { return } 39 37 Task { @MainActor in 40 - startImport(url) 38 + NotificationCenter.default.post(name: .openFolder, object: url) 41 39 } 42 40 } 43 41 return true ··· 52 50 panel.message = "Select a folder containing photos" 53 51 54 52 guard panel.runModal() == .OK, let url = panel.url else { return } 55 - startImport(url) 56 - } 57 - 58 - @MainActor 59 - private func startImport(_ url: URL) { 60 - session.sourceFolder = url 61 - session.isImporting = true 62 - session.importProgress = 0.02 // small initial bump so bar is visible 63 - 64 - let s = session 65 - let c = cache 66 - 67 - Task { 68 - do { 69 - let result = try await PhotoImporter.importFolder(url) 70 - 71 - // Feature print grouping — run off main actor 72 - var lastReported = 0.0 73 - let groups = await ShotGrouper.group(photos: result.photos) { p in 74 - let mapped = p * 0.95 75 - guard mapped - lastReported > 0.02 else { return } 76 - lastReported = mapped 77 - await MainActor.run { 78 - withAnimation(.linear(duration: 0.3)) { 79 - s.importProgress = mapped 80 - } 81 - } 82 - } 83 - 84 - // Phase 2: Load thumbnails into memory (95-98%) 85 - let allPhotos = groups.flatMap(\.photos) 86 - var lastCacheReported = 0.95 87 - await c.preloadAllThumbnails(photos: allPhotos) { p in 88 - let mapped = 0.95 + p * 0.03 89 - guard mapped - lastCacheReported > 0.005 else { return } 90 - lastCacheReported = mapped 91 - await MainActor.run { 92 - withAnimation(.linear(duration: 0.2)) { 93 - s.importProgress = mapped 94 - } 95 - } 96 - } 97 - 98 - // Phase 3: Preload initial full-res previews (98-100%) 99 - let ahead = Array(allPhotos.prefix(30)) 100 - let behind = Array(allPhotos.suffix(30)) 101 - let initialPreviews = ahead + behind.reversed() 102 - var lastPreviewReported = 0.98 103 - await c.preloadAllPreviews(photos: initialPreviews) { p in 104 - let mapped = 0.98 + p * 0.02 105 - guard mapped - lastPreviewReported > 0.005 else { return } 106 - lastPreviewReported = mapped 107 - await MainActor.run { 108 - withAnimation(.linear(duration: 0.2)) { 109 - s.importProgress = mapped 110 - } 111 - } 112 - } 113 - 114 - await MainActor.run { 115 - s.importProgress = 1.0 116 - s.groups = groups 117 - s.selectedGroupIndex = 0 118 - s.selectedPhotoIndex = 0 119 - s.isImporting = false 120 - } 121 - 122 - // Quality analysis in background — batched to avoid overwhelming GPU 123 - let analysisWork: [(UUID, URL)] = allPhotos.map { ($0.id, $0.pairedURL ?? $0.url) } 124 - let photosByID: [UUID: Photo] = Dictionary(uniqueKeysWithValues: allPhotos.map { ($0.id, $0) }) 125 - Task.detached(priority: .background) { 126 - for batchStart in stride(from: 0, to: analysisWork.count, by: 4) { 127 - let batch = Array(analysisWork[batchStart..<min(batchStart + 4, analysisWork.count)]) 128 - await withTaskGroup(of: (UUID, Double?, Double?).self) { group in 129 - for (id, url) in batch { 130 - group.addTask { 131 - let blur = await QualityAnalyzer.analyzeBlur(imageURL: url) 132 - let face = await QualityAnalyzer.analyzeFaceQuality(imageURL: url) 133 - return (id, blur, face) 134 - } 135 - } 136 - for await (id, blur, face) in group { 137 - await MainActor.run { 138 - if let photo = photosByID[id] { 139 - photo.blurScore = blur 140 - photo.faceQualityScore = face 141 - } 142 - } 143 - } 144 - } 145 - } 146 - } 147 - } catch { 148 - await MainActor.run { 149 - s.sourceFolder = nil 150 - s.isImporting = false 151 - } 152 - } 153 - } 53 + NotificationCenter.default.post(name: .openFolder, object: url) 154 54 } 155 55 }