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.
 
 
 

81 lines
3.0 KiB

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