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)
|
|
}
|