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 = ["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) } }