Skip to content

feat(jxl): expose modular-mode control on the encoder - #347

Merged
justin13888 merged 4 commits into
masterfrom
feat/339-jxl-modular-mode
Aug 14, 2026
Merged

feat(jxl): expose modular-mode control on the encoder#347
justin13888 merged 4 commits into
masterfrom
feat/339-jxl-modular-mode

Conversation

@justin13888

Copy link
Copy Markdown
Collaborator

Summary

gamut-jxl reached libjxl with exactly one JxlEncoderFrameSettingIdEFFORT — so the public
encoder offered effort, distance/lossless, container, colour spec, orientation, bit depth and
metadata, and no coding-tool control at all.

This adds modular-mode control:

  • gamut-jxl-sys declares JxlEncoderFrameSettingId::MODULAR (value 11), transcribed from the
    pinned libjxl v0.12.0 jxl/encode.h. JxlEncoderFrameSettingsSetOption already takes an i64,
    so no new extern declaration and no tests/version.rs symbol-table change.
  • gamut-jxl gains ModularMode (Auto / VarDct / Modular, mapping libjxl's -1/0/1)
    and JxlEncoder::with_modular, shaped like the existing Effort / Orientation knobs.
  • gamut-cli gains --jxl-modular auto|vardct|modular on gamut convert.

The motivation is parity with the modular-on variant that
image-evaluation already sweeps, so gamut-jxl
can be compared against libjxl-encode on equal terms — not rate-distortion (forcing modular
generally loses above distance ~0.5, and libjxl already picks modular for lossless). Opening this
seam also makes the rest of the frame-setting surface additive rather than an FFI change each time.

Three decisions worth reviewing

Auto is inert, not "auto = -1". When the mode is Auto the option is not sent at all, so a
default encoder's bytes are byte-identical to before this PR. auto_modular_is_byte_identical_to_the_untouched_default
pins that.

Lossless + forced VarDct is a typed refusal. libjxl's QueueFrame calls cparams.SetLossless()
(lib/jxl/encode.cc:471-473), which sets modular_mode = true unconditionally
(lib/jxl/enc_params.h:144-150) — so the frame setting is overridden and the request cannot be
honoured. Rather than silently emit a Modular stream, this returns
InvalidInput("JXL: lossless encoding cannot force VarDCT mode"). The check lives in
dispatch_encode, ahead of both the backend registry and the built-in tail, so every target and
every backend sees one refusal. Forcing Modular on lossless is allowed — it agrees with libjxl.

The codec-abi adapter declines a pinned mode rather than dropping it. EncodeConfig carries only
a codec id and a 0..=100 quality. Effort is deliberately not conveyed (a free speed/density choice
with no effect on decoded pixels), but the coding tool reshapes the codestream, so is_conveyable
now declines it exactly as it already declines non-default colour and orientation. The knob does
reach typed Rust backends, through a new JxlEncodeRequest::modular() (its constructor is
pub(crate), so this is additive).

recompress_jpeg (jbrd) re-packs the JPEG's own DCT coefficients and has no coding-tool choice to
make, so it ignores the setting — as it already does the other inapplicable knobs. Documented rather
than rejected, matching that path's existing behaviour.

Validation

  • mise run testpass (2505 passed, 0 failed)
  • mise run lintpass (clippy, -D warnings, --all-targets --all-features)
  • mise run fmt-checkpass
  • mise run check-commitspass (no errors in 4 commits)
  • mise run coverage / mise run mutants — running locally; see the comment below for results.
  • Manual CLI end-to-end on a 96x96 RGB PNG:
    • gamut convert in.png auto.jxl --jxl-distance 1.0 → 2228 bytes
    • gamut convert in.png mod.jxl --jxl-distance 1.0 --jxl-modular modular → 1608 bytes, bytes differ
    • gamut convert in.png bad.jxl --jxl-modular vardcterror: invalid input: JXL: lossless encoding cannot force VarDCT mode [origin: gamut-jxl]
    • gamut convert mod.jxl back.png → decodes cleanly through jxl-rs

New tests: ModularMode value round-trip / range / default; with_modular chaining, equality
participation and the lossless+VarDCT refusal; modular_setting_changes_the_stream (forced Modular
differs from forced VarDCT and from libjxl's own choice, all three decodable by both jxl-rs and the
libjxl oracle); auto_modular_is_byte_identical_to_the_untouched_default;
forced_modular_lossy_stays_within_psnr; and, on the seam, that a pushed backend sees req.modular()
while a codec-abi backend declines a pinned mode and falls through to the built-in tail.

Since no decoder in the workspace reports whether a stream is VarDCT or Modular (JxlInfo carries no
such flag, and the oracle helper returns pixels only), the plumbing is pinned on stream bytes
differing
plus decodability — the same shape as the existing effort_setting_changes_the_stream.

Risks and rollout

Additive and semver-minor. Auto is the default and provably byte-identical to the previous output,
so no existing stream changes. JxlEncodeRequest gained a field, but its constructor is pub(crate)
— external JxlCodestreamEncoder implementors are unaffected.

Out of scope

The sibling modular tuning enumerants (MODULAR_COLOR_SPACE 25, MODULAR_GROUP_SIZE 26,
MODULAR_PREDICTOR 27, MODULAR_NB_PREV_CHANNELS 29) and the float-valued
MODULAR_MA_TREE_LEARNING_PERCENT (28, which would additionally need
JxlEncoderFrameSettingsSetFloatOption declared plus a tests/version.rs row). The issue asked for
the minimal enumerant addition; these are now additive behind the seam this opens, and STATUS.md
records them.

Pre-existing issues noticed, deliberately not fixed here

Spotted while reading the crate; unrelated to this change, so left for a separate PR:

  • crates/gamut-jxl/src/lib.rs:98 — the last doc line ends mid-sentence with an unterminated
    backtick: //! The crate is `#![deny(unsafe_code)]. The crate has no
    #![deny(unsafe_code)] / #![forbid(unsafe_code)] attribute at all (unlike every other workspace
    crate), which makes ffi.rs:9's #![allow(unsafe_code)] a no-op.
  • crates/gamut-jxl/README.md:129 — also ends mid-sentence ("On the decode side,").

Issue

Closes #339

`JxlEncoderFrameSettingId` declared only `EFFORT`, so `EFFORT` was the sole
per-frame option any caller could reach. Add `MODULAR` (value 11), transcribed
from the pinned libjxl v0.12.0 `jxl/encode.h`, so `gamut-jxl` can select the
VarDCT/Modular coding tool.

`JxlEncoderFrameSettingsSetOption` already takes an `i64`, so no new extern
declaration is needed and the `tests/version.rs` symbol table (which covers
extern fns only) is unchanged.

Refs #339
Only `EFFORT` ever reached libjxl, so the public encoder offered no
coding-tool control at all. Add `ModularMode` (`Auto`/`VarDct`/`Modular`,
mapping libjxl's -1/0/1) and `JxlEncoder::with_modular`.

`Auto` is the default and leaves the frame setting **unsent**, so a default
encoder's bytes are byte-identical to before.

Lossless + forced `VarDct` is rejected with a typed `InvalidInput` rather than
silently ignored: libjxl's `QueueFrame` calls `cparams.SetLossless()`, which
sets `modular_mode = true` unconditionally, so the request could not be
honoured. The check runs in `dispatch_encode`, ahead of both the backend
registry and the built-in tail, so every target and every backend sees one
refusal. Forcing `Modular` on lossless is allowed — it agrees with libjxl.

The knob is codestream-level, so it reaches pushed backends through
`JxlEncodeRequest`. `EncodeConfig` has no field for it, so the codec-abi
adapter declines a pinned mode (as it already does for colour and orientation)
instead of dropping it — unlike effort, the coding tool reshapes the stream.
`recompress_jpeg` re-packs the JPEG's own coefficients and has no coding-tool
choice to make, so it ignores the setting, as it already does the other
inapplicable knobs.

Refs #339
Exposes `JxlEncoder::with_modular` alongside `--jxl-effort` /
`--jxl-distance` / `--jxl-container`, so the coding tool can be pinned from
the binary — the parity the image-evaluation sweep needs to compare gamut-jxl
against libjxl-encode's `modular-on` variant on equal terms.

`auto` is the default; `vardct` on the lossless default surfaces the codec's
typed refusal through `CliError::Codec`.

Refs #339
Adds the implemented-surface bullet (including the lossless+VarDCT refusal,
the codec-abi decline and the jbrd no-op), the coding-tool test-regime bullet,
and `with_modular` in the README encode example.

Narrows the deferred "progressive encode control" entry into a single
frame-settings entry: the modular *tuning* knobs and the remaining coding-tool
toggles are now additive behind the seam this opened, rather than an FFI change
each time.

Refs #339
@justin13888

Copy link
Copy Markdown
Collaborator Author

Local gate results (the two the PR body left pending):

  • mise run coveragepass, 97.23% lines workspace-wide (gate is 80%). For the touched files: gamut-jxl/src/config.rs 100.00%, encoder.rs 98.17%, backend.rs 97.83%, abi.rs 98.21%.
  • mise run mutants-diffpass, 32 mutants tested: 31 caught, 1 unviable, 0 missed.

Full local set, all green: test (2505 passed / 0 failed), lint, fmt-check, check-commits, coverage, mutants-diff.

@justin13888
justin13888 merged commit 9b317cb into master Aug 14, 2026
5 checks passed
@justin13888
justin13888 deleted the feat/339-jxl-modular-mode branch August 14, 2026 21:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gamut-jxl: only EFFORT reaches libjxl; expose modular-mode control

1 participant