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.
 
 
 

126 lines
5.4 KiB

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