You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

128 lines
5.0 KiB

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