Skip to content

v6.7.0 - JXL - #130

Merged
ajslater merged 852 commits into
mainfrom
develop
Jul 29, 2026
Merged

v6.7.0 - JXL#130
ajslater merged 852 commits into
mainfrom
develop

Conversation

@ajslater

Copy link
Copy Markdown
Owner

Features

  • JPEG XL support, read and write, using the internal
    pillow-jxl-plugin module.
  • JXL is now optimized by default. .jxl files that previous versions ignored
    are re-encoded on a plain picopt -r run. Only lossless JXL is touched; lossy
    (XYB encoded) JXL is left alone, as lossy WebP is.
  • Lossless images convert to JXL with -c JXL, which now leads the preference
    order for still images ahead of WEBP and PNG.
  • New --convert-jpeg-to-jxl converts JPEG to JXL losslessly and reversibly:
    the JXL stores the original JPEG bitstream and can restore it byte for byte.
    Files carrying that reconstruction data are never re-encoded, so the original
    JPEG stays recoverable.
  • New --convert-webp-to-jxl converts lossless WebP to JXL. Off by default
    because JXL support is still much thinner than WebP's.
  • Both flags require -c JXL as well. JPEG and WEBP are formats picopt already
    processes by default, so unlike BMP or TIFF they cannot be gated by naming
    them with -x. A plain -c JXL still converts only GIF and PNG.

ajslater added 30 commits June 17, 2025 23:12
commit 0c2b3790c9611f35381d7159c6dea2295e8eb51e
Author: AJ Slater <aj@slater.net>
Date:   Thu Jun 26 15:01:52 2025 -0700

    internal mozjpeg works, remove external jpeg methods

commit 2a79892c44daf358901c5b1afdac7297e73cbe0e
Merge: 3a01d55 a793e0b
Author: AJ Slater <aj@slater.net>
Date:   Thu Jun 26 14:22:38 2025 -0700

    Merge branch 'develop' into mozjpeg-lossless-optimization

commit 3a01d55856896d2e4fe94d56cdb60dfd00ffe640
Author: AJ Slater <aj@slater.net>
Date:   Wed Jun 11 15:14:40 2025 -0700

    use internal mozjpeg. currently broken on mac
ajslater and others added 29 commits July 4, 2026 18:29
- plugins.pick_route_handler is the one routing decision: the factory
  uses it at runtime and config/handlers uses it to build the startup
  formats summary, replacing an 8-parameter re-derivation whose logic
  had already drifted from the factory's (it ignored empty-pipeline
  availability). The log can no longer disagree with the walk.
- PathInfo's class docstring documents the mutable-identity contract:
  instances cross the process boundary and are mutated by the scheduler
  after hydration, with cache invalidation owned by rename()/set_data().
- computed.handler_stages is structurally typed dict[type, tuple];
  the concrete Handler/Tool annotation is impossible because
  plugins.base imports settings and basedpyright's reportImportCycles
  counts even TYPE_CHECKING edges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgZ7kxNgvEWuSEKeTJMJcH
probe() caches its ToolStatus per instance and concrete probes move to
_probe(). Tools are module singletons, so each external tool spawns its
--version subprocess once per process — a prerequisite for per-directory
config files, whose confuse rebuilds rerun the pipeline probe loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgZ7kxNgvEWuSEKeTJMJcH
Ported from nudebomb's implementation (same confuse + treestamps stack).

A .picopt.yaml placed in a target directory applies to that directory
and everything beneath it. Files are discovered from each processed
file's directory up to the CLI target root (never above it); deeper
directories win over shallower ones, and env vars and CLI options still
win over every directory file — implemented by rebuilding a fresh
confuse Configuration per unique directory chain through the extracted
_build_config layering core. DirConfig caches resolutions per directory
with an identity fast path when no config applies, and isolates
malformed files (logged once, recorded in stats, fall back to run-wide
settings). Run-mode keys (dry_run, list_only, timestamps) are pinned to
run-level values: a per-directory dry run with run-level timestamps
would stamp files that were never optimized.

Per-directory settings reach everything: guards (ignore/symlinks/
recurse) via per-directory skippers and the pre-count mirror, handlers
via the factory's settings parameter (formats/convert_to/bigger/
keep_metadata/... travel into workers), archive members by inheriting
their container's config (nesting inherits automatically), and repacks
run under the handler's own config. .picopt.yaml files are skipped as
optimization targets by basename.

With -t, a sha256 fingerprint of every .picopt.yaml under the targets
joins the treestamps program-config check, so editing, adding, or
removing a directory config re-processes its tree (over-invalidation,
never a wrong-skip).

New write flags, all validate-before-persist with round-trip YAML
merges that preserve comments and unrelated keys, written owner-only:
-w/--write-config (user config), -W/--write-dir-config (a .picopt.yaml
in each target directory), and --write-config-file PATH. Run-mode
flags and target paths are never persisted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgZ7kxNgvEWuSEKeTJMJcH
- --fail-fast cancels every live top-level container and clears both
  scheduler queues on the first repack error; --fail-fast-container
  escalates an inner failure to its subtree root while leaving sibling
  top-level containers untouched; a leaf result arriving after its
  parent's cancellation is dropped without touching the parent's
  contents.
- Optimization converges: a second pass may only shrink files (the
  multi-tool png pipeline legitimately shaves another byte when one
  tool's output enables another) and every file reaches its fixed
  point by the third pass.
- Lossless paths are pixel-exact: png optimization, png-to-webp, and
  gif-to-png conversions decode to identical RGBA pixels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgZ7kxNgvEWuSEKeTJMJcH
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgZ7kxNgvEWuSEKeTJMJcH
Fill in five user-facing fixes that landed since v6.5.2 but were absent
from the v6.6.0 entry: archive conversions recompress members by content,
--preserve survives non-root chown failures, temp files and animated-WebP
frame dirs are cleaned up on failure/cancellation, timezone-aware --after
is honored, and -I without -i no longer aborts at startup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…yaml

The verbose "Optimizing formats" banner is a run-wide summary, but it was
emitted from set_format_handler_map(), which _build_config() re-runs for
every per-directory .picopt.yaml resolution during the walk. With -W (or
any planted directory config in the tree root), every directory finds a
config in its ancestry, skips the "reuse run-wide settings" fast path, and
rebuilds the config — reprinting the banner once per directory.

Gate the banner behind a print_summary flag so only the top-level
get_config() build emits it. Move the summary computation into
_log_formats_summary() so the per-directory rebuilds skip it entirely.

Add a regression test asserting the banner prints exactly once under -W.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
img2webp rejects any frame duration <= 0 ("Invalid negative duration"),
aborting the whole animated-WebP repack. Some sources (e.g. scraped
animated WebPs) carry 0ms frame durations, so clamp each duration up to
img2webp's 1ms minimum in Img2WebPAnimatedLossless.img2webp_args.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Use a real PicoptSettings (not SimpleNamespace) in the img2webp
  duration test so config assignment type-checks.
- Mark the module-level pytest autouse fixtures with
  `# pyright: ignore[reportUnusedFunction]`; they are used by pytest,
  not referenced by name.
- Disable reportImplicitStringConcatenation: ruff format canonicalizes
  adjacent string literals to implicit concatenation, so the check can
  never be satisfied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
radon cc flagged four functions at rank C. Extract behavior-preserving
helpers to bring each to rank A/B:

- WalkSkipper._is_skippable (16): split into _skip_reason and the
  disk-only _skip_reason_fs.
- Walk._count (12): extract the recursion-stop predicate _count_stops_here.
- pick_route_handler (12): fold the convert gate into _pick_convert_handler
  and extract _can_pack_repack for the repack check.
- Handler._cleanup_after_optimize (11): extract the _should_replace predicate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per-directory .picopt.yaml resolution rebuilds the confuse config for
every directory in the tree, repeating the timestamps, ignore, after,
and memory-limit log lines each time. Gate them on the existing
print_summary flag, as set_format_handler_map already does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The treestamps program config was recorded from the run-level CLI
config, so identical options expressed via a tree root's .picopt.yaml
instead of flags always mismatched and every stamp was silently
discarded, re-optimizing whole trees.

Each tree now records its own effective config: the new Grove builds
one Treestamps per top path from DirConfig.get_tree_settings, which
resolves the root's .picopt.yaml beneath CLI and env. timestamps: true
at a tree root now enables stamping without -t (dry run and list still
disable it, and subdirectory configs still can't toggle it mid-walk).

The directory-config fingerprint is now per tree and value-based: it
hashes the parsed picopt section of each config strictly below the
root, so comment and formatting edits, -W rewrites, running with a
different set of top paths, and file-vs-directory targets of the same
tree no longer invalidate stamps — only real option changes do. The
root's own config is excluded because its options are recorded as
values.

Stamps discarded on a config mismatch now warn with the differing keys
(treestamps 4.1.1); stdlib logging is intercepted into loguru so the
warning renders through the shared console, and log messages escape
Rich markup so bracketed names like "Title [en]" display intact.

Migration: existing stamp files mismatch once after this upgrade (now
loudly); a single -N run preserves old stamps instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7R5s6frL7jEykysAzjttq
PIL raises a plain OSError when it recognizes an image format but the
contents are corrupt (e.g. a BMP with pixel depth 0), which escaped the
detection probe and printed a full stack trace. Detection now wraps it
in UnreadableImageError, reported as a one-line warning that is also
counted and listed in the summary; the file is skipped untouched.

The per-file exception guards now skip the stack trace for any
PicoptError — extending the contract cli.main already had: expected
conditions print their message, only unexpected exceptions dump stacks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7R5s6frL7jEykysAzjttq
- Drop the `cls: type[Any]` annotation on the RarDetector/ZipDetector
  `identify` classmethods; ty flagged the widened first parameter as an
  invalid override of `Detector.identify`.
- Replace remark's `no-duplicate-headings` with
  `no-duplicate-headings-in-section`. A changelog necessarily repeats
  `### Fixes` under each version; the in-section rule still catches real
  duplicates.

Also includes the in-progress dependency bumps and ruff rule additions that
were already in the working tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Add JPEG XL support

Read and write JPEG XL via the pillow-jxl-plugin codec, as a new
auto-discovered plugin. JXL is optimized by default and is now the
preferred still conversion target ahead of WEBP and PNG.

Only lossless JXL is re-encoded; lossy (XYB encoded) JXL is left alone,
the same policy lossy WebP already has. The codec exposes no such signal,
so picopt/pillow/jxl.py parses the codestream image header to read
xyb_encoded, and refuses files carrying JPEG reconstruction data, whose
re-encoding would silently destroy the ability to restore the source JPEG.

New --convert-jpeg-to-jxl converts JPEG to JXL by lossless bitstream
reconstruction, which is reversible byte for byte. It requires -c JXL as
well: picopt otherwise never converts from a lossy source, so -c JXL alone
must not sweep up a whole photo library. The codec only takes its
reconstruction path when PIL knows the source filename, which it does only
for path opens, so the tool opens by path and spills to a temp file for
inputs that have none. That makes it work for archive members too.

Three smaller things the codec needed:

- It registers no save_all handler, and pil_save always passes save_all,
  which raised KeyError. The single-frame handler is registered for it;
  animated JXL is unsupported by the codec regardless.
- Given no exif kwarg it reads EXIF off the opened image, which quietly
  defeated --strip-metadata. prepare_info now passes an empty exif.
- It rejects palette and other modes, so ImageHandler gained a
  prepare_image hook, symmetric with prepare_info, to substitute one.

Handlers can now declare CONFIG_ENABLED_KEY, a config flag that must be
true for them to be probed at all. Failing it leaves the handler out of
the probed handler map, which routing already reads as unavailable, so the
startup conversion banner stays accurate for free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Gate WebP to JXL conversion behind --convert-webp-to-jxl

JPEG XL support in browsers and image tooling is still thin, so converting
away from WebP, which every browser reads, should be a deliberate act
rather than a side effect of asking for -c JXL.

JPEG and WEBP are both formats picopt processes by default, which means
the usual "name the source with -x" gate cannot express a choice about
them: -x JPEG and -x WEBP are no-ops. Each therefore carries its own flag,
and a plain -c JXL once again converts only GIF and PNG, as the README has
always described. The previous commit had quietly made WEBP a third
convert-by-default source.

The flag hangs off the handler class, and JxlLossless also serves PNG, GIF
and native JXL, which must stay ungated. So the WebP route now points at a
JxlFromWebP subclass that exists only to carry CONFIG_ENABLED_KEY.

Converting out of JXL into a more widely readable format stays ungated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* update deps

* update deps

* update deps

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@ajslater
ajslater merged commit 6988011 into main Jul 29, 2026
3 checks passed
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.

1 participant