From fe627c5ab91c579b9e740873c3d9f0a446e5a462 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Tue, 24 Mar 2026 14:44:48 +0800 Subject: [PATCH] feat: show size --- cull/Models/Photo.swift | 9 ++++++++ cull/Services/PhotoImporter.swift | 36 ++++++++++++++++++++++++++++- cull/Views/PhotoViewer.swift | 38 +++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/cull/Models/Photo.swift b/cull/Models/Photo.swift index 5e1a8f5..7907ef2 100644 --- a/cull/Models/Photo.swift +++ b/cull/Models/Photo.swift @@ -26,6 +26,15 @@ final class Photo: Identifiable { /// Normalized face bounding boxes (Vision coordinates: origin bottom-left, 0-1 range) var faceRegions: [CGRect] = [] + // Image metadata (populated during import) + var pixelWidth: Int = 0 + var pixelHeight: Int = 0 + var fileSize: Int64 = 0 + // Paired file metadata (only set when pairedURL exists) + var pairedPixelWidth: Int = 0 + var pairedPixelHeight: Int = 0 + var pairedFileSize: Int64 = 0 + // Populated by ShotGrouper var captureDate: Date? diff --git a/cull/Services/PhotoImporter.swift b/cull/Services/PhotoImporter.swift index d638c85..d8961a8 100644 --- a/cull/Services/PhotoImporter.swift +++ b/cull/Services/PhotoImporter.swift @@ -60,10 +60,14 @@ struct PhotoImporter { } } - // Read EXIF dates sequentially (header-only reads are fast, ~1ms each) + // Read EXIF dates + image metadata sequentially (header-only reads are fast, ~1ms each) for photo in photos { let dateURL = photo.pairedURL ?? photo.url photo.captureDate = readCaptureDate(from: dateURL) + readImageMetadata(from: photo.url, into: photo) + if let pairedURL = photo.pairedURL { + readPairedMetadata(from: pairedURL, into: photo) + } } photos.sort { ($0.captureDate ?? .distantPast) < ($1.captureDate ?? .distantPast) } @@ -84,6 +88,36 @@ struct PhotoImporter { return formatter.date(from: dateString) } + nonisolated static func readPairedMetadata(from url: URL, into photo: Photo) { + if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attrs[.size] as? Int64 { + photo.pairedFileSize = size + } + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any], + let width = properties[kCGImagePropertyPixelWidth as String] as? Int, + let height = properties[kCGImagePropertyPixelHeight as String] as? Int + else { return } + photo.pairedPixelWidth = width + photo.pairedPixelHeight = height + } + + nonisolated static func readImageMetadata(from url: URL, into photo: Photo) { + // File size + if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attrs[.size] as? Int64 { + photo.fileSize = size + } + // Pixel dimensions from image header (no full decode) + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any], + let width = properties[kCGImagePropertyPixelWidth as String] as? Int, + let height = properties[kCGImagePropertyPixelHeight as String] as? Int + else { return } + photo.pixelWidth = width + photo.pixelHeight = height + } + static func isRAWExtension(_ ext: String) -> Bool { let raw: Set = ["cr2", "cr3", "arw", "nef", "dng", "raf", "orf", "rw2"] return raw.contains(ext.lowercased()) diff --git a/cull/Views/PhotoViewer.swift b/cull/Views/PhotoViewer.swift index 020ba34..fcadc9c 100644 --- a/cull/Views/PhotoViewer.swift +++ b/cull/Views/PhotoViewer.swift @@ -53,6 +53,31 @@ struct PhotoViewer: View { .foregroundStyle(.red) } + // Resolution & file size + if photo.pixelWidth > 0 { + HStack(spacing: 8) { + // Primary file (RAW or standalone) + HStack(spacing: 4) { + Text(photo.url.pathExtension.uppercased()) + .fontWeight(.medium) + Text("\(photo.pixelWidth)×\(photo.pixelHeight)") + Text(Self.formatFileSize(photo.fileSize)) + } + // Paired file (JPEG sidecar) + if photo.pairedURL != nil, photo.pairedPixelWidth > 0 { + Text("·") + HStack(spacing: 4) { + Text(photo.pairedURL!.pathExtension.uppercased()) + .fontWeight(.medium) + Text("\(photo.pairedPixelWidth)×\(photo.pairedPixelHeight)") + Text(Self.formatFileSize(photo.pairedFileSize)) + } + } + } + .foregroundStyle(.white.opacity(0.5)) + .font(.caption) + } + Spacer() // Quality scores @@ -225,6 +250,19 @@ struct PhotoViewer: View { return blur < median * 0.4 } + // MARK: - Formatting + + private static func formatFileSize(_ bytes: Int64) -> String { + if bytes >= 1_073_741_824 { + return String(format: "%.1f GB", Double(bytes) / 1_073_741_824) + } else if bytes >= 1_048_576 { + return String(format: "%.1f MB", Double(bytes) / 1_048_576) + } else if bytes >= 1024 { + return String(format: "%.0f KB", Double(bytes) / 1024) + } + return "\(bytes) B" + } + // MARK: - Zoom calculations private struct ZoomInfo { -- 2.51.2