From e35a040bc90e963d82d8a73d4be970a76c8561b5 Mon Sep 17 00:00:00 2001 From: GeonHwan Cha Date: Thu, 19 Feb 2026 12:27:38 +0900 Subject: [PATCH 01/12] docs: add JPEG XL encoding design document Clean-room libjxl (BSD-3-Clause) binding approach for JXL encoding support, independent of GPL-licensed jpegxl-sys/jpegxl-rs. --- docs/plans/2026-02-19-jxl-encoding-design.md | 158 +++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/plans/2026-02-19-jxl-encoding-design.md diff --git a/docs/plans/2026-02-19-jxl-encoding-design.md b/docs/plans/2026-02-19-jxl-encoding-design.md new file mode 100644 index 0000000..1f282a4 --- /dev/null +++ b/docs/plans/2026-02-19-jxl-encoding-design.md @@ -0,0 +1,158 @@ +# JPEG XL Encoding Support + +## Summary + +libjxl (BSD-3-Clause) C library에 대한 직접 바인딩을 통해 slimg에 JPEG XL 인코딩을 추가한다. +기존 디코딩(image crate)은 유지하고, 인코딩만 libjxl로 구현한다. + +## Motivation + +- slimg은 JXL 디코딩만 지원하고 인코딩은 `EncodingNotSupported` 반환 +- 기존 Rust JXL 인코더 crate들이 미성숙(v0.1~0.3)하거나 GPL-3.0(jpegxl-rs) +- libjxl 레퍼런스 구현(BSD-3-Clause)에 직접 바인딩하면 라이선스 호환 + 안정성 확보 + +## Clean-room Design + +GPL-3.0인 `jpegxl-sys`/`jpegxl-rs`의 코드를 참조하지 않는다. +libjxl 공식 C 헤더(BSD-3-Clause)만 기반으로 독자 구현한다. + +| 항목 | jpegxl-sys (GPL) | libjxl-enc-sys (ours, MIT) | +|------|-----------------|---------------------------| +| 범위 | 디코더+인코더+전체 API | 인코더 전용 (allowlist 필터링) | +| 빌드 | 자체 스크립트 | cmake crate 기반 독자 구현 | +| sys crate 구조 | 다중 모듈 | flat (bindgen output) | +| safe wrapper 위치 | jpegxl-rs (별도 crate) | slimg-core 내부 모듈 | +| 디코딩 | 포함 | 미포함 (기존 image crate 유지) | + +## Architecture + +### Crate Structure + +``` +crates/libjxl-enc-sys/ # 새 crate: raw FFI bindings (MIT) + Cargo.toml # build-dep: cmake, bindgen + build.rs # libjxl CMake 빌드 + bindgen + src/lib.rs # include!(bindgen output) + libjxl/ # git submodule (BSD-3-Clause) + +crates/slimg-core/src/codec/ + jxl/ # 기존 jxl.rs -> 디렉토리로 변환 + mod.rs # JxlCodec (Codec trait 구현) + encoder.rs # safe encoder wrapper + types.rs # quality -> distance 매핑, 설정 변환 +``` + +### Build System (build.rs) + +1. cmake crate로 libjxl 서브모듈을 static library로 빌드 +2. bindgen으로 인코더 API 바인딩 생성 +3. `cargo:rustc-link-lib=static=jxl` 링킹 + +CMake 최소 빌드 옵션: + +``` +BUILD_TESTING=OFF +JPEGXL_ENABLE_TOOLS=OFF +JPEGXL_ENABLE_DOXYGEN=OFF +JPEGXL_ENABLE_MANPAGES=OFF +JPEGXL_ENABLE_BENCHMARK=OFF +JPEGXL_ENABLE_EXAMPLES=OFF +JPEGXL_ENABLE_SJPEG=OFF +JPEGXL_ENABLE_JPEGLI=OFF +BUILD_SHARED_LIBS=OFF +``` + +### Bindgen Allowlist + +```rust +bindgen::Builder::default() + .header("libjxl/lib/include/jxl/encode.h") + .header("libjxl/lib/include/jxl/types.h") + .header("libjxl/lib/include/jxl/codestream_header.h") + .header("libjxl/lib/include/jxl/color_encoding.h") + .allowlist_function("JxlEncoder.*") + .allowlist_function("JxlColorEncodingSetToSRGB") + .allowlist_function("JxlEncoderDistanceFromQuality") + .allowlist_type("JxlBasicInfo|JxlPixelFormat|JxlDataType|JxlEndianness") + .allowlist_type("JxlColorEncoding|JxlEncoder.*|JxlBool") + .allowlist_var("JXL_TRUE|JXL_FALSE|JXL_ENC_.*") +``` + +### Safe Wrapper (slimg-core/src/codec/jxl/) + +**encoder.rs** - libjxl encoder의 safe Rust 래퍼: + +```rust +pub struct JxlEncoder { /* raw pointer + Drop */ } + +impl JxlEncoder { + pub fn new() -> Result; + pub fn encode_rgba( + &self, + pixels: &[u8], + width: u32, + height: u32, + config: &JxlEncodeConfig, + ) -> Result>; +} +``` + +**types.rs** - quality 매핑: + +```rust +pub struct JxlEncodeConfig { + pub lossless: bool, + pub distance: f32, +} + +impl JxlEncodeConfig { + pub fn from_quality(quality: u8) -> Self { + if quality == 100 { + return Self { lossless: true, distance: 0.0 }; + } + let distance = unsafe { + libjxl_enc_sys::JxlEncoderDistanceFromQuality(quality as f32) + }; + Self { lossless: false, distance } + } +} +``` + +**mod.rs** - Codec trait 구현: + +```rust +impl Codec for JxlCodec { + fn decode(&self, data: &[u8]) -> Result { + // 기존 image crate 디코딩 유지 (변경 없음) + } + + fn encode(&self, image: &ImageData, options: &EncodeOptions) -> Result> { + let config = JxlEncodeConfig::from_quality(options.quality); + let encoder = JxlEncoder::new()?; + encoder.encode_rgba(&image.data, image.width, image.height, &config) + } +} +``` + +### Other Changes + +- `format.rs`: `can_encode()` - JXL을 `true`로 변경 +- `dist-workspace.toml`: system dependencies에 cmake 추가 +- Workspace `Cargo.toml`: `libjxl-enc-sys` 멤버 추가 + +## Encoding Features + +- Lossy 인코딩: quality 0~99 (distance via `JxlEncoderDistanceFromQuality`) +- Lossless 인코딩: quality 100 (distance=0.0 + lossless flag) +- 기본 포함 (feature flag 없음) +- Vendored 빌드 (libjxl git submodule) + +## CI Impact + +현재 NASM, meson, ninja는 이미 설치됨. CMake + C++ 컴파일러 추가 필요. +GitHub Actions runner에 기본 탑재되어 있으므로 `dist-workspace.toml`에 cmake만 추가. + +## License + +- libjxl: BSD-3-Clause (MIT 호환) +- libjxl-enc-sys: MIT (slimg과 동일) From 65cf16de629d72fba0ba4d237a60547ffec38102 Mon Sep 17 00:00:00 2001 From: GeonHwan Cha Date: Thu, 19 Feb 2026 12:34:50 +0900 Subject: [PATCH 02/12] docs: add JXL encoding implementation plan 9 tasks covering libjxl submodule setup, sys crate build, safe wrapper, Codec trait integration, and CI updates. --- docs/plans/2026-02-19-jxl-encoding-design.md | 826 ++++++++++++++++--- 1 file changed, 730 insertions(+), 96 deletions(-) diff --git a/docs/plans/2026-02-19-jxl-encoding-design.md b/docs/plans/2026-02-19-jxl-encoding-design.md index 1f282a4..768a216 100644 --- a/docs/plans/2026-02-19-jxl-encoding-design.md +++ b/docs/plans/2026-02-19-jxl-encoding-design.md @@ -1,158 +1,792 @@ -# JPEG XL Encoding Support +# JPEG XL Encoding Implementation Plan -## Summary +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. -libjxl (BSD-3-Clause) C library에 대한 직접 바인딩을 통해 slimg에 JPEG XL 인코딩을 추가한다. -기존 디코딩(image crate)은 유지하고, 인코딩만 libjxl로 구현한다. +**Goal:** libjxl (BSD-3-Clause)에 대한 clean-room 바인딩으로 slimg에 JXL 인코딩 지원 추가 -## Motivation +**Architecture:** workspace에 `libjxl-enc-sys` crate를 추가하여 libjxl C 인코더를 vendored static 빌드 + bindgen 바인딩. `slimg-core`의 `codec/jxl/` 모듈에서 safe wrapper를 통해 Codec trait 구현. -- slimg은 JXL 디코딩만 지원하고 인코딩은 `EncodingNotSupported` 반환 -- 기존 Rust JXL 인코더 crate들이 미성숙(v0.1~0.3)하거나 GPL-3.0(jpegxl-rs) -- libjxl 레퍼런스 구현(BSD-3-Clause)에 직접 바인딩하면 라이선스 호환 + 안정성 확보 +**Tech Stack:** libjxl (C++/CMake), cmake crate, bindgen, git submodule -## Clean-room Design +**Clean-room 원칙:** GPL-3.0인 `jpegxl-sys`/`jpegxl-rs` 코드를 절대 참조하지 않는다. libjxl 공식 C 헤더(BSD-3-Clause)만 기반으로 독자 구현. -GPL-3.0인 `jpegxl-sys`/`jpegxl-rs`의 코드를 참조하지 않는다. -libjxl 공식 C 헤더(BSD-3-Clause)만 기반으로 독자 구현한다. +--- -| 항목 | jpegxl-sys (GPL) | libjxl-enc-sys (ours, MIT) | -|------|-----------------|---------------------------| -| 범위 | 디코더+인코더+전체 API | 인코더 전용 (allowlist 필터링) | -| 빌드 | 자체 스크립트 | cmake crate 기반 독자 구현 | -| sys crate 구조 | 다중 모듈 | flat (bindgen output) | -| safe wrapper 위치 | jpegxl-rs (별도 crate) | slimg-core 내부 모듈 | -| 디코딩 | 포함 | 미포함 (기존 image crate 유지) | +### Task 1: libjxl git submodule 추가 -## Architecture +**Files:** +- Create: `crates/libjxl-enc-sys/` (directory) +- Create: `.gitmodules` entry -### Crate Structure +**Step 1: 디렉토리 생성 및 submodule 추가** +```bash +mkdir -p crates/libjxl-enc-sys +git submodule add https://github.com/libjxl/libjxl.git crates/libjxl-enc-sys/libjxl ``` -crates/libjxl-enc-sys/ # 새 crate: raw FFI bindings (MIT) - Cargo.toml # build-dep: cmake, bindgen - build.rs # libjxl CMake 빌드 + bindgen - src/lib.rs # include!(bindgen output) - libjxl/ # git submodule (BSD-3-Clause) -crates/slimg-core/src/codec/ - jxl/ # 기존 jxl.rs -> 디렉토리로 변환 - mod.rs # JxlCodec (Codec trait 구현) - encoder.rs # safe encoder wrapper - types.rs # quality -> distance 매핑, 설정 변환 +**Step 2: libjxl의 third-party 서브모듈 초기화** + +libjxl은 highway, brotli 등을 third_party/에서 참조한다. + +```bash +cd crates/libjxl-enc-sys/libjxl +git submodule update --init --recursive --depth 1 third_party/highway third_party/brotli third_party/skcms +cd ../../.. ``` -### Build System (build.rs) +**Step 3: 안정 태그로 체크아웃** -1. cmake crate로 libjxl 서브모듈을 static library로 빌드 -2. bindgen으로 인코더 API 바인딩 생성 -3. `cargo:rustc-link-lib=static=jxl` 링킹 +```bash +cd crates/libjxl-enc-sys/libjxl +git checkout v0.11.1 +cd ../../.. +``` -CMake 최소 빌드 옵션: +**Step 4: 커밋** +```bash +git add .gitmodules crates/libjxl-enc-sys/libjxl +git commit -m "chore: add libjxl v0.11.1 as git submodule" ``` -BUILD_TESTING=OFF -JPEGXL_ENABLE_TOOLS=OFF -JPEGXL_ENABLE_DOXYGEN=OFF -JPEGXL_ENABLE_MANPAGES=OFF -JPEGXL_ENABLE_BENCHMARK=OFF -JPEGXL_ENABLE_EXAMPLES=OFF -JPEGXL_ENABLE_SJPEG=OFF -JPEGXL_ENABLE_JPEGLI=OFF -BUILD_SHARED_LIBS=OFF + +--- + +### Task 2: libjxl-enc-sys crate 스캐폴딩 + +**Files:** +- Create: `crates/libjxl-enc-sys/Cargo.toml` +- Create: `crates/libjxl-enc-sys/src/lib.rs` +- Modify: `Cargo.toml` (workspace members) + +**Step 1: Cargo.toml 작성** + +```toml +# crates/libjxl-enc-sys/Cargo.toml +[package] +name = "libjxl-enc-sys" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Minimal FFI bindings to libjxl encoder (vendored, BSD-3-Clause)" +publish = false +links = "jxl_enc" + +[build-dependencies] +cmake = "0.1" +bindgen = "0.71" ``` -### Bindgen Allowlist +**Step 2: 빈 lib.rs 작성** ```rust -bindgen::Builder::default() - .header("libjxl/lib/include/jxl/encode.h") - .header("libjxl/lib/include/jxl/types.h") - .header("libjxl/lib/include/jxl/codestream_header.h") - .header("libjxl/lib/include/jxl/color_encoding.h") - .allowlist_function("JxlEncoder.*") - .allowlist_function("JxlColorEncodingSetToSRGB") - .allowlist_function("JxlEncoderDistanceFromQuality") - .allowlist_type("JxlBasicInfo|JxlPixelFormat|JxlDataType|JxlEndianness") - .allowlist_type("JxlColorEncoding|JxlEncoder.*|JxlBool") - .allowlist_var("JXL_TRUE|JXL_FALSE|JXL_ENC_.*") +// crates/libjxl-enc-sys/src/lib.rs +#![allow(non_upper_case_globals)] +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] +#![allow(dead_code)] + +include!(concat!(env!("OUT_DIR"), "/bindings.rs")); +``` + +**Step 3: workspace에 멤버 추가** + +`Cargo.toml`의 `members`에 `"crates/libjxl-enc-sys"` 추가. + +**Step 4: 커밋** + +```bash +git add crates/libjxl-enc-sys/Cargo.toml crates/libjxl-enc-sys/src/lib.rs Cargo.toml +git commit -m "chore: scaffold libjxl-enc-sys crate" ``` -### Safe Wrapper (slimg-core/src/codec/jxl/) +--- + +### Task 3: build.rs 구현 (CMake + bindgen) + +**Files:** +- Create: `crates/libjxl-enc-sys/build.rs` -**encoder.rs** - libjxl encoder의 safe Rust 래퍼: +**Step 1: build.rs 작성** ```rust -pub struct JxlEncoder { /* raw pointer + Drop */ } +// crates/libjxl-enc-sys/build.rs +use std::env; +use std::path::PathBuf; + +fn main() { + let dst = cmake::Config::new("libjxl") + .define("BUILD_TESTING", "OFF") + .define("BUILD_SHARED_LIBS", "OFF") + .define("JPEGXL_ENABLE_TOOLS", "OFF") + .define("JPEGXL_ENABLE_DOXYGEN", "OFF") + .define("JPEGXL_ENABLE_MANPAGES", "OFF") + .define("JPEGXL_ENABLE_BENCHMARK", "OFF") + .define("JPEGXL_ENABLE_EXAMPLES", "OFF") + .define("JPEGXL_ENABLE_SJPEG", "OFF") + .define("JPEGXL_ENABLE_JPEGLI", "OFF") + .define("JPEGXL_ENABLE_OPENEXR", "OFF") + .define("JPEGXL_ENABLE_TCMALLOC", "OFF") + .define("JPEGXL_BUNDLE_LIBPNG", "OFF") + .define("JPEGXL_ENABLE_SKCMS", "ON") + .build(); + + let lib_dir = dst.join("lib"); + let lib64_dir = dst.join("lib64"); + println!("cargo:rustc-link-search=native={}", lib_dir.display()); + println!("cargo:rustc-link-search=native={}", lib64_dir.display()); + + // libjxl core + encoder + println!("cargo:rustc-link-lib=static=jxl"); + println!("cargo:rustc-link-lib=static=jxl_enc"); + println!("cargo:rustc-link-lib=static=jxl_cms"); + + // libjxl vendored dependencies + println!("cargo:rustc-link-lib=static=hwy"); + println!("cargo:rustc-link-lib=static=brotlienc"); + println!("cargo:rustc-link-lib=static=brotlidec"); + println!("cargo:rustc-link-lib=static=brotlicommon"); + println!("cargo:rustc-link-lib=static=skcms"); + + // C++ standard library + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + match target_os.as_str() { + "macos" | "ios" => println!("cargo:rustc-link-lib=c++"), + "windows" => {} // MSVC links C++ runtime automatically + _ => println!("cargo:rustc-link-lib=stdc++"), + } -impl JxlEncoder { - pub fn new() -> Result; - pub fn encode_rgba( - &self, - pixels: &[u8], - width: u32, - height: u32, - config: &JxlEncodeConfig, - ) -> Result>; + // bindgen + let include_dir = dst.join("include"); + let src_include = PathBuf::from("libjxl/lib/include"); + + let bindings = bindgen::Builder::default() + .header(src_include.join("jxl/encode.h").to_str().unwrap()) + .header(src_include.join("jxl/types.h").to_str().unwrap()) + .header(src_include.join("jxl/codestream_header.h").to_str().unwrap()) + .header(src_include.join("jxl/color_encoding.h").to_str().unwrap()) + .clang_arg(format!("-I{}", src_include.display())) + .clang_arg(format!("-I{}", include_dir.display())) + // Encoder functions only + .allowlist_function("JxlEncoderCreate") + .allowlist_function("JxlEncoderDestroy") + .allowlist_function("JxlEncoderReset") + .allowlist_function("JxlEncoderSetBasicInfo") + .allowlist_function("JxlEncoderSetColorEncoding") + .allowlist_function("JxlEncoderFrameSettingsCreate") + .allowlist_function("JxlEncoderSetFrameDistance") + .allowlist_function("JxlEncoderSetFrameLossless") + .allowlist_function("JxlEncoderFrameSettingsSetOption") + .allowlist_function("JxlEncoderAddImageFrame") + .allowlist_function("JxlEncoderCloseInput") + .allowlist_function("JxlEncoderProcessOutput") + .allowlist_function("JxlEncoderDistanceFromQuality") + .allowlist_function("JxlColorEncodingSetToSRGB") + // Types + .allowlist_type("JxlBasicInfo") + .allowlist_type("JxlPixelFormat") + .allowlist_type("JxlDataType") + .allowlist_type("JxlEndianness") + .allowlist_type("JxlColorEncoding") + .allowlist_type("JxlEncoderStatus") + .allowlist_type("JxlEncoderFrameSettingId") + .allowlist_type("JxlEncoder") + .allowlist_type("JxlEncoderFrameSettings") + .generate() + .expect("failed to generate libjxl bindings"); + + let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()); + bindings + .write_to_file(out_path.join("bindings.rs")) + .expect("failed to write bindings"); } ``` -**types.rs** - quality 매핑: +**Step 2: 빌드 확인** + +```bash +cargo build -p libjxl-enc-sys +``` + +Expected: 첫 빌드는 libjxl CMake 빌드로 수 분 소요. 성공 시 static 라이브러리 + bindings.rs 생성. + +> **Note:** 링킹 라이브러리 이름(`jxl`, `jxl_enc`, `jxl_cms`, `hwy` 등)은 libjxl 빌드 결과에 따라 조정이 필요할 수 있다. 빌드 실패 시 `target/debug/build/libjxl-enc-sys-*/out/lib/` 디렉토리에서 실제 생성된 `.a` 파일명을 확인하여 맞춘다. + +**Step 3: 커밋** + +```bash +git add crates/libjxl-enc-sys/build.rs +git commit -m "feat(libjxl-enc-sys): implement build.rs with CMake + bindgen" +``` + +--- + +### Task 4: jxl.rs를 jxl/ 디렉토리 모듈로 변환 + +**Files:** +- Delete: `crates/slimg-core/src/codec/jxl.rs` +- Create: `crates/slimg-core/src/codec/jxl/mod.rs` +- Create: `crates/slimg-core/src/codec/jxl/types.rs` +- Create: `crates/slimg-core/src/codec/jxl/encoder.rs` + +**Step 1: 디렉토리 생성 및 기존 코드 이동** + +```bash +mkdir -p crates/slimg-core/src/codec/jxl +mv crates/slimg-core/src/codec/jxl.rs crates/slimg-core/src/codec/jxl/mod.rs +``` + +**Step 2: 빈 모듈 파일 생성** ```rust -pub struct JxlEncodeConfig { +// crates/slimg-core/src/codec/jxl/types.rs +use crate::error::Result; + +/// JXL 인코딩 설정. +pub(crate) struct EncodeConfig { pub lossless: bool, pub distance: f32, } -impl JxlEncodeConfig { +impl EncodeConfig { pub fn from_quality(quality: u8) -> Self { - if quality == 100 { - return Self { lossless: true, distance: 0.0 }; + if quality >= 100 { + return Self { + lossless: true, + distance: 0.0, + }; + } + let distance = + unsafe { libjxl_enc_sys::JxlEncoderDistanceFromQuality(quality as f32) }; + Self { + lossless: false, + distance, } - let distance = unsafe { - libjxl_enc_sys::JxlEncoderDistanceFromQuality(quality as f32) + } +} +``` + +```rust +// crates/slimg-core/src/codec/jxl/encoder.rs +// Safe wrapper는 Task 6에서 구현. 컴파일 확인용 빈 파일. +``` + +**Step 3: mod.rs에 모듈 선언 추가** + +`mod.rs` 상단에 추가: + +```rust +mod encoder; +mod types; +``` + +**Step 4: slimg-core에 libjxl-enc-sys 의존성 추가** + +`crates/slimg-core/Cargo.toml`의 `[dependencies]`에 추가: + +```toml +libjxl-enc-sys = { path = "../libjxl-enc-sys" } +``` + +**Step 5: 빌드 확인** + +```bash +cargo build -p slimg-core +``` + +**Step 6: 커밋** + +```bash +git add crates/slimg-core/src/codec/jxl/ crates/slimg-core/Cargo.toml +git rm crates/slimg-core/src/codec/jxl.rs 2>/dev/null || true +git commit -m "refactor(slimg-core): convert jxl.rs to jxl/ module directory" +``` + +--- + +### Task 5: safe encoder wrapper 구현 + +**Files:** +- Modify: `crates/slimg-core/src/codec/jxl/encoder.rs` + +**Step 1: encoder.rs 작성** + +```rust +// crates/slimg-core/src/codec/jxl/encoder.rs +use std::ptr; + +use libjxl_enc_sys::*; + +use crate::error::{Error, Result}; + +use super::types::EncodeConfig; + +/// Safe wrapper around libjxl encoder. +pub(crate) struct Encoder { + ptr: *mut JxlEncoder, +} + +impl Encoder { + /// Create a new JXL encoder instance. + pub fn new() -> Result { + let ptr = unsafe { JxlEncoderCreate(ptr::null()) }; + if ptr.is_null() { + return Err(Error::Encode("failed to create JXL encoder".into())); + } + Ok(Self { ptr }) + } + + /// Encode RGBA pixel data into JXL format. + pub fn encode_rgba( + &mut self, + pixels: &[u8], + width: u32, + height: u32, + config: &EncodeConfig, + ) -> Result> { + unsafe { + JxlEncoderReset(self.ptr); + + self.set_basic_info(width, height, config)?; + self.set_color_encoding()?; + + let frame_settings = JxlEncoderFrameSettingsCreate(self.ptr, ptr::null()); + if frame_settings.is_null() { + return Err(Error::Encode( + "failed to create frame settings".into(), + )); + } + + self.configure_frame(frame_settings, config)?; + self.add_frame(frame_settings, pixels, width, height)?; + + JxlEncoderCloseInput(self.ptr); + + self.process_output() + } + } + + unsafe fn set_basic_info( + &self, + width: u32, + height: u32, + config: &EncodeConfig, + ) -> Result<()> { + let mut info: JxlBasicInfo = std::mem::zeroed(); + info.xsize = width; + info.ysize = height; + info.bits_per_sample = 8; + info.exponent_bits_per_sample = 0; + info.num_color_channels = 3; + info.num_extra_channels = 1; + info.alpha_bits = 8; + info.alpha_exponent_bits = 0; + info.uses_original_profile = if config.lossless { 1 } else { 0 }; + + check_status( + JxlEncoderSetBasicInfo(self.ptr, &info), + "set basic info", + ) + } + + unsafe fn set_color_encoding(&self) -> Result<()> { + let mut color: JxlColorEncoding = std::mem::zeroed(); + JxlColorEncodingSetToSRGB(&mut color, 0); // is_gray = false + check_status( + JxlEncoderSetColorEncoding(self.ptr, &color), + "set color encoding", + ) + } + + unsafe fn configure_frame( + &self, + settings: *mut JxlEncoderFrameSettings, + config: &EncodeConfig, + ) -> Result<()> { + if config.lossless { + check_status( + JxlEncoderSetFrameLossless(settings, 1), + "set lossless", + )?; + } + check_status( + JxlEncoderSetFrameDistance(settings, config.distance), + "set distance", + ) + } + + unsafe fn add_frame( + &self, + settings: *mut JxlEncoderFrameSettings, + pixels: &[u8], + width: u32, + height: u32, + ) -> Result<()> { + let format = JxlPixelFormat { + num_channels: 4, + data_type: JXL_TYPE_UINT8, + endianness: JXL_NATIVE_ENDIAN, + align: 0, }; - Self { lossless: false, distance } + + let expected = (width as usize) * (height as usize) * 4; + debug_assert_eq!(pixels.len(), expected); + + check_status( + JxlEncoderAddImageFrame( + settings, + &format, + pixels.as_ptr().cast(), + pixels.len(), + ), + "add image frame", + ) + } + + unsafe fn process_output(&self) -> Result> { + let mut buffer = vec![0u8; 64 * 1024]; // 64 KB initial + let mut all_output = Vec::new(); + + loop { + let mut next_out = buffer.as_mut_ptr(); + let mut avail_out = buffer.len(); + + let status = + JxlEncoderProcessOutput(self.ptr, &mut next_out, &mut avail_out); + + let written = buffer.len() - avail_out; + all_output.extend_from_slice(&buffer[..written]); + + match status { + JXL_ENC_SUCCESS => return Ok(all_output), + JXL_ENC_NEED_MORE_OUTPUT => continue, + _ => { + return Err(Error::Encode("JXL encoding failed".into())) + } + } + } + } +} + +impl Drop for Encoder { + fn drop(&mut self) { + unsafe { + JxlEncoderDestroy(self.ptr); + } + } +} + +/// Check a `JxlEncoderStatus` and convert to `Result`. +unsafe fn check_status(status: JxlEncoderStatus, context: &str) -> Result<()> { + if status == JXL_ENC_SUCCESS { + Ok(()) + } else { + Err(Error::Encode(format!("jxl {context}: status {status}"))) } } ``` -**mod.rs** - Codec trait 구현: +> **Note:** bindgen이 생성하는 상수 이름(`JXL_ENC_SUCCESS`, `JXL_TYPE_UINT8`, `JXL_NATIVE_ENDIAN` 등)은 실제 생성 결과에 따라 다를 수 있다. Task 3 빌드 성공 후 `target/debug/build/libjxl-enc-sys-*/out/bindings.rs`에서 실제 이름을 확인하여 맞춘다. + +**Step 2: 빌드 확인** + +```bash +cargo build -p slimg-core +``` + +**Step 3: 커밋** + +```bash +git add crates/slimg-core/src/codec/jxl/encoder.rs +git commit -m "feat(slimg-core): implement safe JXL encoder wrapper" +``` + +--- + +### Task 6: Codec trait 인코딩 구현 + format.rs 업데이트 + +**Files:** +- Modify: `crates/slimg-core/src/codec/jxl/mod.rs` +- Modify: `crates/slimg-core/src/format.rs` + +**Step 1: mod.rs에서 인코딩 구현** ```rust +// crates/slimg-core/src/codec/jxl/mod.rs +mod encoder; +mod types; + +use crate::error::{Error, Result}; +use crate::format::Format; + +use super::{Codec, EncodeOptions, ImageData}; + +/// JXL codec backed by libjxl. +pub struct JxlCodec; + impl Codec for JxlCodec { + fn format(&self) -> Format { + Format::Jxl + } + fn decode(&self, data: &[u8]) -> Result { - // 기존 image crate 디코딩 유지 (변경 없음) + let img = image::load_from_memory(data) + .map_err(|e| Error::Decode(format!("jxl decode: {e}")))?; + + let rgba = img.to_rgba8(); + let width = rgba.width(); + let height = rgba.height(); + + Ok(ImageData::new(width, height, rgba.into_raw())) } fn encode(&self, image: &ImageData, options: &EncodeOptions) -> Result> { - let config = JxlEncodeConfig::from_quality(options.quality); - let encoder = JxlEncoder::new()?; - encoder.encode_rgba(&image.data, image.width, image.height, &config) + let config = types::EncodeConfig::from_quality(options.quality); + let mut enc = encoder::Encoder::new()?; + enc.encode_rgba(&image.data, image.width, image.height, &config) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_image(width: u32, height: u32) -> ImageData { + let size = (width * height * 4) as usize; + let mut data = vec![0u8; size]; + for y in 0..height { + for x in 0..width { + let i = ((y * width + x) * 4) as usize; + data[i] = (x * 255 / width) as u8; + data[i + 1] = (y * 255 / height) as u8; + data[i + 2] = 128; + data[i + 3] = 255; + } + } + ImageData::new(width, height, data) + } + + #[test] + fn encode_produces_valid_jxl() { + let codec = JxlCodec; + let image = create_test_image(64, 48); + let options = EncodeOptions { quality: 80 }; + + let encoded = codec.encode(&image, &options).expect("encode failed"); + assert!(encoded.len() > 2, "encoded data too short"); + + // JXL bare codestream: [0xFF, 0x0A] or container: [0x00,0x00,0x00,0x0C] + let is_jxl = (encoded[0] == 0xFF && encoded[1] == 0x0A) + || (encoded.len() >= 8 + && encoded[..4] == [0x00, 0x00, 0x00, 0x0C] + && &encoded[4..8] == b"JXL "); + assert!(is_jxl, "output is not valid JXL"); + } + + #[test] + fn encode_and_decode_roundtrip() { + let codec = JxlCodec; + let original = create_test_image(64, 48); + let options = EncodeOptions { quality: 90 }; + + let encoded = codec.encode(&original, &options).expect("encode failed"); + let decoded = codec.decode(&encoded).expect("decode failed"); + + assert_eq!(decoded.width, original.width); + assert_eq!(decoded.height, original.height); + assert_eq!( + decoded.data.len(), + (decoded.width * decoded.height * 4) as usize + ); + } + + #[test] + fn lower_quality_produces_smaller_file() { + let codec = JxlCodec; + let image = create_test_image(128, 96); + + let high = codec + .encode(&image, &EncodeOptions { quality: 95 }) + .expect("encode q95 failed"); + let low = codec + .encode(&image, &EncodeOptions { quality: 20 }) + .expect("encode q20 failed"); + + assert!( + low.len() < high.len(), + "low quality ({} bytes) should be smaller than high quality ({} bytes)", + low.len(), + high.len(), + ); + } + + #[test] + fn lossless_encode_at_quality_100() { + let codec = JxlCodec; + let original = create_test_image(32, 32); + let options = EncodeOptions { quality: 100 }; + + let encoded = codec.encode(&original, &options).expect("lossless encode failed"); + let decoded = codec.decode(&encoded).expect("lossless decode failed"); + + assert_eq!(decoded.data, original.data, "lossless roundtrip must be exact"); + } +} +``` + +**Step 2: format.rs - can_encode()를 true로 변경** + +`crates/slimg-core/src/format.rs`의 `can_encode()` 메서드에서 JXL 예외 제거: + +```rust +// Before +pub fn can_encode(&self) -> bool { + !matches!(self, Self::Jxl) +} + +// After +pub fn can_encode(&self) -> bool { + true +} +``` + +`can_encode()` 주석도 업데이트: + +```rust +// Before +/// Whether encoding is supported for this format. +/// +/// Returns `false` only for JXL due to GPL license restrictions +/// in the reference encoder. + +// After +/// Whether encoding is supported for this format. +``` + +**Step 3: format.rs 테스트 수정** + +기존 `can_encode_jxl_is_false` 테스트를 반전: + +```rust +// Before +#[test] +fn can_encode_jxl_is_false() { + assert!(!Format::Jxl.can_encode()); +} + +// After +#[test] +fn can_encode_jxl_is_true() { + assert!(Format::Jxl.can_encode()); +} +``` + +`can_encode_all_others_true` 테스트에 JXL 추가: + +```rust +#[test] +fn can_encode_all_formats() { + assert!(Format::Jpeg.can_encode()); + assert!(Format::Png.can_encode()); + assert!(Format::WebP.can_encode()); + assert!(Format::Avif.can_encode()); + assert!(Format::Jxl.can_encode()); + assert!(Format::Qoi.can_encode()); +} +``` + +**Step 4: 테스트 실행** + +```bash +cargo test -p slimg-core +``` + +**Step 5: 커밋** + +```bash +git add crates/slimg-core/src/codec/jxl/ crates/slimg-core/src/format.rs +git commit -m "feat(slimg-core): implement JXL encoding via libjxl" ``` -### Other Changes +--- -- `format.rs`: `can_encode()` - JXL을 `true`로 변경 -- `dist-workspace.toml`: system dependencies에 cmake 추가 -- Workspace `Cargo.toml`: `libjxl-enc-sys` 멤버 추가 +### Task 7: FFI 바인딩 업데이트 (slimg-ffi) -## Encoding Features +**Files:** +- Modify: `crates/slimg-ffi/src/lib.rs` -- Lossy 인코딩: quality 0~99 (distance via `JxlEncoderDistanceFromQuality`) -- Lossless 인코딩: quality 100 (distance=0.0 + lossless flag) -- 기본 포함 (feature flag 없음) -- Vendored 빌드 (libjxl git submodule) +**Step 1: EncodingNotSupported 주석 정리** -## CI Impact +`lib.rs`의 `SlimgError::EncodingNotSupported` variant는 그대로 유지 (다른 포맷이 향후 필요할 수 있음). 변경 사항 없음. `format_can_encode`가 이미 `slimg_core::Format::can_encode()`를 호출하므로 자동 반영. -현재 NASM, meson, ninja는 이미 설치됨. CMake + C++ 컴파일러 추가 필요. -GitHub Actions runner에 기본 탑재되어 있으므로 `dist-workspace.toml`에 cmake만 추가. +**Step 2: 확인** -## License +```bash +cargo build -p slimg-ffi +``` + +**Step 3: 커밋 (변경 사항 있는 경우에만)** + +--- + +### Task 8: CI 빌드 설정 업데이트 + +**Files:** +- Modify: `dist-workspace.toml` + +**Step 1: system dependencies에 cmake 추가** + +```toml +[dist.dependencies.homebrew] +nasm = { stage = ["build"] } +meson = { stage = ["build"] } +ninja = { stage = ["build"] } +cmake = { stage = ["build"] } + +[dist.dependencies.apt] +nasm = '*' +meson = '*' +ninja-build = '*' +cmake = '*' + +[dist.dependencies.chocolatey] +nasm = '*' +ninja = '*' +cmake = '*' +``` + +**Step 2: 전체 빌드 + 테스트 확인** + +```bash +cargo build --workspace +cargo test --workspace +``` + +**Step 3: 커밋** + +```bash +git add dist-workspace.toml +git commit -m "ci: add cmake to system dependencies for libjxl build" +``` + +--- + +### Task 9: CLI JXL 인코딩 동작 확인 + +**Step 1: CLI로 JXL 인코딩 테스트** + +테스트용 이미지가 있다면: + +```bash +cargo run -- convert test.png -f jxl -q 80 -o test.jxl +cargo run -- convert test.jxl -f png -o roundtrip.png +``` + +**Step 2: 최종 확인** + +```bash +cargo test --workspace +cargo clippy --workspace -- -D warnings +``` -- libjxl: BSD-3-Clause (MIT 호환) -- libjxl-enc-sys: MIT (slimg과 동일) +**Step 3: 커밋 (clippy 수정 사항 있는 경우)** From fd3f726cab5ff34bb72728f2987468c1721fdfc9 Mon Sep 17 00:00:00 2001 From: GeonHwan Cha Date: Thu, 19 Feb 2026 12:50:26 +0900 Subject: [PATCH 03/12] chore: add libjxl v0.11.1 as git submodule --- .gitmodules | 3 +++ crates/libjxl-enc-sys/libjxl | 1 + 2 files changed, 4 insertions(+) create mode 100644 .gitmodules create mode 160000 crates/libjxl-enc-sys/libjxl diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..d1d666f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "crates/libjxl-enc-sys/libjxl"] + path = crates/libjxl-enc-sys/libjxl + url = https://github.com/libjxl/libjxl.git diff --git a/crates/libjxl-enc-sys/libjxl b/crates/libjxl-enc-sys/libjxl new file mode 160000 index 0000000..794a5dc --- /dev/null +++ b/crates/libjxl-enc-sys/libjxl @@ -0,0 +1 @@ +Subproject commit 794a5dcf0d54f9f0b20d288a12e87afb91d20dfc From 7d6273f68bb18acbc557c97f3c894f6db94612ec Mon Sep 17 00:00:00 2001 From: GeonHwan Cha Date: Thu, 19 Feb 2026 12:59:58 +0900 Subject: [PATCH 04/12] chore: scaffold libjxl-enc-sys crate --- Cargo.toml | 2 +- crates/libjxl-enc-sys/Cargo.toml | 12 ++++++++++++ crates/libjxl-enc-sys/src/lib.rs | 6 ++++++ 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 crates/libjxl-enc-sys/Cargo.toml create mode 100644 crates/libjxl-enc-sys/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 6b933e4..648c8b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/slimg-core", "crates/slimg-ffi", "cli"] +members = ["crates/slimg-core", "crates/slimg-ffi", "crates/libjxl-enc-sys", "cli"] # The profile that 'dist' will build with [profile.dist] diff --git a/crates/libjxl-enc-sys/Cargo.toml b/crates/libjxl-enc-sys/Cargo.toml new file mode 100644 index 0000000..758f863 --- /dev/null +++ b/crates/libjxl-enc-sys/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "libjxl-enc-sys" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Minimal FFI bindings to libjxl encoder (vendored, BSD-3-Clause)" +publish = false +links = "jxl_enc" + +[build-dependencies] +cmake = "0.1" +bindgen = "0.71" diff --git a/crates/libjxl-enc-sys/src/lib.rs b/crates/libjxl-enc-sys/src/lib.rs new file mode 100644 index 0000000..cd503e4 --- /dev/null +++ b/crates/libjxl-enc-sys/src/lib.rs @@ -0,0 +1,6 @@ +#![allow(non_upper_case_globals)] +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] +#![allow(dead_code)] + +include!(concat!(env!("OUT_DIR"), "/bindings.rs")); From 5dd863ff722b6aad08b18d037329928a6b99ee22 Mon Sep 17 00:00:00 2001 From: GeonHwan Cha Date: Thu, 19 Feb 2026 13:10:34 +0900 Subject: [PATCH 05/12] feat(libjxl-enc-sys): implement build.rs with CMake + bindgen --- crates/libjxl-enc-sys/build.rs | 87 ++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 crates/libjxl-enc-sys/build.rs diff --git a/crates/libjxl-enc-sys/build.rs b/crates/libjxl-enc-sys/build.rs new file mode 100644 index 0000000..a7ceddf --- /dev/null +++ b/crates/libjxl-enc-sys/build.rs @@ -0,0 +1,87 @@ +use std::env; +use std::path::PathBuf; + +fn main() { + let dst = cmake::Config::new("libjxl") + .define("BUILD_TESTING", "OFF") + .define("BUILD_SHARED_LIBS", "OFF") + .define("JPEGXL_ENABLE_TOOLS", "OFF") + .define("JPEGXL_ENABLE_DOXYGEN", "OFF") + .define("JPEGXL_ENABLE_MANPAGES", "OFF") + .define("JPEGXL_ENABLE_BENCHMARK", "OFF") + .define("JPEGXL_ENABLE_EXAMPLES", "OFF") + .define("JPEGXL_ENABLE_SJPEG", "OFF") + .define("JPEGXL_ENABLE_JPEGLI", "OFF") + .define("JPEGXL_ENABLE_OPENEXR", "OFF") + .define("JPEGXL_ENABLE_TCMALLOC", "OFF") + .define("JPEGXL_BUNDLE_LIBPNG", "OFF") + .define("JPEGXL_ENABLE_SKCMS", "ON") + .build(); + + let lib_dir = dst.join("lib"); + let lib64_dir = dst.join("lib64"); + println!("cargo:rustc-link-search=native={}", lib_dir.display()); + println!("cargo:rustc-link-search=native={}", lib64_dir.display()); + + // libjxl core (encoder is bundled into libjxl.a) + println!("cargo:rustc-link-lib=static=jxl"); + println!("cargo:rustc-link-lib=static=jxl_cms"); + + // libjxl vendored dependencies + println!("cargo:rustc-link-lib=static=hwy"); + println!("cargo:rustc-link-lib=static=brotlienc"); + println!("cargo:rustc-link-lib=static=brotlidec"); + println!("cargo:rustc-link-lib=static=brotlicommon"); + + // C++ standard library + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + match target_os.as_str() { + "macos" | "ios" => println!("cargo:rustc-link-lib=c++"), + "windows" => {} // MSVC links C++ runtime automatically + _ => println!("cargo:rustc-link-lib=stdc++"), + } + + // bindgen + let include_dir = dst.join("include"); + let src_include = PathBuf::from("libjxl/lib/include"); + + let bindings = bindgen::Builder::default() + .header(src_include.join("jxl/encode.h").to_str().unwrap()) + .header(src_include.join("jxl/types.h").to_str().unwrap()) + .header(src_include.join("jxl/codestream_header.h").to_str().unwrap()) + .header(src_include.join("jxl/color_encoding.h").to_str().unwrap()) + .clang_arg(format!("-I{}", src_include.display())) + .clang_arg(format!("-I{}", include_dir.display())) + // Encoder functions only + .allowlist_function("JxlEncoderCreate") + .allowlist_function("JxlEncoderDestroy") + .allowlist_function("JxlEncoderReset") + .allowlist_function("JxlEncoderSetBasicInfo") + .allowlist_function("JxlEncoderSetColorEncoding") + .allowlist_function("JxlEncoderFrameSettingsCreate") + .allowlist_function("JxlEncoderSetFrameDistance") + .allowlist_function("JxlEncoderSetFrameLossless") + .allowlist_function("JxlEncoderFrameSettingsSetOption") + .allowlist_function("JxlEncoderAddImageFrame") + .allowlist_function("JxlEncoderCloseInput") + .allowlist_function("JxlEncoderProcessOutput") + .allowlist_function("JxlEncoderDistanceFromQuality") + .allowlist_function("JxlColorEncodingSetToSRGB") + // Types + .allowlist_type("JxlBasicInfo") + .allowlist_type("JxlPixelFormat") + .allowlist_type("JxlDataType") + .allowlist_type("JxlEndianness") + .allowlist_type("JxlColorEncoding") + .allowlist_type("JxlEncoderStatus") + .allowlist_type("JxlEncoderFrameSettingId") + .allowlist_type("JxlEncoder") + .allowlist_type("JxlEncoderFrameSettings") + .generate() + .expect("failed to generate libjxl bindings"); + + let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()); + bindings + .write_to_file(out_path.join("bindings.rs")) + .expect("failed to write bindings"); +} From fd7080cff80afd95211eba42818ea8781ed2b986 Mon Sep 17 00:00:00 2001 From: GeonHwan Cha Date: Thu, 19 Feb 2026 13:16:39 +0900 Subject: [PATCH 06/12] refactor(slimg-core): convert jxl.rs to jxl/ module directory --- crates/libjxl-enc-sys/build.rs | 2 ++ crates/slimg-core/Cargo.toml | 1 + crates/slimg-core/src/codec/jxl/encoder.rs | 1 + .../src/codec/{jxl.rs => jxl/mod.rs} | 3 +++ crates/slimg-core/src/codec/jxl/types.rs | 22 +++++++++++++++++++ 5 files changed, 29 insertions(+) create mode 100644 crates/slimg-core/src/codec/jxl/encoder.rs rename crates/slimg-core/src/codec/{jxl.rs => jxl/mod.rs} (97%) create mode 100644 crates/slimg-core/src/codec/jxl/types.rs diff --git a/crates/libjxl-enc-sys/build.rs b/crates/libjxl-enc-sys/build.rs index a7ceddf..1b46719 100644 --- a/crates/libjxl-enc-sys/build.rs +++ b/crates/libjxl-enc-sys/build.rs @@ -44,6 +44,7 @@ fn main() { // bindgen let include_dir = dst.join("include"); let src_include = PathBuf::from("libjxl/lib/include"); + let target = env::var("TARGET").unwrap(); let bindings = bindgen::Builder::default() .header(src_include.join("jxl/encode.h").to_str().unwrap()) @@ -52,6 +53,7 @@ fn main() { .header(src_include.join("jxl/color_encoding.h").to_str().unwrap()) .clang_arg(format!("-I{}", src_include.display())) .clang_arg(format!("-I{}", include_dir.display())) + .clang_arg(format!("--target={target}")) // Encoder functions only .allowlist_function("JxlEncoderCreate") .allowlist_function("JxlEncoderDestroy") diff --git a/crates/slimg-core/Cargo.toml b/crates/slimg-core/Cargo.toml index ae5b23b..2150654 100644 --- a/crates/slimg-core/Cargo.toml +++ b/crates/slimg-core/Cargo.toml @@ -12,6 +12,7 @@ categories = ["multimedia::images", "encoding"] [dependencies] image = { version = "0.25", features = ["avif-native"] } +libjxl-enc-sys = { path = "../libjxl-enc-sys" } mozjpeg = "0.10" oxipng = { version = "10", default-features = false, features = ["parallel", "zopfli"] } imgref = "1" diff --git a/crates/slimg-core/src/codec/jxl/encoder.rs b/crates/slimg-core/src/codec/jxl/encoder.rs new file mode 100644 index 0000000..df23fae --- /dev/null +++ b/crates/slimg-core/src/codec/jxl/encoder.rs @@ -0,0 +1 @@ +// Safe wrapper — implemented in Task 5. diff --git a/crates/slimg-core/src/codec/jxl.rs b/crates/slimg-core/src/codec/jxl/mod.rs similarity index 97% rename from crates/slimg-core/src/codec/jxl.rs rename to crates/slimg-core/src/codec/jxl/mod.rs index 117f4e4..1a9c323 100644 --- a/crates/slimg-core/src/codec/jxl.rs +++ b/crates/slimg-core/src/codec/jxl/mod.rs @@ -1,3 +1,6 @@ +mod encoder; +mod types; + use crate::error::{Error, Result}; use crate::format::Format; diff --git a/crates/slimg-core/src/codec/jxl/types.rs b/crates/slimg-core/src/codec/jxl/types.rs new file mode 100644 index 0000000..08fc41f --- /dev/null +++ b/crates/slimg-core/src/codec/jxl/types.rs @@ -0,0 +1,22 @@ +/// JXL encoding configuration. +pub(crate) struct EncodeConfig { + pub lossless: bool, + pub distance: f32, +} + +impl EncodeConfig { + pub fn from_quality(quality: u8) -> Self { + if quality >= 100 { + return Self { + lossless: true, + distance: 0.0, + }; + } + let distance = + unsafe { libjxl_enc_sys::JxlEncoderDistanceFromQuality(quality as f32) }; + Self { + lossless: false, + distance, + } + } +} From 7870bbffd7d1671d982f95ee70060a85ebb715f6 Mon Sep 17 00:00:00 2001 From: GeonHwan Cha Date: Thu, 19 Feb 2026 13:40:45 +0900 Subject: [PATCH 07/12] feat(slimg-core): implement safe JXL encoder wrapper --- crates/slimg-core/src/codec/jxl/encoder.rs | 185 ++++++++++++++++++++- 1 file changed, 184 insertions(+), 1 deletion(-) diff --git a/crates/slimg-core/src/codec/jxl/encoder.rs b/crates/slimg-core/src/codec/jxl/encoder.rs index df23fae..977fd23 100644 --- a/crates/slimg-core/src/codec/jxl/encoder.rs +++ b/crates/slimg-core/src/codec/jxl/encoder.rs @@ -1 +1,184 @@ -// Safe wrapper — implemented in Task 5. +use std::ptr; + +use libjxl_enc_sys::*; + +use crate::error::{Error, Result}; + +use super::types::EncodeConfig; + +/// Safe wrapper around libjxl encoder. +pub(crate) struct Encoder { + ptr: *mut JxlEncoder, +} + +impl Encoder { + /// Create a new JXL encoder instance. + pub fn new() -> Result { + let ptr = unsafe { JxlEncoderCreate(ptr::null()) }; + if ptr.is_null() { + return Err(Error::Encode("failed to create JXL encoder".into())); + } + Ok(Self { ptr }) + } + + /// Encode RGBA pixel data into JXL format. + pub fn encode_rgba( + &mut self, + pixels: &[u8], + width: u32, + height: u32, + config: &EncodeConfig, + ) -> Result> { + unsafe { JxlEncoderReset(self.ptr) }; + + self.set_basic_info(width, height, config)?; + self.set_color_encoding()?; + + let frame_settings = + unsafe { JxlEncoderFrameSettingsCreate(self.ptr, ptr::null()) }; + if frame_settings.is_null() { + return Err(Error::Encode( + "failed to create frame settings".into(), + )); + } + + self.configure_frame(frame_settings, config)?; + self.add_frame(frame_settings, pixels, width, height)?; + + unsafe { JxlEncoderCloseInput(self.ptr) }; + + self.process_output() + } + + fn set_basic_info( + &self, + width: u32, + height: u32, + config: &EncodeConfig, + ) -> Result<()> { + unsafe { + let mut info: JxlBasicInfo = std::mem::zeroed(); + info.xsize = width; + info.ysize = height; + info.bits_per_sample = 8; + info.exponent_bits_per_sample = 0; + info.num_color_channels = 3; + info.num_extra_channels = 1; + info.alpha_bits = 8; + info.alpha_exponent_bits = 0; + info.uses_original_profile = if config.lossless { 1 } else { 0 }; + + check_status( + JxlEncoderSetBasicInfo(self.ptr, &info), + "set basic info", + ) + } + } + + fn set_color_encoding(&self) -> Result<()> { + unsafe { + let mut color: JxlColorEncoding = std::mem::zeroed(); + JxlColorEncodingSetToSRGB(&mut color, 0); // is_gray = false + check_status( + JxlEncoderSetColorEncoding(self.ptr, &color), + "set color encoding", + ) + } + } + + fn configure_frame( + &self, + settings: *mut JxlEncoderFrameSettings, + config: &EncodeConfig, + ) -> Result<()> { + if config.lossless { + unsafe { + check_status( + JxlEncoderSetFrameLossless(settings, 1), + "set lossless", + )?; + } + } + unsafe { + check_status( + JxlEncoderSetFrameDistance(settings, config.distance), + "set distance", + ) + } + } + + fn add_frame( + &self, + settings: *mut JxlEncoderFrameSettings, + pixels: &[u8], + width: u32, + height: u32, + ) -> Result<()> { + let format = JxlPixelFormat { + num_channels: 4, + data_type: JxlDataType_JXL_TYPE_UINT8, + endianness: JxlEndianness_JXL_NATIVE_ENDIAN, + align: 0, + }; + + let expected = (width as usize) * (height as usize) * 4; + debug_assert_eq!(pixels.len(), expected); + + unsafe { + check_status( + JxlEncoderAddImageFrame( + settings, + &format, + pixels.as_ptr().cast(), + pixels.len(), + ), + "add image frame", + ) + } + } + + fn process_output(&self) -> Result> { + let mut buffer = vec![0u8; 64 * 1024]; // 64 KB initial + let mut all_output = Vec::new(); + + loop { + let mut next_out = buffer.as_mut_ptr(); + let mut avail_out = buffer.len(); + + let status = unsafe { + JxlEncoderProcessOutput(self.ptr, &mut next_out, &mut avail_out) + }; + + let written = buffer.len() - avail_out; + all_output.extend_from_slice(&buffer[..written]); + + if status == JxlEncoderStatus_JXL_ENC_SUCCESS { + return Ok(all_output); + } else if status == JxlEncoderStatus_JXL_ENC_NEED_MORE_OUTPUT { + continue; + } else { + return Err(Error::Encode("JXL encoding failed".into())); + } + } + } +} + +impl Drop for Encoder { + fn drop(&mut self) { + unsafe { + JxlEncoderDestroy(self.ptr); + } + } +} + +/// Check a `JxlEncoderStatus` and convert to `Result`. +/// +/// # Safety +/// The caller must ensure `status` was returned by a valid libjxl encoder call. +unsafe fn check_status(status: JxlEncoderStatus, context: &str) -> Result<()> { + if status == JxlEncoderStatus_JXL_ENC_SUCCESS { + Ok(()) + } else { + Err(Error::Encode(format!("jxl {context}: status {status}"))) + } +} From 89467088bd66cf7612941b25020082ba3ca62d66 Mon Sep 17 00:00:00 2001 From: GeonHwan Cha Date: Thu, 19 Feb 2026 14:48:49 +0900 Subject: [PATCH 08/12] feat: rename libjxl-enc-sys to libjxl-sys, add JXL decoder via libjxl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename crate from libjxl-enc-sys to libjxl-sys (now provides both encoder and decoder bindings) - Add libjxl decoder bindings (JxlDecoder*) to build.rs bindgen - Create decoder.rs safe wrapper using libjxl event-based decode API - Rewrite JxlCodec to use libjxl for both encode and decode (replacing image crate's limited JXL decode support) - Add roundtrip tests (lossy + lossless) that verify encode→decode - Update pipeline test to expect JXL encoding success - Update format.rs can_encode to return true for all formats --- .gitmodules | 4 +- Cargo.lock | 69 ++++++++++++ Cargo.toml | 2 +- crates/libjxl-enc-sys/Cargo.toml | 12 -- crates/libjxl-sys/Cargo.toml | 13 +++ .../{libjxl-enc-sys => libjxl-sys}/build.rs | 41 +++++-- crates/{libjxl-enc-sys => libjxl-sys}/libjxl | 0 .../{libjxl-enc-sys => libjxl-sys}/src/lib.rs | 0 crates/slimg-core/Cargo.toml | 2 +- crates/slimg-core/src/codec/jxl/decoder.rs | 104 ++++++++++++++++++ crates/slimg-core/src/codec/jxl/encoder.rs | 3 +- crates/slimg-core/src/codec/jxl/mod.rs | 96 +++++++++++++--- crates/slimg-core/src/codec/jxl/types.rs | 2 +- crates/slimg-core/src/format.rs | 13 +-- crates/slimg-core/src/pipeline.rs | 4 +- 15 files changed, 311 insertions(+), 54 deletions(-) delete mode 100644 crates/libjxl-enc-sys/Cargo.toml create mode 100644 crates/libjxl-sys/Cargo.toml rename crates/{libjxl-enc-sys => libjxl-sys}/build.rs (73%) rename crates/{libjxl-enc-sys => libjxl-sys}/libjxl (100%) rename crates/{libjxl-enc-sys => libjxl-sys}/src/lib.rs (100%) create mode 100644 crates/slimg-core/src/codec/jxl/decoder.rs diff --git a/.gitmodules b/.gitmodules index d1d666f..2e09cb1 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "crates/libjxl-enc-sys/libjxl"] - path = crates/libjxl-enc-sys/libjxl +[submodule "crates/libjxl-sys/libjxl"] + path = crates/libjxl-sys/libjxl url = https://github.com/libjxl/libjxl.git diff --git a/Cargo.lock b/Cargo.lock index 8ba773f..e163419 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -254,6 +254,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.10.5", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + [[package]] name = "bit_field" version = "0.10.3" @@ -388,6 +408,15 @@ dependencies = [ "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfg-expr" version = "0.20.6" @@ -431,6 +460,17 @@ dependencies = [ "half", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.5.59" @@ -480,6 +520,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1062,6 +1111,25 @@ dependencies = [ "cc", ] +[[package]] +name = "libjxl-sys" +version = "0.1.0" +dependencies = [ + "bindgen", + "cc", + "cmake", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libwebp-sys" version = "0.9.6" @@ -1795,6 +1863,7 @@ dependencies = [ "criterion", "image", "imgref", + "libjxl-sys", "mozjpeg", "oxipng", "rapid-qoi", diff --git a/Cargo.toml b/Cargo.toml index 648c8b0..f5da36b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/slimg-core", "crates/slimg-ffi", "crates/libjxl-enc-sys", "cli"] +members = ["crates/slimg-core", "crates/slimg-ffi", "crates/libjxl-sys", "cli"] # The profile that 'dist' will build with [profile.dist] diff --git a/crates/libjxl-enc-sys/Cargo.toml b/crates/libjxl-enc-sys/Cargo.toml deleted file mode 100644 index 758f863..0000000 --- a/crates/libjxl-enc-sys/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "libjxl-enc-sys" -version = "0.1.0" -edition = "2024" -license = "MIT" -description = "Minimal FFI bindings to libjxl encoder (vendored, BSD-3-Clause)" -publish = false -links = "jxl_enc" - -[build-dependencies] -cmake = "0.1" -bindgen = "0.71" diff --git a/crates/libjxl-sys/Cargo.toml b/crates/libjxl-sys/Cargo.toml new file mode 100644 index 0000000..e967d70 --- /dev/null +++ b/crates/libjxl-sys/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "libjxl-sys" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Minimal FFI bindings to libjxl encoder/decoder (vendored, BSD-3-Clause)" +publish = false +links = "jxl" + +[build-dependencies] +cmake = "0.1" +cc = "1" +bindgen = "0.71" diff --git a/crates/libjxl-enc-sys/build.rs b/crates/libjxl-sys/build.rs similarity index 73% rename from crates/libjxl-enc-sys/build.rs rename to crates/libjxl-sys/build.rs index 1b46719..e92f58f 100644 --- a/crates/libjxl-enc-sys/build.rs +++ b/crates/libjxl-sys/build.rs @@ -23,7 +23,17 @@ fn main() { println!("cargo:rustc-link-search=native={}", lib_dir.display()); println!("cargo:rustc-link-search=native={}", lib64_dir.display()); - // libjxl core (encoder is bundled into libjxl.a) + // skcms transform files not included in libjxl's skcms.cmake + let skcms_src = PathBuf::from("libjxl/third_party/skcms/src"); + let skcms_inc = PathBuf::from("libjxl/third_party/skcms/src"); + cc::Build::new() + .cpp(true) + .file(skcms_src.join("skcms_TransformBaseline.cc")) + .include(&skcms_inc) + .flag_if_supported("-Wno-psabi") + .compile("skcms_transform"); + + // libjxl core (encoder + decoder are bundled into libjxl.a) println!("cargo:rustc-link-lib=static=jxl"); println!("cargo:rustc-link-lib=static=jxl_cms"); @@ -48,13 +58,14 @@ fn main() { let bindings = bindgen::Builder::default() .header(src_include.join("jxl/encode.h").to_str().unwrap()) + .header(src_include.join("jxl/decode.h").to_str().unwrap()) .header(src_include.join("jxl/types.h").to_str().unwrap()) .header(src_include.join("jxl/codestream_header.h").to_str().unwrap()) .header(src_include.join("jxl/color_encoding.h").to_str().unwrap()) .clang_arg(format!("-I{}", src_include.display())) .clang_arg(format!("-I{}", include_dir.display())) .clang_arg(format!("--target={target}")) - // Encoder functions only + // Encoder functions .allowlist_function("JxlEncoderCreate") .allowlist_function("JxlEncoderDestroy") .allowlist_function("JxlEncoderReset") @@ -69,16 +80,32 @@ fn main() { .allowlist_function("JxlEncoderProcessOutput") .allowlist_function("JxlEncoderDistanceFromQuality") .allowlist_function("JxlColorEncodingSetToSRGB") - // Types + // Decoder functions + .allowlist_function("JxlDecoderCreate") + .allowlist_function("JxlDecoderDestroy") + .allowlist_function("JxlDecoderReset") + .allowlist_function("JxlDecoderSubscribeEvents") + .allowlist_function("JxlDecoderSetInput") + .allowlist_function("JxlDecoderCloseInput") + .allowlist_function("JxlDecoderProcessInput") + .allowlist_function("JxlDecoderGetBasicInfo") + .allowlist_function("JxlDecoderImageOutBufferSize") + .allowlist_function("JxlDecoderSetImageOutBuffer") + .allowlist_function("JxlDecoderReleaseInput") + // Encoder types + .allowlist_type("JxlEncoderStatus") + .allowlist_type("JxlEncoderFrameSettingId") + .allowlist_type("JxlEncoder") + .allowlist_type("JxlEncoderFrameSettings") + // Decoder types + .allowlist_type("JxlDecoder") + .allowlist_type("JxlDecoderStatus") + // Shared types .allowlist_type("JxlBasicInfo") .allowlist_type("JxlPixelFormat") .allowlist_type("JxlDataType") .allowlist_type("JxlEndianness") .allowlist_type("JxlColorEncoding") - .allowlist_type("JxlEncoderStatus") - .allowlist_type("JxlEncoderFrameSettingId") - .allowlist_type("JxlEncoder") - .allowlist_type("JxlEncoderFrameSettings") .generate() .expect("failed to generate libjxl bindings"); diff --git a/crates/libjxl-enc-sys/libjxl b/crates/libjxl-sys/libjxl similarity index 100% rename from crates/libjxl-enc-sys/libjxl rename to crates/libjxl-sys/libjxl diff --git a/crates/libjxl-enc-sys/src/lib.rs b/crates/libjxl-sys/src/lib.rs similarity index 100% rename from crates/libjxl-enc-sys/src/lib.rs rename to crates/libjxl-sys/src/lib.rs diff --git a/crates/slimg-core/Cargo.toml b/crates/slimg-core/Cargo.toml index 2150654..ff0e1e7 100644 --- a/crates/slimg-core/Cargo.toml +++ b/crates/slimg-core/Cargo.toml @@ -12,7 +12,7 @@ categories = ["multimedia::images", "encoding"] [dependencies] image = { version = "0.25", features = ["avif-native"] } -libjxl-enc-sys = { path = "../libjxl-enc-sys" } +libjxl-sys = { path = "../libjxl-sys" } mozjpeg = "0.10" oxipng = { version = "10", default-features = false, features = ["parallel", "zopfli"] } imgref = "1" diff --git a/crates/slimg-core/src/codec/jxl/decoder.rs b/crates/slimg-core/src/codec/jxl/decoder.rs new file mode 100644 index 0000000..cf1a0ae --- /dev/null +++ b/crates/slimg-core/src/codec/jxl/decoder.rs @@ -0,0 +1,104 @@ +use std::ptr; + +use libjxl_sys::*; + +use crate::error::{Error, Result}; + +/// Safe wrapper around libjxl decoder. +pub(crate) struct Decoder { + ptr: *mut JxlDecoder, +} + +impl Decoder { + /// Create a new JXL decoder instance. + pub fn new() -> Result { + let ptr = unsafe { JxlDecoderCreate(ptr::null()) }; + if ptr.is_null() { + return Err(Error::Decode("failed to create JXL decoder".into())); + } + Ok(Self { ptr }) + } + + /// Decode JXL data into RGBA pixels. Returns (width, height, rgba_pixels). + pub fn decode_to_rgba(&mut self, data: &[u8]) -> Result<(u32, u32, Vec)> { + unsafe { JxlDecoderReset(self.ptr) }; + + let events = JxlDecoderStatus_JXL_DEC_BASIC_INFO | JxlDecoderStatus_JXL_DEC_FULL_IMAGE; + let status = unsafe { JxlDecoderSubscribeEvents(self.ptr, events as i32) }; + if status != JxlDecoderStatus_JXL_DEC_SUCCESS { + return Err(Error::Decode("failed to subscribe decoder events".into())); + } + + let status = unsafe { + JxlDecoderSetInput(self.ptr, data.as_ptr(), data.len()) + }; + if status != JxlDecoderStatus_JXL_DEC_SUCCESS { + return Err(Error::Decode("failed to set decoder input".into())); + } + unsafe { JxlDecoderCloseInput(self.ptr) }; + + let format = JxlPixelFormat { + num_channels: 4, + data_type: JxlDataType_JXL_TYPE_UINT8, + endianness: JxlEndianness_JXL_NATIVE_ENDIAN, + align: 0, + }; + + let mut width = 0u32; + let mut height = 0u32; + let mut pixels: Vec = Vec::new(); + + loop { + let status = unsafe { JxlDecoderProcessInput(self.ptr) }; + + if status == JxlDecoderStatus_JXL_DEC_BASIC_INFO { + let mut info: JxlBasicInfo = unsafe { std::mem::zeroed() }; + let s = unsafe { JxlDecoderGetBasicInfo(self.ptr, &mut info) }; + if s != JxlDecoderStatus_JXL_DEC_SUCCESS { + return Err(Error::Decode("failed to get basic info".into())); + } + width = info.xsize; + height = info.ysize; + + let mut buf_size: usize = 0; + let s = unsafe { + JxlDecoderImageOutBufferSize(self.ptr, &format, &mut buf_size) + }; + if s != JxlDecoderStatus_JXL_DEC_SUCCESS { + return Err(Error::Decode("failed to get output buffer size".into())); + } + + pixels.resize(buf_size, 0); + let s = unsafe { + JxlDecoderSetImageOutBuffer( + self.ptr, + &format, + pixels.as_mut_ptr().cast(), + pixels.len(), + ) + }; + if s != JxlDecoderStatus_JXL_DEC_SUCCESS { + return Err(Error::Decode("failed to set output buffer".into())); + } + } else if status == JxlDecoderStatus_JXL_DEC_FULL_IMAGE { + return Ok((width, height, pixels)); + } else if status == JxlDecoderStatus_JXL_DEC_SUCCESS { + if !pixels.is_empty() { + return Ok((width, height, pixels)); + } + return Err(Error::Decode("decoder finished without producing image".into())); + } else if status == JxlDecoderStatus_JXL_DEC_ERROR { + return Err(Error::Decode("JXL decoding failed".into())); + } + // JXL_DEC_NEED_MORE_INPUT shouldn't happen since we closed input + } + } +} + +impl Drop for Decoder { + fn drop(&mut self) { + unsafe { + JxlDecoderDestroy(self.ptr); + } + } +} diff --git a/crates/slimg-core/src/codec/jxl/encoder.rs b/crates/slimg-core/src/codec/jxl/encoder.rs index 977fd23..adc8dc2 100644 --- a/crates/slimg-core/src/codec/jxl/encoder.rs +++ b/crates/slimg-core/src/codec/jxl/encoder.rs @@ -1,6 +1,6 @@ use std::ptr; -use libjxl_enc_sys::*; +use libjxl_sys::*; use crate::error::{Error, Result}; @@ -66,6 +66,7 @@ impl Encoder { info.num_extra_channels = 1; info.alpha_bits = 8; info.alpha_exponent_bits = 0; + info.orientation = JxlOrientation_JXL_ORIENT_IDENTITY; info.uses_original_profile = if config.lossless { 1 } else { 0 }; check_status( diff --git a/crates/slimg-core/src/codec/jxl/mod.rs b/crates/slimg-core/src/codec/jxl/mod.rs index 1a9c323..9ef8056 100644 --- a/crates/slimg-core/src/codec/jxl/mod.rs +++ b/crates/slimg-core/src/codec/jxl/mod.rs @@ -1,12 +1,13 @@ +mod decoder; mod encoder; mod types; -use crate::error::{Error, Result}; +use crate::error::Result; use crate::format::Format; use super::{Codec, EncodeOptions, ImageData}; -/// JXL codec — decode-only (encoding is not supported due to license restrictions). +/// JXL codec backed by libjxl (BSD-3-Clause) for both encoding and decoding. pub struct JxlCodec; impl Codec for JxlCodec { @@ -15,18 +16,15 @@ impl Codec for JxlCodec { } fn decode(&self, data: &[u8]) -> Result { - let img = - image::load_from_memory(data).map_err(|e| Error::Decode(format!("jxl decode: {e}")))?; - - let rgba = img.to_rgba8(); - let width = rgba.width(); - let height = rgba.height(); - - Ok(ImageData::new(width, height, rgba.into_raw())) + let mut dec = decoder::Decoder::new()?; + let (width, height, pixels) = dec.decode_to_rgba(data)?; + Ok(ImageData::new(width, height, pixels)) } - fn encode(&self, _image: &ImageData, _options: &EncodeOptions) -> Result> { - Err(Error::EncodingNotSupported(Format::Jxl)) + fn encode(&self, image: &ImageData, options: &EncodeOptions) -> Result> { + let config = types::EncodeConfig::from_quality(options.quality); + let mut enc = encoder::Encoder::new()?; + enc.encode_rgba(&image.data, image.width, image.height, &config) } } @@ -34,14 +32,78 @@ impl Codec for JxlCodec { mod tests { use super::*; + fn create_test_image(width: u32, height: u32) -> ImageData { + let mut pixels = Vec::with_capacity((width * height * 4) as usize); + for y in 0..height { + for x in 0..width { + let r = ((x * 255) / width.max(1)) as u8; + let g = ((y * 255) / height.max(1)) as u8; + let b = 128u8; + let a = 255u8; + pixels.extend_from_slice(&[r, g, b, a]); + } + } + ImageData::new(width, height, pixels) + } + #[test] - fn encode_returns_not_supported() { + fn encode_lossy_produces_valid_jxl() { let codec = JxlCodec; - let data = vec![0u8; 4 * 2 * 2]; - let image = ImageData::new(2, 2, data); + let image = create_test_image(8, 8); let options = EncodeOptions { quality: 80 }; - let result = codec.encode(&image, &options); - assert!(result.is_err(), "JXL encoding should not be supported"); + let encoded = codec.encode(&image, &options).expect("encode should succeed"); + assert!(!encoded.is_empty(), "encoded data should not be empty"); + + // Check JXL magic bytes (bare codestream: 0xFF 0x0A) + assert!( + (encoded.len() >= 2 && encoded[0] == 0xFF && encoded[1] == 0x0A) + || (encoded.len() >= 8 + && encoded[..4] == [0x00, 0x00, 0x00, 0x0C] + && &encoded[4..8] == b"JXL "), + "output should have a valid JXL signature" + ); + } + + #[test] + fn encode_lossless_produces_valid_jxl() { + let codec = JxlCodec; + let image = create_test_image(8, 8); + let options = EncodeOptions { quality: 100 }; + + let encoded = codec.encode(&image, &options).expect("lossless encode should succeed"); + assert!(!encoded.is_empty()); + } + + #[test] + fn roundtrip_lossy() { + let codec = JxlCodec; + let original = create_test_image(16, 16); + let options = EncodeOptions { quality: 90 }; + + let encoded = codec.encode(&original, &options).expect("encode failed"); + let decoded = codec.decode(&encoded).expect("decode failed"); + + assert_eq!(decoded.width, original.width); + assert_eq!(decoded.height, original.height); + assert_eq!(decoded.data.len(), original.data.len()); + } + + #[test] + fn roundtrip_lossless() { + let codec = JxlCodec; + let original = create_test_image(4, 4); + let options = EncodeOptions { quality: 100 }; + + let encoded = codec.encode(&original, &options).expect("encode failed"); + let decoded = codec.decode(&encoded).expect("decode failed"); + + assert_eq!(decoded.width, original.width); + assert_eq!(decoded.height, original.height); + assert_eq!( + decoded.data, + original.data, + "lossless roundtrip should produce identical pixels" + ); } } diff --git a/crates/slimg-core/src/codec/jxl/types.rs b/crates/slimg-core/src/codec/jxl/types.rs index 08fc41f..94d6f1a 100644 --- a/crates/slimg-core/src/codec/jxl/types.rs +++ b/crates/slimg-core/src/codec/jxl/types.rs @@ -13,7 +13,7 @@ impl EncodeConfig { }; } let distance = - unsafe { libjxl_enc_sys::JxlEncoderDistanceFromQuality(quality as f32) }; + unsafe { libjxl_sys::JxlEncoderDistanceFromQuality(quality as f32) }; Self { lossless: false, distance, diff --git a/crates/slimg-core/src/format.rs b/crates/slimg-core/src/format.rs index 1a8f4e7..7be437d 100644 --- a/crates/slimg-core/src/format.rs +++ b/crates/slimg-core/src/format.rs @@ -70,11 +70,8 @@ impl Format { } /// Whether encoding is supported for this format. - /// - /// Returns `false` only for JXL due to GPL license restrictions - /// in the reference encoder. pub fn can_encode(&self) -> bool { - !matches!(self, Self::Jxl) + true } } @@ -272,16 +269,12 @@ mod tests { // ── can_encode ────────────────────────────────────────────── #[test] - fn can_encode_jxl_is_false() { - assert!(!Format::Jxl.can_encode()); - } - - #[test] - fn can_encode_all_others_true() { + fn can_encode_all_formats() { assert!(Format::Jpeg.can_encode()); assert!(Format::Png.can_encode()); assert!(Format::WebP.can_encode()); assert!(Format::Avif.can_encode()); + assert!(Format::Jxl.can_encode()); assert!(Format::Qoi.can_encode()); } } diff --git a/crates/slimg-core/src/pipeline.rs b/crates/slimg-core/src/pipeline.rs index 074e613..17ebbb9 100644 --- a/crates/slimg-core/src/pipeline.rs +++ b/crates/slimg-core/src/pipeline.rs @@ -154,7 +154,7 @@ mod tests { } #[test] - fn jxl_encode_returns_error() { + fn jxl_encode_succeeds() { let image = ImageData::new(2, 2, vec![128u8; 16]); let options = PipelineOptions { format: Format::Jxl, @@ -165,6 +165,6 @@ mod tests { fill_color: None, }; let result = convert(&image, &options); - assert!(result.is_err(), "converting to JXL should fail"); + assert!(result.is_ok(), "converting to JXL should succeed"); } } From 3b67e517a68660425392de3fba3a5448b8a801aa Mon Sep 17 00:00:00 2001 From: GeonHwan Cha Date: Thu, 19 Feb 2026 15:01:04 +0900 Subject: [PATCH 09/12] ci: add cmake dependency and submodule checkout for libjxl build - Add cmake to dist-workspace.toml system dependencies (homebrew, apt, chocolatey) for cargo-dist release builds - Add submodules: recursive to kotlin/python bindings workflows - Add cmake to system dependencies install steps in both workflows - Add crates/libjxl-sys/** to path triggers for bindings workflows --- .github/workflows/kotlin-bindings.yml | 10 +++++++--- .github/workflows/python-bindings.yml | 10 +++++++--- dist-workspace.toml | 3 +++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.github/workflows/kotlin-bindings.yml b/.github/workflows/kotlin-bindings.yml index eaa36fd..cc313b6 100644 --- a/.github/workflows/kotlin-bindings.yml +++ b/.github/workflows/kotlin-bindings.yml @@ -6,6 +6,7 @@ on: paths: - 'crates/slimg-ffi/**' - 'crates/slimg-core/**' + - 'crates/libjxl-sys/**' - 'bindings/kotlin/**' - 'bindings/scripts/**' - '.github/workflows/kotlin-bindings.yml' @@ -13,6 +14,7 @@ on: paths: - 'crates/slimg-ffi/**' - 'crates/slimg-core/**' + - 'crates/libjxl-sys/**' - 'bindings/kotlin/**' - 'bindings/scripts/**' - '.github/workflows/kotlin-bindings.yml' @@ -28,17 +30,19 @@ jobs: steps: - uses: actions/checkout@v4 + with: + submodules: recursive - name: Install Rust uses: dtolnay/rust-toolchain@stable - name: Install system dependencies (macOS) if: runner.os == 'macOS' - run: brew install nasm meson ninja + run: brew install cmake nasm meson ninja - name: Install system dependencies (Linux) if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y nasm meson ninja-build + run: sudo apt-get update && sudo apt-get install -y cmake nasm meson ninja-build - name: Setup MSVC environment (Windows) if: runner.os == 'Windows' @@ -53,7 +57,7 @@ jobs: if: runner.os == 'Windows' shell: bash run: | - choco install nasm ninja pkgconfiglite -y + choco install cmake nasm ninja pkgconfiglite -y pip install meson - name: Force MSVC compiler for meson (Windows) diff --git a/.github/workflows/python-bindings.yml b/.github/workflows/python-bindings.yml index a620c62..dabda46 100644 --- a/.github/workflows/python-bindings.yml +++ b/.github/workflows/python-bindings.yml @@ -6,12 +6,14 @@ on: paths: - 'crates/slimg-ffi/**' - 'crates/slimg-core/**' + - 'crates/libjxl-sys/**' - 'bindings/python/**' - '.github/workflows/python-bindings.yml' pull_request: paths: - 'crates/slimg-ffi/**' - 'crates/slimg-core/**' + - 'crates/libjxl-sys/**' - 'bindings/python/**' - '.github/workflows/python-bindings.yml' @@ -27,6 +29,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + submodules: recursive - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -38,11 +42,11 @@ jobs: - name: Install system dependencies (macOS) if: runner.os == 'macOS' - run: brew install nasm meson ninja + run: brew install cmake nasm meson ninja - name: Install system dependencies (Linux) if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y nasm meson ninja-build + run: sudo apt-get update && sudo apt-get install -y cmake nasm meson ninja-build - name: Setup MSVC environment (Windows) if: runner.os == 'Windows' @@ -57,7 +61,7 @@ jobs: if: runner.os == 'Windows' shell: bash run: | - choco install nasm ninja pkgconfiglite -y + choco install cmake nasm ninja pkgconfiglite -y pip install meson - name: Force MSVC compiler for meson (Windows) diff --git a/dist-workspace.toml b/dist-workspace.toml index 04cb0cf..c15fe9a 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -24,15 +24,18 @@ github-build-setup = "../ci/build-setup.yml" # System dependencies required for building [dist.dependencies.homebrew] +cmake = { stage = ["build"] } nasm = { stage = ["build"] } meson = { stage = ["build"] } ninja = { stage = ["build"] } [dist.dependencies.apt] +cmake = '*' nasm = '*' meson = '*' ninja-build = '*' [dist.dependencies.chocolatey] +cmake = '*' nasm = '*' ninja = '*' From 191ed4bf3d200b6515dc2d09a6a981ef70871187 Mon Sep 17 00:00:00 2001 From: GeonHwan Cha Date: Thu, 19 Feb 2026 15:13:39 +0900 Subject: [PATCH 10/12] feat(cli): add JXL to CLI format options JXL encoding is now supported, so add it to FormatArg enum to allow --format jxl in convert, resize, crop, extend commands. --- cli/src/commands/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 37f48ba..84dd116 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -12,13 +12,14 @@ use clap::ValueEnum; use indicatif::{ProgressBar, ProgressStyle}; use slimg_core::Format; -/// Image format argument for CLI (excludes JXL which cannot be encoded). +/// Image format argument for CLI. #[derive(Debug, Clone, Copy, ValueEnum)] pub enum FormatArg { Jpeg, Png, Webp, Avif, + Jxl, Qoi, } @@ -29,6 +30,7 @@ impl FormatArg { Self::Png => Format::Png, Self::Webp => Format::WebP, Self::Avif => Format::Avif, + Self::Jxl => Format::Jxl, Self::Qoi => Format::Qoi, } } From 9803312b35138cff83022b8816d480c6684737cb Mon Sep 17 00:00:00 2001 From: GeonHwan Cha Date: Thu, 19 Feb 2026 15:55:35 +0900 Subject: [PATCH 11/12] refactor: rename libjxl-sys to slimg-libjxl-sys for crates.io publishing - Rename crate from `libjxl-sys` to `slimg-libjxl-sys` (existing name taken on crates.io) - Remove `publish = false`, add repository/homepage metadata - Use `package = "slimg-libjxl-sys"` alias in slimg-core to keep `libjxl_sys` import name - Add slimg-libjxl-sys publish step to publish.yml (before slimg-core) Note: crates.io 10MB limit prevents vendoring libjxl source directly. Prebuilt binary download approach (build.rs) is planned as next step. --- .github/workflows/publish.yml | 13 ++++++++++++- Cargo.lock | 20 ++++++++++---------- crates/libjxl-sys/Cargo.toml | 5 +++-- crates/slimg-core/Cargo.toml | 2 +- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0bd912b..17a00a3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,7 +36,18 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y nasm meson ninja-build + run: sudo apt-get update && sudo apt-get install -y nasm meson ninja-build cmake + + - name: Checkout submodules + run: git submodule update --init --recursive + + - name: Publish slimg-libjxl-sys + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: cargo publish -p slimg-libjxl-sys || echo "Already published, skipping" + + - name: Wait for crates.io index (slimg-libjxl-sys) + run: sleep 30 - name: Publish slimg-core env: diff --git a/Cargo.lock b/Cargo.lock index e163419..a260a45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1111,15 +1111,6 @@ dependencies = [ "cc", ] -[[package]] -name = "libjxl-sys" -version = "0.1.0" -dependencies = [ - "bindgen", - "cc", - "cmake", -] - [[package]] name = "libloading" version = "0.8.9" @@ -1863,12 +1854,12 @@ dependencies = [ "criterion", "image", "imgref", - "libjxl-sys", "mozjpeg", "oxipng", "rapid-qoi", "ravif 0.13.0", "rgb", + "slimg-libjxl-sys", "thiserror", "webp", ] @@ -1882,6 +1873,15 @@ dependencies = [ "uniffi", ] +[[package]] +name = "slimg-libjxl-sys" +version = "0.1.0" +dependencies = [ + "bindgen", + "cc", + "cmake", +] + [[package]] name = "smallvec" version = "1.15.1" diff --git a/crates/libjxl-sys/Cargo.toml b/crates/libjxl-sys/Cargo.toml index e967d70..cfc36ac 100644 --- a/crates/libjxl-sys/Cargo.toml +++ b/crates/libjxl-sys/Cargo.toml @@ -1,10 +1,11 @@ [package] -name = "libjxl-sys" +name = "slimg-libjxl-sys" version = "0.1.0" edition = "2024" license = "MIT" description = "Minimal FFI bindings to libjxl encoder/decoder (vendored, BSD-3-Clause)" -publish = false +repository = "https://github.com/clroot/slimg" +homepage = "https://github.com/clroot/slimg" links = "jxl" [build-dependencies] diff --git a/crates/slimg-core/Cargo.toml b/crates/slimg-core/Cargo.toml index ff0e1e7..35e4a96 100644 --- a/crates/slimg-core/Cargo.toml +++ b/crates/slimg-core/Cargo.toml @@ -12,7 +12,7 @@ categories = ["multimedia::images", "encoding"] [dependencies] image = { version = "0.25", features = ["avif-native"] } -libjxl-sys = { path = "../libjxl-sys" } +libjxl-sys = { version = "0.1", path = "../libjxl-sys", package = "slimg-libjxl-sys" } mozjpeg = "0.10" oxipng = { version = "10", default-features = false, features = ["parallel", "zopfli"] } imgref = "1" From d44462b46e1b77fdec80c97e45dcf9807eba6649 Mon Sep 17 00:00:00 2001 From: GeonHwan Cha Date: Thu, 19 Feb 2026 16:39:53 +0900 Subject: [PATCH 12/12] feat(libjxl-sys): add prebuilt binary download for crates.io publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solve the crates.io 10MB package limit by downloading prebuilt static libraries from GitHub Releases instead of including the ~95MB libjxl submodule source. Build paths (in priority order): 1. LIBJXL_SYS_DIR env var — user-provided prebuilt directory 2. vendored feature — cmake source build (if submodule present) 3. prebuilt download — curl from GitHub Releases (crates.io default) Also add build-libjxl-prebuilt.yml CI workflow for building prebuilt archives across 5 platforms, and update publish.yml to use --no-default-features for the slimg-libjxl-sys crate. Remove separate skcms_transform compilation as the transform code is now integrated into skcms.cc (included in jxl_cms). --- .github/workflows/build-libjxl-prebuilt.yml | 302 ++++++++++++++++++++ .github/workflows/publish.yml | 10 +- Cargo.lock | 1 - crates/libjxl-sys/Cargo.toml | 20 +- crates/libjxl-sys/build.rs | 214 +++++++++++--- 5 files changed, 502 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/build-libjxl-prebuilt.yml diff --git a/.github/workflows/build-libjxl-prebuilt.yml b/.github/workflows/build-libjxl-prebuilt.yml new file mode 100644 index 0000000..72981cc --- /dev/null +++ b/.github/workflows/build-libjxl-prebuilt.yml @@ -0,0 +1,302 @@ +name: Build libjxl Prebuilt Libraries + +on: + workflow_dispatch: + inputs: + crate_version: + description: 'slimg-libjxl-sys crate version (e.g. 0.1.0)' + required: true + type: string + libjxl_tag: + description: 'libjxl git tag to build (e.g. v0.11.1)' + required: true + default: 'v0.11.1' + type: string + +permissions: + contents: write + +env: + RELEASE_TAG: libjxl-prebuilt-v${{ inputs.crate_version }} + +jobs: + build: + name: Build (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux-x86_64 + runner: ubuntu-latest + target: x86_64-unknown-linux-gnu + + - platform: linux-aarch64 + runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + + - platform: macos-x86_64 + runner: macos-15-intel + target: x86_64-apple-darwin + + - platform: macos-aarch64 + runner: macos-latest + target: aarch64-apple-darwin + + - platform: windows-x86_64 + runner: windows-latest + target: x86_64-pc-windows-msvc + + steps: + - uses: actions/checkout@v4 + + - name: Checkout libjxl ${{ inputs.libjxl_tag }} + shell: bash + run: | + git clone --depth 1 --branch "${{ inputs.libjxl_tag }}" \ + --recurse-submodules --shallow-submodules \ + https://github.com/libjxl/libjxl.git \ + libjxl-src + + # ── Platform toolchain setup ─────────────────────────────────────── + - name: Install build tools (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y cmake ninja-build nasm clang libclang-dev + + - name: Install build tools (macOS) + if: runner.os == 'macOS' + run: brew install cmake ninja nasm + + - name: Setup MSVC (Windows) + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@v1 + + - name: Install build tools (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + choco install cmake ninja nasm -y + echo "C:/Program Files/NASM" >> $GITHUB_PATH + echo "CC=cl" >> $GITHUB_ENV + echo "CXX=cl" >> $GITHUB_ENV + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + # ── Build libjxl via cmake ───────────────────────────────────────── + - name: Configure libjxl (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + cmake \ + -S libjxl-src \ + -B libjxl-build \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="${PWD}/libjxl-install" \ + -DBUILD_TESTING=OFF \ + -DBUILD_SHARED_LIBS=OFF \ + -DJPEGXL_ENABLE_TOOLS=OFF \ + -DJPEGXL_ENABLE_DOXYGEN=OFF \ + -DJPEGXL_ENABLE_MANPAGES=OFF \ + -DJPEGXL_ENABLE_BENCHMARK=OFF \ + -DJPEGXL_ENABLE_EXAMPLES=OFF \ + -DJPEGXL_ENABLE_SJPEG=OFF \ + -DJPEGXL_ENABLE_JPEGLI=OFF \ + -DJPEGXL_ENABLE_OPENEXR=OFF \ + -DJPEGXL_ENABLE_TCMALLOC=OFF \ + -DJPEGXL_BUNDLE_LIBPNG=OFF \ + -DJPEGXL_ENABLE_SKCMS=ON + + - name: Configure libjxl (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + cmake \ + -S libjxl-src \ + -B libjxl-build \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="${PWD}/libjxl-install" \ + -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded \ + -DBUILD_TESTING=OFF \ + -DBUILD_SHARED_LIBS=OFF \ + -DJPEGXL_ENABLE_TOOLS=OFF \ + -DJPEGXL_ENABLE_DOXYGEN=OFF \ + -DJPEGXL_ENABLE_MANPAGES=OFF \ + -DJPEGXL_ENABLE_BENCHMARK=OFF \ + -DJPEGXL_ENABLE_EXAMPLES=OFF \ + -DJPEGXL_ENABLE_SJPEG=OFF \ + -DJPEGXL_ENABLE_JPEGLI=OFF \ + -DJPEGXL_ENABLE_OPENEXR=OFF \ + -DJPEGXL_ENABLE_TCMALLOC=OFF \ + -DJPEGXL_BUNDLE_LIBPNG=OFF \ + -DJPEGXL_ENABLE_SKCMS=ON + + - name: Build and install libjxl + shell: bash + run: | + cmake --build libjxl-build --config Release --parallel + cmake --install libjxl-build --config Release + + # ── Generate bindings.rs via bindgen ─────────────────────────────── + - name: Set LIBCLANG_PATH (macOS) + if: runner.os == 'macOS' + shell: bash + run: echo "LIBCLANG_PATH=$(brew --prefix llvm)/lib" >> $GITHUB_ENV + + - name: Generate bindings.rs + shell: bash + run: | + cargo install bindgen-cli --locked --quiet 2>/dev/null || true + + SRC_INCLUDE="libjxl-src/lib/include" + INSTALL_INCLUDE="libjxl-install/include" + + bindgen "${SRC_INCLUDE}/jxl/encode.h" \ + --header "${SRC_INCLUDE}/jxl/decode.h" \ + --header "${SRC_INCLUDE}/jxl/types.h" \ + --header "${SRC_INCLUDE}/jxl/codestream_header.h" \ + --header "${SRC_INCLUDE}/jxl/color_encoding.h" \ + --allowlist-function "JxlEncoderCreate" \ + --allowlist-function "JxlEncoderDestroy" \ + --allowlist-function "JxlEncoderReset" \ + --allowlist-function "JxlEncoderSetBasicInfo" \ + --allowlist-function "JxlEncoderSetColorEncoding" \ + --allowlist-function "JxlEncoderFrameSettingsCreate" \ + --allowlist-function "JxlEncoderSetFrameDistance" \ + --allowlist-function "JxlEncoderSetFrameLossless" \ + --allowlist-function "JxlEncoderFrameSettingsSetOption" \ + --allowlist-function "JxlEncoderAddImageFrame" \ + --allowlist-function "JxlEncoderCloseInput" \ + --allowlist-function "JxlEncoderProcessOutput" \ + --allowlist-function "JxlEncoderDistanceFromQuality" \ + --allowlist-function "JxlColorEncodingSetToSRGB" \ + --allowlist-function "JxlDecoderCreate" \ + --allowlist-function "JxlDecoderDestroy" \ + --allowlist-function "JxlDecoderReset" \ + --allowlist-function "JxlDecoderSubscribeEvents" \ + --allowlist-function "JxlDecoderSetInput" \ + --allowlist-function "JxlDecoderCloseInput" \ + --allowlist-function "JxlDecoderProcessInput" \ + --allowlist-function "JxlDecoderGetBasicInfo" \ + --allowlist-function "JxlDecoderImageOutBufferSize" \ + --allowlist-function "JxlDecoderSetImageOutBuffer" \ + --allowlist-function "JxlDecoderReleaseInput" \ + --allowlist-type "JxlEncoderStatus" \ + --allowlist-type "JxlEncoderFrameSettingId" \ + --allowlist-type "JxlEncoder" \ + --allowlist-type "JxlEncoderFrameSettings" \ + --allowlist-type "JxlDecoder" \ + --allowlist-type "JxlDecoderStatus" \ + --allowlist-type "JxlBasicInfo" \ + --allowlist-type "JxlPixelFormat" \ + --allowlist-type "JxlDataType" \ + --allowlist-type "JxlEndianness" \ + --allowlist-type "JxlColorEncoding" \ + -- \ + -I"${SRC_INCLUDE}" \ + -I"${INSTALL_INCLUDE}" \ + --target="${{ matrix.target }}" \ + > libjxl-install/bindings.rs + + # ── Package archive ──────────────────────────────────────────────── + - name: Package archive (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + PLATFORM="${{ matrix.platform }}" + STAGE="libjxl-prebuilt-${PLATFORM}" + mkdir -p "${STAGE}/lib" "${STAGE}/include/jxl" + + # Collect static libs from lib/ or lib64/ + for dir in libjxl-install/lib libjxl-install/lib64; do + [ -d "$dir" ] || continue + for lib in libjxl.a libjxl_cms.a libhwy.a libbrotlienc.a libbrotlidec.a libbrotlicommon.a; do + [ -f "${dir}/${lib}" ] && cp "${dir}/${lib}" "${STAGE}/lib/" + done + done + + cp libjxl-install/bindings.rs "${STAGE}/bindings.rs" + cp libjxl-install/include/jxl/*.h "${STAGE}/include/jxl/" + + # Verify all required libs + MISSING=0 + for lib in libjxl.a libjxl_cms.a libhwy.a libbrotlienc.a libbrotlidec.a libbrotlicommon.a; do + if [ ! -f "${STAGE}/lib/${lib}" ]; then + echo "::error::Missing ${lib}" + MISSING=1 + fi + done + [ "$MISSING" -eq 0 ] || { ls -R "${STAGE}/lib/"; exit 1; } + + tar -czf "libjxl-prebuilt-${PLATFORM}.tar.gz" "${STAGE}" + + - name: Package archive (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + PLATFORM="${{ matrix.platform }}" + STAGE="libjxl-prebuilt-${PLATFORM}" + mkdir -p "${STAGE}/lib" "${STAGE}/include/jxl" + + for lib in jxl.lib jxl_cms.lib hwy.lib brotlienc.lib brotlidec.lib brotlicommon.lib; do + cp "libjxl-install/lib/${lib}" "${STAGE}/lib/" + done + + cp libjxl-install/bindings.rs "${STAGE}/bindings.rs" + cp libjxl-install/include/jxl/*.h "${STAGE}/include/jxl/" + + # Verify all required libs + MISSING=0 + for lib in jxl.lib jxl_cms.lib hwy.lib brotlienc.lib brotlidec.lib brotlicommon.lib; do + if [ ! -f "${STAGE}/lib/${lib}" ]; then + echo "::error::Missing ${lib}" + MISSING=1 + fi + done + [ "$MISSING" -eq 0 ] || { ls -R "${STAGE}/lib/"; exit 1; } + + tar -czf "libjxl-prebuilt-${PLATFORM}.tar.gz" "${STAGE}" + + - name: Upload archive + uses: actions/upload-artifact@v4 + with: + name: libjxl-prebuilt-${{ matrix.platform }} + path: libjxl-prebuilt-${{ matrix.platform }}.tar.gz + retention-days: 7 + + # ── Create GitHub Release ──────────────────────────────────────────────── + release: + name: Create GitHub Release + needs: build + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + + - name: Download all archives + uses: actions/download-artifact@v4 + with: + pattern: libjxl-prebuilt-* + path: dist/ + merge-multiple: true + + - name: List archives + run: ls -lh dist/ + + - name: Create Release + run: | + # Delete existing release if re-running + gh release delete "$RELEASE_TAG" --yes 2>/dev/null || true + git push origin --delete "$RELEASE_TAG" 2>/dev/null || true + + gh release create "$RELEASE_TAG" \ + --title "libjxl prebuilt v${{ inputs.crate_version }}" \ + --notes "Prebuilt static libraries for slimg-libjxl-sys v${{ inputs.crate_version }} (libjxl ${{ inputs.libjxl_tag }})" \ + dist/*.tar.gz diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 17a00a3..1e3e403 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,15 +36,19 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y nasm meson ninja-build cmake + run: sudo apt-get update && sudo apt-get install -y nasm meson ninja-build - - name: Checkout submodules + - name: Checkout submodules (for slimg-core dav1d build) run: git submodule update --init --recursive - name: Publish slimg-libjxl-sys env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - run: cargo publish -p slimg-libjxl-sys || echo "Already published, skipping" + run: | + # --no-default-features: disable vendored so cmake/bindgen are not needed + # --no-verify: skip sandbox build (prebuilt archive not available during packaging) + cargo publish -p slimg-libjxl-sys --no-default-features --no-verify \ + || echo "Already published, skipping" - name: Wait for crates.io index (slimg-libjxl-sys) run: sleep 30 diff --git a/Cargo.lock b/Cargo.lock index a260a45..87ced8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1878,7 +1878,6 @@ name = "slimg-libjxl-sys" version = "0.1.0" dependencies = [ "bindgen", - "cc", "cmake", ] diff --git a/crates/libjxl-sys/Cargo.toml b/crates/libjxl-sys/Cargo.toml index cfc36ac..dc162de 100644 --- a/crates/libjxl-sys/Cargo.toml +++ b/crates/libjxl-sys/Cargo.toml @@ -3,12 +3,24 @@ name = "slimg-libjxl-sys" version = "0.1.0" edition = "2024" license = "MIT" -description = "Minimal FFI bindings to libjxl encoder/decoder (vendored, BSD-3-Clause)" +description = "Minimal FFI bindings to libjxl encoder/decoder" repository = "https://github.com/clroot/slimg" homepage = "https://github.com/clroot/slimg" links = "jxl" +include = [ + "build.rs", + "src/**/*", + "Cargo.toml", + "LICENSE*", +] + +[features] +default = ["vendored"] +vendored = ["dep:cmake", "dep:bindgen"] [build-dependencies] -cmake = "0.1" -cc = "1" -bindgen = "0.71" +cmake = { version = "0.1", optional = true } +bindgen = { version = "0.71", optional = true } + +[package.metadata.docs.rs] +no-default-features = true diff --git a/crates/libjxl-sys/build.rs b/crates/libjxl-sys/build.rs index e92f58f..ca33c10 100644 --- a/crates/libjxl-sys/build.rs +++ b/crates/libjxl-sys/build.rs @@ -1,7 +1,46 @@ use std::env; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const GITHUB_REPO: &str = "clroot/slimg"; fn main() { + println!("cargo:rerun-if-env-changed=LIBJXL_SYS_DIR"); + println!("cargo:rerun-if-env-changed=DOCS_RS"); + println!("cargo:rerun-if-changed=build.rs"); + + // docs.rs: no native libs available, emit an empty stub. + if env::var("DOCS_RS").is_ok() { + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + std::fs::write(out_dir.join("bindings.rs"), "// docs.rs stub\n").unwrap(); + return; + } + + // User-provided prebuilt directory — bypass everything. + if let Ok(dir) = env::var("LIBJXL_SYS_DIR") { + let prebuilt = PathBuf::from(&dir); + link_prebuilt(&prebuilt); + copy_bindings(&prebuilt.join("bindings.rs")); + return; + } + + // Vendored path: build from source if the submodule is checked out. + #[cfg(feature = "vendored")] + if Path::new("libjxl/CMakeLists.txt").exists() { + build_vendored(); + return; + } + + // Prebuilt download path (crates.io consumers, or vendored without source). + let prebuilt = download_prebuilt(); + link_prebuilt(&prebuilt); + copy_bindings(&prebuilt.join("bindings.rs")); +} + +// ── Vendored (source) build ───────────────────────────────────────────────── + +#[cfg(feature = "vendored")] +fn build_vendored() { let dst = cmake::Config::new("libjxl") .define("BUILD_TESTING", "OFF") .define("BUILD_SHARED_LIBS", "OFF") @@ -23,47 +62,31 @@ fn main() { println!("cargo:rustc-link-search=native={}", lib_dir.display()); println!("cargo:rustc-link-search=native={}", lib64_dir.display()); - // skcms transform files not included in libjxl's skcms.cmake - let skcms_src = PathBuf::from("libjxl/third_party/skcms/src"); - let skcms_inc = PathBuf::from("libjxl/third_party/skcms/src"); - cc::Build::new() - .cpp(true) - .file(skcms_src.join("skcms_TransformBaseline.cc")) - .include(&skcms_inc) - .flag_if_supported("-Wno-psabi") - .compile("skcms_transform"); - - // libjxl core (encoder + decoder are bundled into libjxl.a) - println!("cargo:rustc-link-lib=static=jxl"); - println!("cargo:rustc-link-lib=static=jxl_cms"); - - // libjxl vendored dependencies - println!("cargo:rustc-link-lib=static=hwy"); - println!("cargo:rustc-link-lib=static=brotlienc"); - println!("cargo:rustc-link-lib=static=brotlidec"); - println!("cargo:rustc-link-lib=static=brotlicommon"); - - // C++ standard library - let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); - match target_os.as_str() { - "macos" | "ios" => println!("cargo:rustc-link-lib=c++"), - "windows" => {} // MSVC links C++ runtime automatically - _ => println!("cargo:rustc-link-lib=stdc++"), - } + emit_link_libs(); // bindgen let include_dir = dst.join("include"); let src_include = PathBuf::from("libjxl/lib/include"); - let target = env::var("TARGET").unwrap(); + let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()); + run_bindgen(&src_include, &include_dir, &out_path.join("bindings.rs")); +} - let bindings = bindgen::Builder::default() +#[cfg(feature = "vendored")] +fn run_bindgen(src_include: &Path, install_include: &Path, out_file: &Path) { + let target = env::var("TARGET").unwrap(); + bindgen::Builder::default() .header(src_include.join("jxl/encode.h").to_str().unwrap()) .header(src_include.join("jxl/decode.h").to_str().unwrap()) .header(src_include.join("jxl/types.h").to_str().unwrap()) - .header(src_include.join("jxl/codestream_header.h").to_str().unwrap()) + .header( + src_include + .join("jxl/codestream_header.h") + .to_str() + .unwrap(), + ) .header(src_include.join("jxl/color_encoding.h").to_str().unwrap()) .clang_arg(format!("-I{}", src_include.display())) - .clang_arg(format!("-I{}", include_dir.display())) + .clang_arg(format!("-I{}", install_include.display())) .clang_arg(format!("--target={target}")) // Encoder functions .allowlist_function("JxlEncoderCreate") @@ -107,10 +130,127 @@ fn main() { .allowlist_type("JxlEndianness") .allowlist_type("JxlColorEncoding") .generate() - .expect("failed to generate libjxl bindings"); - - let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()); - bindings - .write_to_file(out_path.join("bindings.rs")) + .expect("failed to generate libjxl bindings") + .write_to_file(out_file) .expect("failed to write bindings"); } + +// ── Prebuilt download ─────────────────────────────────────────────────────── + +fn download_prebuilt() -> PathBuf { + let version = env!("CARGO_PKG_VERSION"); + let platform = detect_platform(); + let tag = format!("libjxl-prebuilt-v{version}"); + let archive_name = format!("libjxl-prebuilt-{platform}.tar.gz"); + let url = format!("https://github.com/{GITHUB_REPO}/releases/download/{tag}/{archive_name}"); + + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + let archive_path = out_dir.join(&archive_name); + let extract_dir = out_dir.join(format!("libjxl-prebuilt-{platform}")); + let sentinel = out_dir.join(format!(".libjxl-prebuilt-{tag}-{platform}")); + + // Already downloaded and extracted in a previous build run. + if sentinel.exists() && extract_dir.exists() { + return extract_dir; + } + + // Download via curl. + eprintln!("slimg-libjxl-sys: downloading prebuilt from {url}"); + let status = Command::new("curl") + .args([ + "--proto", + "=https", + "--tlsv1.2", + "-L", + "--fail", + "--silent", + "--show-error", + "-o", + ]) + .arg(&archive_path) + .arg(&url) + .status() + .unwrap_or_else(|e| { + panic!( + "slimg-libjxl-sys: failed to run curl: {e}\n\ + Hint: install curl, or set LIBJXL_SYS_DIR, or enable the vendored feature" + ) + }); + + assert!( + status.success(), + "slimg-libjxl-sys: failed to download prebuilt (HTTP error).\n\ + URL: {url}\n\ + Hint: check network connectivity, or set LIBJXL_SYS_DIR, or enable the vendored feature" + ); + + // Extract via tar. + let status = Command::new("tar") + .args(["-xzf"]) + .arg(&archive_path) + .arg("-C") + .arg(&out_dir) + .status() + .expect("slimg-libjxl-sys: failed to run tar"); + + assert!(status.success(), "slimg-libjxl-sys: tar extraction failed"); + + // Write sentinel so we skip re-download on incremental builds. + std::fs::write(&sentinel, &tag).unwrap(); + + extract_dir +} + +fn detect_platform() -> &'static str { + let os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); + match (os.as_str(), arch.as_str()) { + ("linux", "x86_64") => "linux-x86_64", + ("linux", "aarch64") => "linux-aarch64", + ("macos", "x86_64") => "macos-x86_64", + ("macos", "aarch64") => "macos-aarch64", + ("windows", "x86_64") => "windows-x86_64", + _ => panic!( + "slimg-libjxl-sys: unsupported platform {os}-{arch}.\n\ + Hint: build with the vendored feature to compile from source." + ), + } +} + +// ── Shared helpers ────────────────────────────────────────────────────────── + +fn link_prebuilt(prebuilt_dir: &Path) { + let lib_dir = prebuilt_dir.join("lib"); + println!("cargo:rustc-link-search=native={}", lib_dir.display()); + emit_link_libs(); +} + +fn emit_link_libs() { + // libjxl core + println!("cargo:rustc-link-lib=static=jxl"); + println!("cargo:rustc-link-lib=static=jxl_cms"); + + // libjxl vendored dependencies + println!("cargo:rustc-link-lib=static=hwy"); + println!("cargo:rustc-link-lib=static=brotlienc"); + println!("cargo:rustc-link-lib=static=brotlidec"); + println!("cargo:rustc-link-lib=static=brotlicommon"); + + // C++ standard library + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + match target_os.as_str() { + "macos" | "ios" => println!("cargo:rustc-link-lib=c++"), + "windows" => {} // MSVC links C++ runtime automatically + _ => println!("cargo:rustc-link-lib=stdc++"), + } +} + +fn copy_bindings(src: &Path) { + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + std::fs::copy(src, out_dir.join("bindings.rs")).unwrap_or_else(|e| { + panic!( + "slimg-libjxl-sys: failed to copy bindings from {}: {e}", + src.display() + ) + }); +}