import Foundation
|
|
|
|
/// Wraps the `cjxl` encoder from libjxl. Each input is converted to a sibling
|
|
/// .jxl file; original byte size and result byte size are reported back so the
|
|
/// UI can show ImageOptim-style savings.
|
|
enum Compressor {
|
|
|
|
/// Settings that map directly onto cjxl flags.
|
|
struct Options {
|
|
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
|
|
case failed(String)
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .encoderMissing:
|
|
return "cjxl not found. Install with: brew install jpeg-xl"
|
|
case .failed(let msg):
|
|
return msg
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Common Homebrew/system locations; resolved once.
|
|
static let cjxlPath: String? = {
|
|
let candidates = ["/usr/local/bin/cjxl", "/opt/homebrew/bin/cjxl", "/usr/bin/cjxl"]
|
|
return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
|
|
}()
|
|
|
|
static let supportedExtensions: Set<String> = ["jpg", "jpeg", "png", "gif", "apng"]
|
|
|
|
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 {
|
|
guard let cjxl = cjxlPath else { throw CompressError.encoderMissing }
|
|
|
|
let fm = FileManager.default
|
|
let originalBytes = (try? fm.attributesOfItem(atPath: input.path)[.size] as? Int) ?? 0
|
|
let output = input.deletingPathExtension().appendingPathExtension("jxl")
|
|
|
|
var args = [input.path, output.path,
|
|
"-e", String(options.effort),
|
|
"--num_threads", "0"]
|
|
if options.lossless {
|
|
args += ["-d", "0"]
|
|
} else {
|
|
args += ["-d", String(format: "%.2f", options.distance)]
|
|
}
|
|
|
|
let proc = Process()
|
|
proc.executableURL = URL(fileURLWithPath: cjxl)
|
|
proc.arguments = args
|
|
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) ?? "cjxl 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)
|
|
}
|
|
}
|