|
|
import Foundation
|
|
|
|
|
|
/// Re-encodes images to one of three modern formats, shelling out to the
|
|
|
/// matching CLI encoder. Original vs. result byte size is reported back so the
|
|
|
/// UI can show ImageOptim-style savings.
|
|
|
enum Compressor {
|
|
|
|
|
|
/// Output target. Each maps to a distinct encoder + flag dialect.
|
|
|
enum Format: String, CaseIterable, Identifiable {
|
|
|
case jxl, avif, webp
|
|
|
var id: String { rawValue }
|
|
|
var ext: String { rawValue }
|
|
|
var label: String { rawValue.uppercased() }
|
|
|
/// cli name, brew formula — for path lookup + install hint.
|
|
|
var encoder: (cmd: String, brew: String) {
|
|
|
switch self {
|
|
|
case .jxl: return ("cjxl", "jpeg-xl")
|
|
|
case .avif: return ("avifenc", "libavif")
|
|
|
case .webp: return ("cwebp", "webp")
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
struct Options {
|
|
|
var format: Format = .jxl
|
|
|
var lossless: Bool = false
|
|
|
/// Visual distance (0 = lossless, ~1 = visually lossless, higher = smaller/worse).
|
|
|
var distance: Double = 1.0
|
|
|
/// Encoder effort 1..9 — higher is slower but smaller.
|
|
|
var effort: Int = 7
|
|
|
var keepOriginal: Bool = true
|
|
|
}
|
|
|
|
|
|
enum CompressError: LocalizedError {
|
|
|
case encoderMissing(String)
|
|
|
case failed(String)
|
|
|
var errorDescription: String? {
|
|
|
switch self {
|
|
|
case .encoderMissing(let brew): return "encoder not found. Install: brew install \(brew)"
|
|
|
case .failed(let msg): return msg
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
static let supportedExtensions: Set<String> = ["jpg", "jpeg", "png", "gif", "apng"]
|
|
|
|
|
|
static func path(for fmt: Format) -> String? { bin(fmt.encoder.cmd) }
|
|
|
|
|
|
static func bin(_ cmd: String) -> String? {
|
|
|
["/usr/local/bin/\(cmd)", "/opt/homebrew/bin/\(cmd)", "/usr/bin/\(cmd)"]
|
|
|
.first { FileManager.default.isExecutableFile(atPath: $0) }
|
|
|
}
|
|
|
|
|
|
/// Decode a .jxl back to a sibling _roundtrip.png so the user can eyeball the
|
|
|
/// result. Returns the decoded URL on success.
|
|
|
static func decode(_ jxl: URL) throws -> URL {
|
|
|
guard let djxl = bin("djxl") else { throw CompressError.encoderMissing("jpeg-xl") }
|
|
|
let out = jxl.deletingPathExtension().appendingPathExtension("roundtrip.png")
|
|
|
let proc = Process()
|
|
|
proc.executableURL = URL(fileURLWithPath: djxl)
|
|
|
proc.arguments = [jxl.path, out.path]
|
|
|
proc.standardError = Pipe(); proc.standardOutput = Pipe()
|
|
|
try proc.run(); proc.waitUntilExit()
|
|
|
guard proc.terminationStatus == 0 else { throw CompressError.failed("djxl exited \(proc.terminationStatus)") }
|
|
|
return out
|
|
|
}
|
|
|
|
|
|
/// Map distance ~[0.5,3] → 0–100 quality for avif/webp (lower distance = higher q).
|
|
|
private static func quality(from distance: Double) -> Int {
|
|
|
max(0, min(100, Int(100 - (distance - 0.5) * 25)))
|
|
|
}
|
|
|
|
|
|
/// Build encoder-specific args. JXL uses distance; AVIF/WebP use 0–100 quality.
|
|
|
private static func args(_ fmt: Format, _ o: Options, in input: String, out: String) -> [String] {
|
|
|
let q = quality(from: o.distance)
|
|
|
switch fmt {
|
|
|
case .jxl:
|
|
|
// JPEG inputs default to lossless transcode, which conflicts with -d>0.
|
|
|
// Lossless mode keeps that transcode; lossy mode must turn it off.
|
|
|
return o.lossless
|
|
|
? [input, out, "-e", String(o.effort), "--num_threads", "1", "-d", "0"]
|
|
|
: [input, out, "-e", String(o.effort), "--num_threads", "1",
|
|
|
"--lossless_jpeg=0", "-d", String(format: "%.2f", o.distance)]
|
|
|
case .avif:
|
|
|
return ["-s", String(max(0, 10 - o.effort)),
|
|
|
o.lossless ? "-l" : "-q", o.lossless ? "" : String(q), input, out].filter { !$0.isEmpty }
|
|
|
case .webp:
|
|
|
return o.lossless ? ["-lossless", input, "-o", out] : ["-q", String(q), input, "-o", out]
|
|
|
}
|
|
|
}
|
|
|
|
|
|
struct Result {
|
|
|
var outputURL: URL
|
|
|
var originalBytes: Int
|
|
|
var newBytes: Int
|
|
|
var savedFraction: Double { originalBytes > 0 ? 1 - Double(newBytes) / Double(originalBytes) : 0 }
|
|
|
}
|
|
|
|
|
|
static func compress(_ input: URL, options: Options) throws -> Result {
|
|
|
let fmt = options.format
|
|
|
guard let bin = path(for: fmt) else { throw CompressError.encoderMissing(fmt.encoder.brew) }
|
|
|
|
|
|
let fm = FileManager.default
|
|
|
let originalBytes = (try? fm.attributesOfItem(atPath: input.path)[.size] as? Int) ?? 0
|
|
|
let output = input.deletingPathExtension().appendingPathExtension(fmt.ext)
|
|
|
|
|
|
let proc = Process()
|
|
|
proc.executableURL = URL(fileURLWithPath: bin)
|
|
|
proc.arguments = args(fmt, options, in: input.path, out: output.path)
|
|
|
let err = Pipe()
|
|
|
proc.standardError = err
|
|
|
proc.standardOutput = Pipe()
|
|
|
try proc.run()
|
|
|
proc.waitUntilExit()
|
|
|
|
|
|
guard proc.terminationStatus == 0, fm.fileExists(atPath: output.path) else {
|
|
|
let data = err.fileHandleForReading.readDataToEndOfFile()
|
|
|
let msg = String(data: data, encoding: .utf8) ?? "\(fmt.encoder.cmd) exited \(proc.terminationStatus)"
|
|
|
throw CompressError.failed(msg.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
|
}
|
|
|
|
|
|
let newBytes = (try? fm.attributesOfItem(atPath: output.path)[.size] as? Int) ?? 0
|
|
|
if !options.keepOriginal { try? fm.removeItem(at: input) }
|
|
|
return Result(outputURL: output, originalBytes: originalBytes, newBytes: newBytes)
|
|
|
}
|
|
|
}
|