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: show size

+82 -1
+9
cull/Models/Photo.swift
··· 26 26 /// Normalized face bounding boxes (Vision coordinates: origin bottom-left, 0-1 range) 27 27 var faceRegions: [CGRect] = [] 28 28 29 + // Image metadata (populated during import) 30 + var pixelWidth: Int = 0 31 + var pixelHeight: Int = 0 32 + var fileSize: Int64 = 0 33 + // Paired file metadata (only set when pairedURL exists) 34 + var pairedPixelWidth: Int = 0 35 + var pairedPixelHeight: Int = 0 36 + var pairedFileSize: Int64 = 0 37 + 29 38 // Populated by ShotGrouper 30 39 var captureDate: Date? 31 40
+35 -1
cull/Services/PhotoImporter.swift
··· 60 60 } 61 61 } 62 62 63 - // Read EXIF dates sequentially (header-only reads are fast, ~1ms each) 63 + // Read EXIF dates + image metadata sequentially (header-only reads are fast, ~1ms each) 64 64 for photo in photos { 65 65 let dateURL = photo.pairedURL ?? photo.url 66 66 photo.captureDate = readCaptureDate(from: dateURL) 67 + readImageMetadata(from: photo.url, into: photo) 68 + if let pairedURL = photo.pairedURL { 69 + readPairedMetadata(from: pairedURL, into: photo) 70 + } 67 71 } 68 72 69 73 photos.sort { ($0.captureDate ?? .distantPast) < ($1.captureDate ?? .distantPast) } ··· 82 86 formatter.dateFormat = "yyyy:MM:dd HH:mm:ss" 83 87 formatter.locale = Locale(identifier: "en_US_POSIX") 84 88 return formatter.date(from: dateString) 89 + } 90 + 91 + nonisolated static func readPairedMetadata(from url: URL, into photo: Photo) { 92 + if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), 93 + let size = attrs[.size] as? Int64 { 94 + photo.pairedFileSize = size 95 + } 96 + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), 97 + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any], 98 + let width = properties[kCGImagePropertyPixelWidth as String] as? Int, 99 + let height = properties[kCGImagePropertyPixelHeight as String] as? Int 100 + else { return } 101 + photo.pairedPixelWidth = width 102 + photo.pairedPixelHeight = height 103 + } 104 + 105 + nonisolated static func readImageMetadata(from url: URL, into photo: Photo) { 106 + // File size 107 + if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), 108 + let size = attrs[.size] as? Int64 { 109 + photo.fileSize = size 110 + } 111 + // Pixel dimensions from image header (no full decode) 112 + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), 113 + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any], 114 + let width = properties[kCGImagePropertyPixelWidth as String] as? Int, 115 + let height = properties[kCGImagePropertyPixelHeight as String] as? Int 116 + else { return } 117 + photo.pixelWidth = width 118 + photo.pixelHeight = height 85 119 } 86 120 87 121 static func isRAWExtension(_ ext: String) -> Bool {
+38
cull/Views/PhotoViewer.swift
··· 53 53 .foregroundStyle(.red) 54 54 } 55 55 56 + // Resolution & file size 57 + if photo.pixelWidth > 0 { 58 + HStack(spacing: 8) { 59 + // Primary file (RAW or standalone) 60 + HStack(spacing: 4) { 61 + Text(photo.url.pathExtension.uppercased()) 62 + .fontWeight(.medium) 63 + Text("\(photo.pixelWidth)×\(photo.pixelHeight)") 64 + Text(Self.formatFileSize(photo.fileSize)) 65 + } 66 + // Paired file (JPEG sidecar) 67 + if photo.pairedURL != nil, photo.pairedPixelWidth > 0 { 68 + Text("·") 69 + HStack(spacing: 4) { 70 + Text(photo.pairedURL!.pathExtension.uppercased()) 71 + .fontWeight(.medium) 72 + Text("\(photo.pairedPixelWidth)×\(photo.pairedPixelHeight)") 73 + Text(Self.formatFileSize(photo.pairedFileSize)) 74 + } 75 + } 76 + } 77 + .foregroundStyle(.white.opacity(0.5)) 78 + .font(.caption) 79 + } 80 + 56 81 Spacer() 57 82 58 83 // Quality scores ··· 223 248 guard peerScores.count >= 2 else { return false } 224 249 let median = peerScores.sorted()[peerScores.count / 2] 225 250 return blur < median * 0.4 251 + } 252 + 253 + // MARK: - Formatting 254 + 255 + private static func formatFileSize(_ bytes: Int64) -> String { 256 + if bytes >= 1_073_741_824 { 257 + return String(format: "%.1f GB", Double(bytes) / 1_073_741_824) 258 + } else if bytes >= 1_048_576 { 259 + return String(format: "%.1f MB", Double(bytes) / 1_048_576) 260 + } else if bytes >= 1024 { 261 + return String(format: "%.0f KB", Double(bytes) / 1024) 262 + } 263 + return "\(bytes) B" 226 264 } 227 265 228 266 // MARK: - Zoom calculations