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