| @ -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'" | |||