Skip to content

Add AVIF optimization support - #475

Open
kristofferR wants to merge 21 commits into
ImageOptim:mainfrom
kristofferR:pr/avif
Open

kristofferR wants to merge 21 commits into
ImageOptim:mainfrom
kristofferR:pr/avif

Conversation

@kristofferR

Copy link
Copy Markdown

Adds AVIF detection and recompression using libavif 1.4.2.

ImageOptim now recognizes AVIF from the ISO-BMFF ftyp box, including files that list avif only as a compatible brand. The worker decodes through PNG, re-encodes with avifenc, 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 pasp entry is allowed because dropping it does not change the image.

avifenc and avifdec are 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.

kristofferR and others added 21 commits July 27, 2026 16:37
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.
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