| @ -0,0 +1,3 @@ | |||||
| .build/ | |||||
| *.app/ | |||||
| .DS_Store | |||||
| @ -0,0 +1,13 @@ | |||||
| // swift-tools-version:5.7 | |||||
| import PackageDescription | |||||
| let package = Package( | |||||
| name: "JXLOptim", | |||||
| platforms: [.macOS(.v12)], | |||||
| targets: [ | |||||
| .executableTarget( | |||||
| name: "JXLOptim", | |||||
| path: "Sources/JXLOptim" | |||||
| ) | |||||
| ] | |||||
| ) | |||||
| @ -0,0 +1,40 @@ | |||||
| # JXL Optim | |||||
| An ImageOptim-style drag-and-drop optimizer that re-encodes JPG / PNG / GIF into | |||||
| **JPEG XL** (`.jxl`). Native SwiftUI, macOS 12+. Wraps the `cjxl` encoder from | |||||
| [libjxl](https://github.com/libjxl/libjxl). | |||||
| ## Prerequisites | |||||
| ```sh | |||||
| brew install jpeg-xl # provides cjxl / djxl | |||||
| ``` | |||||
| The app looks for `cjxl` in `/usr/local/bin`, `/opt/homebrew/bin`, `/usr/bin`. | |||||
| If it isn't found you'll see a red banner with the install hint. | |||||
| ## Build & run | |||||
| ```sh | |||||
| swift run # dev: launches the window | |||||
| ./bundle.sh # builds "JXL Optim.app" (release, double-clickable) | |||||
| open "JXL Optim.app" | |||||
| ``` | |||||
| ## Use | |||||
| Drop images onto the window. Each is converted to a sibling `.jxl`; the row shows | |||||
| `before → after` and the percentage saved. A running total sits at the bottom. | |||||
| Controls: | |||||
| - **Lossless** — pixel-exact (`cjxl -d 0`). PNG/GIF stay perfect; JPEG is | |||||
| transcoded losslessly (decode-back gives the original). | |||||
| - **Quality** — visual distance `d0.5`–`d3.0` (lower = better). `d1` ≈ visually lossless. | |||||
| - **Effort** — encoder effort 1–9; higher is slower but smaller. | |||||
| - **Keep original** — off deletes the source after a successful encode. | |||||
| ## How it maps to cjxl | |||||
| `cjxl in.png out.jxl -e <effort> --num_threads 0 [-d 0 | -d <distance>]` | |||||
| 讲解文章在 Niamos vault: `wiki/projects/自己写一个 JPEG XL 压缩器.md`。 | |||||
| @ -0,0 +1,81 @@ | |||||
| 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) | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,98 @@ | |||||
| import SwiftUI | |||||
| import UniformTypeIdentifiers | |||||
| struct ContentView: View { | |||||
| @EnvironmentObject var model: AppModel | |||||
| var body: some View { | |||||
| VStack(spacing: 0) { | |||||
| if !model.encoderReady { | |||||
| banner("cjxl not found — run: brew install jpeg-xl", color: .red) | |||||
| } | |||||
| controls | |||||
| Divider() | |||||
| dropZone | |||||
| Divider() | |||||
| footer | |||||
| } | |||||
| } | |||||
| private var controls: some View { | |||||
| HStack(spacing: 16) { | |||||
| Toggle("Lossless", isOn: $model.lossless) | |||||
| if !model.lossless { | |||||
| HStack { | |||||
| Text("Quality") | |||||
| Slider(value: $model.distance, in: 0.5...3.0) | |||||
| .frame(width: 120) | |||||
| Text(String(format: "d%.1f", model.distance)).monospacedDigit() | |||||
| } | |||||
| } | |||||
| Stepper("Effort \(model.effort)", value: $model.effort, in: 1...9) | |||||
| Toggle("Keep original", isOn: $model.keepOriginal) | |||||
| Spacer() | |||||
| }.padding(10) | |||||
| } | |||||
| private var dropZone: some View { | |||||
| ZStack { | |||||
| if model.items.isEmpty { | |||||
| VStack(spacing: 6) { | |||||
| Image(systemName: "square.and.arrow.down").font(.system(size: 34)) | |||||
| Text("Drop JPG / PNG / GIF here").foregroundColor(.secondary) | |||||
| } | |||||
| } else { | |||||
| List(model.items) { ImageRow(item: $0) } | |||||
| } | |||||
| } | |||||
| .frame(maxWidth: .infinity, maxHeight: .infinity) | |||||
| .onDrop(of: [UTType.fileURL], isTargeted: nil) { providers in | |||||
| for p in providers { | |||||
| _ = p.loadObject(ofClass: URL.self) { url, _ in | |||||
| if let url { DispatchQueue.main.async { model.add(urls: [url]) } } | |||||
| } | |||||
| } | |||||
| return true | |||||
| } | |||||
| } | |||||
| private var footer: some View { | |||||
| HStack { | |||||
| if model.busy { ProgressView().scaleEffect(0.6); Text("Compressing…") } | |||||
| Spacer() | |||||
| Text("Saved \(human(model.totalSaved))").bold() | |||||
| }.padding(8) | |||||
| } | |||||
| private func banner(_ s: String, color: Color) -> some View { | |||||
| Text(s).foregroundColor(.white).frame(maxWidth: .infinity) | |||||
| .padding(6).background(color) | |||||
| } | |||||
| } | |||||
| struct ImageRow: View { | |||||
| @ObservedObject var item: ImageItem | |||||
| var body: some View { | |||||
| HStack { | |||||
| icon | |||||
| Text(item.url.lastPathComponent).lineLimit(1) | |||||
| Spacer() | |||||
| switch item.status { | |||||
| case .done: | |||||
| Text("\(human(item.originalBytes)) → \(human(item.newBytes))").foregroundColor(.secondary) | |||||
| Text(String(format: "−%.0f%%", item.savedFraction * 100)).foregroundColor(.green).bold() | |||||
| case .failed: Text(item.message).foregroundColor(.red).lineLimit(1) | |||||
| case .skipped: Text("no gain").foregroundColor(.secondary) | |||||
| case .working: ProgressView().scaleEffect(0.5) | |||||
| case .pending: Text("queued").foregroundColor(.secondary) | |||||
| } | |||||
| }.monospacedDigit() | |||||
| } | |||||
| @ViewBuilder var icon: some View { | |||||
| switch item.status { | |||||
| case .done: Image(systemName: "checkmark.circle.fill").foregroundColor(.green) | |||||
| case .failed: Image(systemName: "xmark.circle.fill").foregroundColor(.red) | |||||
| default: Image(systemName: "photo") | |||||
| } | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,13 @@ | |||||
| import SwiftUI | |||||
| @main | |||||
| struct JXLOptimApp: App { | |||||
| @StateObject private var model = AppModel() | |||||
| var body: some Scene { | |||||
| WindowGroup("JXL Optim") { | |||||
| ContentView().environmentObject(model) | |||||
| .frame(minWidth: 540, minHeight: 420) | |||||
| } | |||||
| .windowStyle(.titleBar) | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,78 @@ | |||||
| import Foundation | |||||
| import SwiftUI | |||||
| /// One row in the drop list. ObservableObject so each row updates independently | |||||
| /// as its async compression finishes. | |||||
| final class ImageItem: ObservableObject, Identifiable { | |||||
| let id = UUID() | |||||
| let url: URL | |||||
| @Published var status: Status = .pending | |||||
| @Published var originalBytes: Int = 0 | |||||
| @Published var newBytes: Int = 0 | |||||
| @Published var message: String = "" | |||||
| enum Status { case pending, working, done, failed, skipped } | |||||
| init(url: URL) { | |||||
| self.url = url | |||||
| self.originalBytes = (try? FileManager.default | |||||
| .attributesOfItem(atPath: url.path)[.size] as? Int) ?? 0 | |||||
| } | |||||
| var savedFraction: Double { | |||||
| originalBytes > 0 && newBytes > 0 ? 1 - Double(newBytes) / Double(originalBytes) : 0 | |||||
| } | |||||
| } | |||||
| final class AppModel: ObservableObject { | |||||
| @Published var items: [ImageItem] = [] | |||||
| @Published var lossless = false | |||||
| @Published var distance = 1.0 | |||||
| @Published var effort = 7 | |||||
| @Published var keepOriginal = true | |||||
| @Published var busy = false | |||||
| var totalSaved: Int { items.reduce(0) { $0 + max(0, $1.originalBytes - $1.newBytes) } } | |||||
| var encoderReady: Bool { Compressor.cjxlPath != nil } | |||||
| func add(urls: [URL]) { | |||||
| for url in urls { | |||||
| let ext = url.pathExtension.lowercased() | |||||
| guard Compressor.supportedExtensions.contains(ext) else { continue } | |||||
| guard !items.contains(where: { $0.url == url }) else { continue } | |||||
| items.append(ImageItem(url: url)) | |||||
| } | |||||
| run() | |||||
| } | |||||
| func run() { | |||||
| guard !busy else { return } | |||||
| let opts = Compressor.Options(lossless: lossless, distance: distance, | |||||
| effort: effort, keepOriginal: keepOriginal) | |||||
| let pending = items.filter { $0.status == .pending } | |||||
| guard !pending.isEmpty else { return } | |||||
| busy = true | |||||
| DispatchQueue.global(qos: .userInitiated).async { | |||||
| for item in pending { | |||||
| DispatchQueue.main.async { item.status = .working } | |||||
| do { | |||||
| let r = try Compressor.compress(item.url, options: opts) | |||||
| DispatchQueue.main.async { | |||||
| item.newBytes = r.newBytes | |||||
| item.status = r.savedFraction > 0 ? .done : .skipped | |||||
| } | |||||
| } catch { | |||||
| DispatchQueue.main.async { | |||||
| item.status = .failed | |||||
| item.message = error.localizedDescription | |||||
| } | |||||
| } | |||||
| } | |||||
| DispatchQueue.main.async { self.busy = false } | |||||
| } | |||||
| } | |||||
| } | |||||
| func human(_ bytes: Int) -> String { | |||||
| ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file) | |||||
| } | |||||
| @ -0,0 +1,23 @@ | |||||
| #!/bin/bash | |||||
| # Build a double-clickable JXL Optim.app from the SwiftPM executable. | |||||
| set -e | |||||
| cd "$(dirname "$0")" | |||||
| swift build -c release | |||||
| APP="JXL Optim.app" | |||||
| rm -rf "$APP" | |||||
| mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" | |||||
| cp .build/release/JXLOptim "$APP/Contents/MacOS/" | |||||
| cat > "$APP/Contents/Info.plist" <<PLIST | |||||
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |||||
| <plist version="1.0"><dict> | |||||
| <key>CFBundleName</key><string>JXL Optim</string> | |||||
| <key>CFBundleExecutable</key><string>JXLOptim</string> | |||||
| <key>CFBundleIdentifier</key><string>com.local.jxloptim</string> | |||||
| <key>CFBundlePackageType</key><string>APPL</string> | |||||
| <key>CFBundleShortVersionString</key><string>1.0</string> | |||||
| <key>LSMinimumSystemVersion</key><string>12.0</string> | |||||
| <key>NSHighResolutionCapable</key><true/> | |||||
| </dict></plist> | |||||
| PLIST | |||||
| echo "Built $APP — open it with: open '$APP'" | |||||