Add AVIF optimization support - #475
Open
kristofferR wants to merge 21 commits into
Open
kristofferR wants to merge 21 commits into
kristofferR wants to merge 21 commits into
Conversation
Guetzli and SVGCleaner are archived upstream, and Zopfli's PNG role is covered by OxiPNG. Removes their submodules, Xcode subprojects, worker classes, preference keys, prefs/menu UI and credits entries. pngcrush is deliberately kept, as it is still actively updated upstream. Localized help text in 25 languages still mentions Zopfli; rewording it needs a translator, so it is left for a follow-up.
pornel/giflossy has been archived since its --lossy support was merged upstream. Mainline gifsicle carries it (src/gifsicle.c registers "lossy" and handles LOSSY_OPT), so the fork is no longer needed. Points the submodule at kohler/gifsicle and pins release v1.96 instead of an untagged fork commit. The ImageOptim patch is regenerated against v1.96: the old 1.89 patch no longer applied, because mainline dropped the kc_set_gamma call from do_colormap_change and added an argument to colormap_stream. The new patch strips the same features and applies with no fuzz or offsets. Renames the lossy pass from "Giflossy" to "Gifsicle lossy" in the UI, since the fork is no longer what runs.
Mainline gifsicle split the k-d tree and colour-histogram code (kd3_*, kchist_*) out of quantize.c into src/kcolor.c. quantize.c and xform.c still call into it, so without kcolor.c the target compiles but fails to link on ten undefined symbols.
The Xcode target compiles with HAVE_CONFIG_H and picks up the checked-in gifsicle/config.h, which still described the archived fork: gifsicle --version printed 1.88 for a 1.96 build, and the package metadata pointed at pornel/giflossy. The version fields now match mainline's AC_INIT([gifsicle],[1.96]), which sets no bug-report address or home page. scripts/fetch-submodule also gains a submodule sync: git keeps a submodule's original URL in .git/config once it is initialized, so an existing checkout would keep fetching giflossy and then fail to apply the 1.96 patch against the stale 1.89 tree. The sync is best effort because pngquant passes a nested path, which only the recursive update handles.
git submodule sync without --recursive only rewrites .git/config for the superproject's own submodules, so a submodule of a submodule kept fetching its old URL: the update below clones a nested submodule but never resyncs one that already exists. pngquant/src/lib is exactly that case. The path stays unquoted-safe now, and the failure remains suppressed on purpose: a caller that passes a nested path (pngquant/Makefile asks for src/lib) makes sync error out with "pathspec did not match", since that path is no submodule of this repo. Only the recursive update can fetch it, so aborting on a sync failure would break that caller.
Jpegli produces smaller JPEGs than MozJPEG at equivalent quality and is actively developed, while ImageOptim's mozjpeg-cocoa fork has not moved in years. jpegli lives in its own repository. It was developed inside libjxl but removed there in March 2026 (libjxl#4657), and libjxl v0.12.0 ships none of it, so libjpeg/src now points at google/jpegli. That repo has no release tags yet, so it is pinned to a commit. Because jpegli exposes a libjpeg-compatible API, it drops into the slot mozjpeg occupied and jpegtran/jpegoptim keep linking libjpeg unchanged. jpeg.xcodeproj no longer compiles a vendored source list; it runs build-jpegli.sh, which builds jpegli and highway per architecture, merges in the libjpeg wrapper plus jutils_compat.c (transupp.c needs utility functions jpegli does not export), and lipos the result. The CMake options are the JPEGLI_* family, the renamed equivalents of libjxl's JPEGXL_* options; every one used here was checked against the repository's CMakeLists.txt. libjpeg's Makefile now initialises the three third_party submodules the build actually needs — highway, libjpeg-turbo and lcms, the last being a hard requirement whenever JPEGLI_ENABLE_SKCMS is off. testdata and the disabled backends are left alone. It also stops using src/jpeglib.h as its stamp: that file does not exist in jpegli, so touching it dirtied the submodule and fought the generated-header cleanup. jconfigint.h and jversion.h were mozjpeg build config and are now unused.
Jpegli is libjpeg-API-compatible but is not laid out like mozjpeg, so
the two consumers need adjusting:
- jpegtran compiled src/{jpegtran,transupp,cdjpeg,rdswitch}.c, which in
jpegli live under src/third_party/libjpeg-turbo/. Repoint the four
file references and add the generated-header and libjpeg-turbo
directories to the header search path.
- jpegoptim looked for jpeglib.h in libjpeg/src/; jpegli generates it
into libjpeg/build/include/, so add that first.
jconfigint.h and jversion.h are still required even though they were
mozjpeg build configuration: libjpeg-turbo's jinclude.h includes the
former and jpegtran.c includes the latter, and jpegli configures only
libjpeg-turbo's public headers. jconfigint.h is kept checked in next to
jconfig.h, which is maintained the same way; jversion.h is generated by
build-jpegli.sh from the vendored template.
The localised help listed MozJPEG in the tool list; it now lists Jpegli.
- JpegtranWorker reported "MozJPEG" as the winning tool; it runs jpegtran linked against jpegli now, so report "Jpegli". - The Polish credits kept MozJPEG's "Mozilli i współtwórców" next to the google/jpegli link, and the Spanish help page listed Jpegli twice. - build-jpegli.sh compiles jutils_compat.c, which lives outside src/ and so was invisible to the build cache signature: editing it alone left the script reporting "already built" while shipping the stale object. - cmake output was truncated to the last 5 lines, which hides configure errors emitted earlier. Tee the full output to a log and point at it when cmake fails. - libjpeg/Makefile gated the submodule update on a stamp file whose only prerequisite was ../.gitmodules. Bumping src's pinned commit touches neither, so the recipe was skipped and the build used the old checkout. Drop the gate; the updates are no-ops when the submodules are current. - build-cache.sh read $BUILD_CACHE_HELPER out of its caller's environment; resolve it from BASH_SOURCE instead, and make a failing cd terminate the subshell rather than letting git run in the wrong directory. - jpegtran's USER_HEADER_SEARCH_PATHS was a space-joined string, which breaks if the checkout path contains a space. Use the array form the other projects use.
libjpeg/src changed repositories in this branch, but a clone that had already initialised it keeps the old submodule.libjpeg/src.url in .git/config, and `git submodule update --init` fetches from that cached URL instead of the one in .gitmodules. The update then fails to find the pinned jpegli commit and the nested update never runs. `git submodule sync` refreshes the cached URL from .gitmodules, so an existing clone picks up the new repository without a manual repair. It is recursive to match the recursive update fallback below it.
The build cache accepted a hit whenever the marker and the two static libraries were present, so a missing or incomplete build/include left jpegtran and jpegoptim failing to compile while every later build kept taking the same stale hit. Validate the generated headers too. Jpegli is configured with CMake and Ninja, neither of which comes with Xcode, so the README's requirements list them and the script checks for them up front rather than failing part-way through the build.
Xcode launched from Finder or the Dock inherits launchd's minimal PATH, which has neither /opt/homebrew/bin nor /usr/local/bin, so the documented `brew install cmake ninja` setup still failed the tool check. Add both Homebrew prefixes to PATH the way the generated Cargo build phases in oxipng and pngquant already do.
Recompresses AVIF files by decoding to PNG with avifdec and re-encoding
with avifenc, keeping the result only when it is smaller. Animated AVIF
("avis" brand) is detected and tracked, since avifenc cannot re-encode a
sequence.
libavif is pinned to release v1.4.2. aom, zlib/libpng and libjpeg are all
built in LOCAL mode, so libavif fetches versions it is known to work
with. zlib/libpng are needed because the worker round-trips through PNG;
libjpeg is unused by that path but libavif refuses to build its apps with
AVIF_JPEG=OFF.
Building the dependencies this way, rather than by hand from another
submodule's third_party tree, keeps AVIF independent of the JPEG codec
and drops roughly ninety lines of dependency plumbing.
Format detection parses the ISO-BMFF ftyp box properly, checking both the
major brand and the compatible-brands list, because plenty of AVIF files
carry a different major brand and only list "avif" as compatible.
There is deliberately no preferences checkbox yet: AvifEnabled defaults
to on, and the toggles for the new formats are better added once WebP and
JPEG XL have landed too, so the prefs layout is reworked once.
libavif's AVIF_JPEG=LOCAL builds libjpeg through ExternalProject_Add, which forwards CMAKE_C_COMPILER and CMAKE_C_FLAGS but not CMAKE_OSX_ARCHITECTURES, so the sub-build always targets the host architecture and the second slice fails to link on undefined jpeg_* symbols. Build libjpeg-turbo per architecture from jpegli's vendored copy instead and pass it in with AVIF_JPEG=SYSTEM, matching how the JPEG XL tools are built. libavif has no Xcode subproject, so Xcode runs build-avif.sh directly with nothing to fetch the sources first; the script now runs its own Makefile, which is a no-op once the stamp is current. The avifenc/avifdec file references also had one ../ too many, which resolved outside the repository.
launchTask now reports failure instead of swallowing it. When NSTask cannot start the process, nothing holds the write end of the pipe open on the child side, so the --info read waited for an EOF that could never arrive and the job could neither finish nor be cancelled. All three AVIF launches check it. The gain map probe is now a plain --info read, and its output also supplies the source bit depth. avifdec writes anything above 8 bits as a 16-bit PNG, from which avifenc infers 12 bits, so a 10-bit image came back out as 12-bit; pass the original depth explicitly instead. The lossy status line lists AVIF alongside JPEG, PNG and GIF, so enabling lossy minification no longer recompresses AVIF at an unadvertised quality. The preferences control comes with the layout rework. "Build AVIF Tools" is marked always out of date. It declared outputs but no inputs, so once avifenc and avifdec existed Xcode could skip it and never run the script's own signature check; the script is a no-op when its cache is current. The Optimize service accepts public.avif, so AVIF files can be sent to it from Finder like the other supported formats.
The libavif submodule stamp only depended on .gitmodules, so a checkout that moved the pinned gitlink kept building the previous source. Drop the stamp and update the submodule on every invocation instead. Hash the libjpeg source into the AVIF build signature too, since the tools statically link it, and key the per-architecture libjpeg build on that signature so a libjpeg change rebuilds it rather than relinking the old static library.
avifdec may leave a partial PNG behind when the task throws, and that path returned without the cleanup the other failure branches do. Also localize the new libavif/aom credits line for the locales whose attribution list is translated; ar, tr and zh-Hant credit every other tool in English, so their entry stays as it is. The nasm comment in build-avif.sh described the flag as a cross-assembly workaround when it disables x86_64 assembly for both slices.
AVIFWorker took its lossy flag straight from the defaults, so re-running a job re-encoded an already-lossy AVIF at the same quality and compounded generation loss. Pass the flag in from Job like the PNG and GIF branches do, gated on lossyConverted. Also refuse files with irot/imir/clap/pasp: avifdec bakes the first three into the decoded pixels and drops pasp, and avifenc recreates none of them.
avifdec writes no cICP chunk, so a source AVIF's CICP only survives the round-trip when it is plain sRGB (written as PNG's sRGB chunk) or when an ICC profile carries it in iCCP. Anything else comes back approximated by cHRM/gAMA, or dropped entirely for curves that have no gamma, which would re-tag a PQ or HLG image as sRGB. Bail out in that case, in both lossy and lossless mode - the loss is in the PNG hop, not in the encoder settings. Also handle launchTask failure in the workers that read from or wait on the task afterwards: parseLinesFromHandle blocks on a pipe that nothing will write to or close if the launch threw. Attach the avifenc/avifdec file references to the Supporting Files group instead of leaving them orphaned. Their paths are relative to the project directory, not to that group, so they become SOURCE_ROOT-relative, matching the "$(SRCROOT)/../libavif/build" outputs the script phase declares. Capture full cmake/ninja output in per-step logs and print the log on failure, as libjpeg/build-jpegli.sh already does, so an error earlier than the last few lines is not lost.
Xcode launched from Finder runs build phases with launchd's minimal PATH, so build_cache_signature's cmake/ninja lookups failed under set -euo pipefail before the build even started. Initialise PATH the way libjpeg/build-jpegli.sh does, and check for the two tools up front so a genuinely missing one reports itself instead of aborting inside the signature calculation.
PNG has no premultiplied alpha, so avifdec divides the colour out and avifenc writes it back without the prem association. The decoded RGBA is unchanged, but the stored samples are no longer the ones a compositor can use directly, so leave those files alone like the other AVIF properties the round-trip cannot reproduce. Above 8 bits avifdec converts YUV straight to 16-bit RGB, and quantizing that back down to the original depth does not invert the conversion for any matrix but identity: a 10-bit BT.601 image comes back with samples up to half a code off. At 8 bits the decoded RGB is exactly what gets stored again, for every matrix and both ranges, so those still round-trip. Correct the CICP comment while here: avifdec does write a cICP chunk, but avifenc only reads it back when its libpng supports the chunk, which is what keeps the sRGB-only check necessary.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds AVIF detection and recompression using libavif 1.4.2.
ImageOptim now recognizes AVIF from the ISO-BMFF
ftypbox, including files that listavifonly as a compatible brand. The worker decodes through PNG, re-encodes withavifenc, and keeps the result only when it is smaller.That PNG round-trip cannot preserve every AVIF feature, so the worker leaves a file alone when it contains data that would be changed or lost. This includes animation, gain maps, premultiplied alpha, unsupported color information, deep non-identity-matrix images, and geometry transforms. A square-pixel
paspentry is allowed because dropping it does not change the image.avifencandavifdecare built as universal arm64/x86_64 tools. The preference key, file type, MIME type, share extension, and Finder document support are wired up here; the actual preferences controls are added later in the stack.Tested with lossless and lossy round-trips on macOS 27 / Xcode 27.
Depends on #474. Until the preceding PRs merge, GitHub will also show their commits in this diff.
Development and review history: kristofferR/ImageOptim#5. The change has been tested and verified to convergence there, with no open review findings.