import Foundation import SwiftUI /// One row in the drop list. ObservableObject so each row updates independently /// as its async compression finishes. final class ImageItem: ObservableObject, Identifiable { let id = UUID() let url: URL @Published var status: Status = .pending @Published var originalBytes: Int = 0 @Published var newBytes: Int = 0 @Published var message: String = "" @Published var thumb: NSImage? @Published var outputURL: URL? enum Status { case pending, working, done, failed, skipped } init(url: URL) { self.url = url self.originalBytes = (try? FileManager.default .attributesOfItem(atPath: url.path)[.size] as? Int) ?? 0 loadThumb() } /// 32px preview, decoded off the main thread so big folders don't stutter. private func loadThumb() { DispatchQueue.global(qos: .utility).async { [weak self] in guard let self, let img = NSImage(contentsOf: self.url) else { return } let t = NSImage(size: NSSize(width: 32, height: 32)) t.lockFocus(); img.draw(in: NSRect(x: 0, y: 0, width: 32, height: 32)); t.unlockFocus() DispatchQueue.main.async { self.thumb = t } } } var savedFraction: Double { originalBytes > 0 && newBytes > 0 ? 1 - Double(newBytes) / Double(originalBytes) : 0 } } final class AppModel: ObservableObject { @Published var items: [ImageItem] = [] @Published var format: Compressor.Format = .jxl @Published var lossless = false @Published var distance = 1.0 @Published var effort = 7 @Published var keepOriginal = true @Published var busy = false var totalSaved: Int { items.reduce(0) { $0 + max(0, $1.originalBytes - $1.newBytes) } } /// Overall savings across all finished files, matching ImageOptim's bottom-bar %. var overallSavedPercent: Double { let done = items.filter { $0.newBytes > 0 } let orig = done.reduce(0) { $0 + $1.originalBytes } let now = done.reduce(0) { $0 + $1.newBytes } return orig > 0 ? (1 - Double(now) / Double(orig)) * 100 : 0 } var encoderReady: Bool { Compressor.path(for: format) != nil } func add(urls: [URL]) { for url in expand(urls) { let ext = url.pathExtension.lowercased() guard Compressor.supportedExtensions.contains(ext) else { continue } guard !items.contains(where: { $0.url == url }) else { continue } items.append(ImageItem(url: url)) } run() } /// Drop a folder → recurse into it; drop a file → keep it. Skips hidden /// files. Mirrors ImageOptim's "add a directory" behaviour. private func expand(_ urls: [URL]) -> [URL] { let fm = FileManager.default var out: [URL] = [] for url in urls { var isDir: ObjCBool = false guard fm.fileExists(atPath: url.path, isDirectory: &isDir) else { continue } if isDir.boolValue { let opts: FileManager.DirectoryEnumerationOptions = [.skipsHiddenFiles, .skipsPackageDescendants] let en = fm.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey], options: opts) while let f = en?.nextObject() as? URL { if (try? f.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true { out.append(f) } } } else { out.append(url) } } return out } private let queue: OperationQueue = { let q = OperationQueue() // One cjxl per core; cjxl itself is single-threaded here so the OS keeps cores busy. q.maxConcurrentOperationCount = max(1, ProcessInfo.processInfo.activeProcessorCount) return q }() func run() { let opts = Compressor.Options(format: format, lossless: lossless, distance: distance, effort: effort, keepOriginal: keepOriginal) let pending = items.filter { $0.status == .pending } guard !pending.isEmpty else { return } busy = true for item in pending { DispatchQueue.main.async { item.status = .working } queue.addOperation { let result: ImageItem.Status do { let r = try Compressor.compress(item.url, options: opts) DispatchQueue.main.async { item.newBytes = r.newBytes; item.outputURL = r.outputURL } result = r.savedFraction > 0 ? .done : .skipped } catch { DispatchQueue.main.async { item.message = error.localizedDescription } result = .failed } DispatchQueue.main.async { item.status = result self.busy = self.items.contains { $0.status == .working || $0.status == .pending } } } } } } func human(_ bytes: Int) -> String { ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file) }