commit fe111549542ebc8dfc9b22fbd889973ad9db42a3 Author: nicksxs <736886864@qq.com> Date: Sun Jun 21 11:13:50 2026 +0800 add code diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ecb897 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.build/ +*.app/ +.DS_Store diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..bff2d33 --- /dev/null +++ b/Package.swift @@ -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" + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..875cc87 --- /dev/null +++ b/README.md @@ -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 --num_threads 0 [-d 0 | -d ]` + +讲解文章在 Niamos vault: `wiki/projects/自己写一个 JPEG XL 压缩器.md`。 diff --git a/Sources/JXLOptim/Compressor.swift b/Sources/JXLOptim/Compressor.swift new file mode 100644 index 0000000..412c7e5 --- /dev/null +++ b/Sources/JXLOptim/Compressor.swift @@ -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 = ["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) + } +} diff --git a/Sources/JXLOptim/ContentView.swift b/Sources/JXLOptim/ContentView.swift new file mode 100644 index 0000000..092b77a --- /dev/null +++ b/Sources/JXLOptim/ContentView.swift @@ -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") + } + } +} diff --git a/Sources/JXLOptim/JXLOptimApp.swift b/Sources/JXLOptim/JXLOptimApp.swift new file mode 100644 index 0000000..b6aa858 --- /dev/null +++ b/Sources/JXLOptim/JXLOptimApp.swift @@ -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) + } +} diff --git a/Sources/JXLOptim/Model.swift b/Sources/JXLOptim/Model.swift new file mode 100644 index 0000000..a406709 --- /dev/null +++ b/Sources/JXLOptim/Model.swift @@ -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) +} diff --git a/bundle.sh b/bundle.sh new file mode 100755 index 0000000..1a67d71 --- /dev/null +++ b/bundle.sh @@ -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" < + + + CFBundleNameJXL Optim + CFBundleExecutableJXLOptim + CFBundleIdentifiercom.local.jxloptim + CFBundlePackageTypeAPPL + CFBundleShortVersionString1.0 + LSMinimumSystemVersion12.0 + NSHighResolutionCapable + +PLIST +echo "Built $APP — open it with: open '$APP'"