Browse Source

add code

master
nicksxs 4 months ago
commit
fe11154954
8 changed files with 349 additions and 0 deletions
  1. +3
    -0
      .gitignore
  2. +13
    -0
      Package.swift
  3. +40
    -0
      README.md
  4. +81
    -0
      Sources/JXLOptim/Compressor.swift
  5. +98
    -0
      Sources/JXLOptim/ContentView.swift
  6. +13
    -0
      Sources/JXLOptim/JXLOptimApp.swift
  7. +78
    -0
      Sources/JXLOptim/Model.swift
  8. +23
    -0
      bundle.sh

+ 3
- 0
.gitignore View File

@ -0,0 +1,3 @@
.build/
*.app/
.DS_Store

+ 13
- 0
Package.swift View File

@ -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"
)
]
)

+ 40
- 0
README.md View File

@ -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`。

+ 81
- 0
Sources/JXLOptim/Compressor.swift View File

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

+ 98
- 0
Sources/JXLOptim/ContentView.swift View File

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

+ 13
- 0
Sources/JXLOptim/JXLOptimApp.swift View File

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

+ 78
- 0
Sources/JXLOptim/Model.swift View File

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

+ 23
- 0
bundle.sh View 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'"

Loading…
Cancel
Save