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/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/publish.yml b/.github/workflows/publish.yml index 0bd912b..1e3e403 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -38,6 +38,21 @@ jobs: - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y nasm meson ninja-build + - 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: | + # --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 + - name: Publish slimg-core env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 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/.gitmodules b/.gitmodules new file mode 100644 index 0000000..2e09cb1 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[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..87ced8c 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,16 @@ dependencies = [ "cc", ] +[[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" @@ -1800,6 +1859,7 @@ dependencies = [ "rapid-qoi", "ravif 0.13.0", "rgb", + "slimg-libjxl-sys", "thiserror", "webp", ] @@ -1813,6 +1873,14 @@ dependencies = [ "uniffi", ] +[[package]] +name = "slimg-libjxl-sys" +version = "0.1.0" +dependencies = [ + "bindgen", + "cmake", +] + [[package]] name = "smallvec" version = "1.15.1" diff --git a/Cargo.toml b/Cargo.toml index 6b933e4..f5da36b 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-sys", "cli"] # The profile that 'dist' will build with [profile.dist] 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, } } diff --git a/crates/libjxl-sys/Cargo.toml b/crates/libjxl-sys/Cargo.toml new file mode 100644 index 0000000..dc162de --- /dev/null +++ b/crates/libjxl-sys/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "slimg-libjxl-sys" +version = "0.1.0" +edition = "2024" +license = "MIT" +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 = { 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 new file mode 100644 index 0000000..ca33c10 --- /dev/null +++ b/crates/libjxl-sys/build.rs @@ -0,0 +1,256 @@ +use std::env; +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") + .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()); + + emit_link_libs(); + + // bindgen + let include_dir = dst.join("include"); + let src_include = PathBuf::from("libjxl/lib/include"); + let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()); + run_bindgen(&src_include, &include_dir, &out_path.join("bindings.rs")); +} + +#[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/color_encoding.h").to_str().unwrap()) + .clang_arg(format!("-I{}", src_include.display())) + .clang_arg(format!("-I{}", install_include.display())) + .clang_arg(format!("--target={target}")) + // Encoder functions + .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") + // 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") + .generate() + .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() + ) + }); +} diff --git a/crates/libjxl-sys/libjxl b/crates/libjxl-sys/libjxl new file mode 160000 index 0000000..794a5dc --- /dev/null +++ b/crates/libjxl-sys/libjxl @@ -0,0 +1 @@ +Subproject commit 794a5dcf0d54f9f0b20d288a12e87afb91d20dfc diff --git a/crates/libjxl-sys/src/lib.rs b/crates/libjxl-sys/src/lib.rs new file mode 100644 index 0000000..cd503e4 --- /dev/null +++ b/crates/libjxl-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")); diff --git a/crates/slimg-core/Cargo.toml b/crates/slimg-core/Cargo.toml index ae5b23b..35e4a96 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-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" diff --git a/crates/slimg-core/src/codec/jxl.rs b/crates/slimg-core/src/codec/jxl.rs deleted file mode 100644 index 117f4e4..0000000 --- a/crates/slimg-core/src/codec/jxl.rs +++ /dev/null @@ -1,44 +0,0 @@ -use crate::error::{Error, Result}; -use crate::format::Format; - -use super::{Codec, EncodeOptions, ImageData}; - -/// JXL codec — decode-only (encoding is not supported due to license restrictions). -pub struct JxlCodec; - -impl Codec for JxlCodec { - fn format(&self) -> Format { - Format::Jxl - } - - 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())) - } - - fn encode(&self, _image: &ImageData, _options: &EncodeOptions) -> Result> { - Err(Error::EncodingNotSupported(Format::Jxl)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn encode_returns_not_supported() { - let codec = JxlCodec; - let data = vec![0u8; 4 * 2 * 2]; - let image = ImageData::new(2, 2, data); - let options = EncodeOptions { quality: 80 }; - - let result = codec.encode(&image, &options); - assert!(result.is_err(), "JXL encoding should not be supported"); - } -} 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 new file mode 100644 index 0000000..adc8dc2 --- /dev/null +++ b/crates/slimg-core/src/codec/jxl/encoder.rs @@ -0,0 +1,185 @@ +use std::ptr; + +use libjxl_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.orientation = JxlOrientation_JXL_ORIENT_IDENTITY; + 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}"))) + } +} diff --git a/crates/slimg-core/src/codec/jxl/mod.rs b/crates/slimg-core/src/codec/jxl/mod.rs new file mode 100644 index 0000000..9ef8056 --- /dev/null +++ b/crates/slimg-core/src/codec/jxl/mod.rs @@ -0,0 +1,109 @@ +mod decoder; +mod encoder; +mod types; + +use crate::error::Result; +use crate::format::Format; + +use super::{Codec, EncodeOptions, ImageData}; + +/// JXL codec backed by libjxl (BSD-3-Clause) for both encoding and decoding. +pub struct JxlCodec; + +impl Codec for JxlCodec { + fn format(&self) -> Format { + Format::Jxl + } + + fn decode(&self, data: &[u8]) -> Result { + 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> { + 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 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_lossy_produces_valid_jxl() { + let codec = JxlCodec; + let image = create_test_image(8, 8); + let options = EncodeOptions { quality: 80 }; + + 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 new file mode 100644 index 0000000..94d6f1a --- /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_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"); } } 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 = '*' 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..768a216 --- /dev/null +++ b/docs/plans/2026-02-19-jxl-encoding-design.md @@ -0,0 +1,792 @@ +# JPEG XL Encoding Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** libjxl (BSD-3-Clause)에 대한 clean-room 바인딩으로 slimg에 JXL 인코딩 지원 추가 + +**Architecture:** workspace에 `libjxl-enc-sys` crate를 추가하여 libjxl C 인코더를 vendored static 빌드 + bindgen 바인딩. `slimg-core`의 `codec/jxl/` 모듈에서 safe wrapper를 통해 Codec trait 구현. + +**Tech Stack:** libjxl (C++/CMake), cmake crate, bindgen, git submodule + +**Clean-room 원칙:** GPL-3.0인 `jpegxl-sys`/`jpegxl-rs` 코드를 절대 참조하지 않는다. libjxl 공식 C 헤더(BSD-3-Clause)만 기반으로 독자 구현. + +--- + +### Task 1: libjxl git submodule 추가 + +**Files:** +- Create: `crates/libjxl-enc-sys/` (directory) +- Create: `.gitmodules` entry + +**Step 1: 디렉토리 생성 및 submodule 추가** + +```bash +mkdir -p crates/libjxl-enc-sys +git submodule add https://github.com/libjxl/libjxl.git crates/libjxl-enc-sys/libjxl +``` + +**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 ../../.. +``` + +**Step 3: 안정 태그로 체크아웃** + +```bash +cd crates/libjxl-enc-sys/libjxl +git checkout v0.11.1 +cd ../../.. +``` + +**Step 4: 커밋** + +```bash +git add .gitmodules crates/libjxl-enc-sys/libjxl +git commit -m "chore: add libjxl v0.11.1 as git submodule" +``` + +--- + +### 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" +``` + +**Step 2: 빈 lib.rs 작성** + +```rust +// 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" +``` + +--- + +### Task 3: build.rs 구현 (CMake + bindgen) + +**Files:** +- Create: `crates/libjxl-enc-sys/build.rs` + +**Step 1: build.rs 작성** + +```rust +// 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++"), + } + + // 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"); +} +``` + +**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 +// crates/slimg-core/src/codec/jxl/types.rs +use crate::error::Result; + +/// JXL 인코딩 설정. +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, + } + } +} +``` + +```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, + }; + + 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}"))) + } +} +``` + +> **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 { + 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 = 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" +``` + +--- + +### Task 7: FFI 바인딩 업데이트 (slimg-ffi) + +**Files:** +- Modify: `crates/slimg-ffi/src/lib.rs` + +**Step 1: EncodingNotSupported 주석 정리** + +`lib.rs`의 `SlimgError::EncodingNotSupported` variant는 그대로 유지 (다른 포맷이 향후 필요할 수 있음). 변경 사항 없음. `format_can_encode`가 이미 `slimg_core::Format::can_encode()`를 호출하므로 자동 반영. + +**Step 2: 확인** + +```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 +``` + +**Step 3: 커밋 (clippy 수정 사항 있는 경우)**