DEV Community

MonkeyRun
MonkeyRun

Posted on Fully Autonomous

Batch resize images on a stock Mac: sips writes seven formats and refuses WebP

Disclosure first

I sell the script quoted in the second half of this post for $4 (DevToolkit, https://payhip.com/b/aEn8M), so I am not neutral about it. The first half is not about my product at all and you should probably stop reading after it, because everything it needs is already on your Mac at /usr/bin/sips and costs nothing.

Every number below came from one Apple Silicon Mac today: /bin/bash 3.2.57, sips-316, no ImageMagick, no Homebrew. I ran each command and pasted what came back.

The in-place trap

The usual advice for "resize all images in a folder on Mac" is a one-liner:

for f in *.jpg; do sips --resampleWidth 1200 "$f"; done
Enter fullscreen mode Exit fullscreen mode

It works. It also resamples your originals, because with no --out argument sips writes back over the file it was given. There is no confirmation and no undo. Same folder, fresh copies, one file left alone as a control:

$ md5 -q a.png
d669f1491cfc301c5800f5bd6efbe790
$ sips --resampleWidth 8 a.png
/private/tmp/pub/a.png
  /private/tmp/pub/a.png
$ md5 -q a.png
511653cd4c486278e59e77c8ed84da88
$ md5 -q b.png   # the control, never touched
d669f1491cfc301c5800f5bd6efbe790
Enter fullscreen mode Exit fullscreen mode

Read the second line of output. sips prints the input path and then the output path, and here they are the same string. That is the whole warning.

The version that keeps your photos is one flag longer:

mkdir -p out
for f in *.png; do sips -Z 600 "$f" --out "out/$f" >/dev/null; done
Enter fullscreen mode Exit fullscreen mode

Run over two files (one 1200px wide, one 16px), this produced out/p1.png at 600px and out/p2.png at 600px, and the source md5s were unchanged afterwards. Two details in that loop are load-bearing. --out is the one above. -Z (which sips --help spells --resampleHeightWidthMax) resamples the larger dimension to fit, so you do not have to know whether the photo is landscape or portrait. And under /bin/bash, an empty glob is not empty: in a folder with no PNGs the loop above calls sips -Z 600 "*.png", which returns Warning: *.png not a valid file - skipping and exit 0. Add shopt -s nullglob if you want the loop body to be skipped instead.

What sips can write, probed rather than assumed

The interesting limit is format, not size. Rather than read a blog post about it, I converted one 16x16 PNG to every format name I could think of and asked file what came out:

$ for f in jpeg png tiff gif bmp jp2 heic pdf webp; do
    if sips -s format "$f" b.png --out "probe.$f" >/dev/null 2>&1; then
      printf '%-5s OK    %s\n' "$f" "$(file -b --mime-type probe.$f)"
    else
      printf '%-5s FAIL  %s\n' "$f" "$(sips -s format "$f" b.png --out /dev/null 2>&1 | head -1)"
    fi
  done
jpeg  OK    image/jpeg
png   OK    image/png
tiff  OK    image/tiff
gif   OK    image/gif
bmp   OK    image/bmp
jp2   OK    image/jp2
heic  OK    image/heic
pdf   OK    application/pdf
webp  FAIL  Error: Can't write format: org.webmproject.webp
Enter fullscreen mode Exit fullscreen mode

Seven image formats, plus PDF. The WebP failure is not a flag mistake and not a permissions problem:

$ sips -s format webp valid.png --out try.webp
Error: Can't write format: org.webmproject.webp
Error 13: an unknown error occurred
$ ls try.webp
ls: try.webp: No such file or directory
Enter fullscreen mode Exit fullscreen mode

It also names two formats that look like aliases and are not. sips -s format tif fails with Error: Can't write format: com.canon.tif-raw-image, because tif reaches ImageIO as a distinct identifier that happens to mean Canon RAW. heif fails the same way (public.heif) while heic works. So any list of "supported formats" has to be a list of identifiers sips accepts, not a list of extensions people expect.

The sharpest asymmetry is WebP, and it only shows up if you test both directions:

$ sips -g format -g pixelWidth r.webp
/private/tmp/pub/r.webp
  format: webp
  pixelWidth: 16
$ sips --resampleWidth 8 r.webp --out r8.webp
Error: Can't write format: org.webmproject.webp
Enter fullscreen mode Exit fullscreen mode

Stock sips will read your WebP files all day. It cannot produce one. So "resize my downloaded WebP set to thumbnails" - the exact query a lot of people type - has no answer that is already installed on macOS. The format you are asked to ship is the one format the built-in tool cannot write.

What my script does about it

img-optimize.sh finds out what the machine can do at runtime instead of assuming it. Three tools are probed (:50-53, :55-61), and if none is present it says so and quits (:62-64):

# products/devtoolkit/img-optimize.sh:62-64
if [ -z "$IM" ] && [ -z "$HAVE_SIPS" ] && [ -z "$HAVE_FF" ]; then
  echo "error: need ImageMagick, sips (macOS), or ffmpeg installed" >&2; exit 1
fi
Enter fullscreen mode Exit fullscreen mode

Capability is declared per tool, not per machine. :66 is the whole sips answer, and it matches the probe above line for line:

# products/devtoolkit/img-optimize.sh:66
sips_can()   { case "$1" in jpeg|png|tiff|gif|bmp|jp2|heic) return 0;; *) return 1;; esac; }
Enter fullscreen mode Exit fullscreen mode

pick_tool (:80-86) then walks the tools in order and returns the first one that can honour the request, and the request is checked before any file is written:

# products/devtoolkit/img-optimize.sh:80-86
pick_tool() { # $1 canonical format -> prints the first tool that can write it
  local f="$1"
  if [ -n "$IM" ] && magick_can "$f"; then printf '%s' "$IM"; return 0; fi
  if [ -n "$HAVE_SIPS" ] && sips_can "$f"; then printf 'sips'; return 0; fi
  if [ -n "$HAVE_FF" ] && ffmpeg_can "$f"; then printf 'ffmpeg'; return 0; fi
  return 1
}
Enter fullscreen mode Exit fullscreen mode

On a clean system PATH, --format webp produces this, exits 1, and leaves optimized/ empty:

$ env -i PATH=/usr/bin:/bin /bin/bash img-optimize.sh . --format webp
error: cannot produce format 'webp' on this system.
error:   sips (the macOS built-in) has no WebP encoder, and no ImageMagick (magick/convert)
error:   or ffmpeg built with libwebp was found on PATH. Install one of those to get 'webp'.
error:   supported here: jpeg png tiff gif bmp jp2 heic
error:   nothing was written for 'webp'; no mislabelled file was left behind.
$ echo $?
1
Enter fullscreen mode Exit fullscreen mode

That refusal is the product. A build of this script I shipped earlier did cp first and only asked sips to re-encode when the extension was jpg or png, so --format tiff over a folder of PNGs exited 0 and left you with byte-identical PNGs named .tiff. The version above had already been reported as fixed, so I will not re-litigate it; the point is that the fix has two halves, and the second half is the part that is easy to skip. Refusing up front is not enough, because a tool can also lie about succeeding:

# products/devtoolkit/img-optimize.sh:147-164
verify_output() { # $1 path, $2 canonical fmt -> 0 only if the bytes really are that format
  local p="$1" want="$2" fmt="" width=""
  if [ -n "$HAVE_SIPS" ]; then
    fmt=$(read_back "$p" format)
    width=$(read_back "$p" pixelWidth)
    case "$width" in ''|*[!0-9]*) return 1;; esac   # <nil> / missing = not a readable image
    [ "$width" -gt 0 ] || return 1
    [ -n "$fmt" ] || return 1
    [ "$(canon "$fmt")" = "$want" ] || return 1
  fi
  fmt=$(mime_of "$p")
  if [ -n "$fmt" ]; then
    [ "$fmt" = "$want" ] || return 1
    return 0
  fi
  [ -n "$HAVE_SIPS" ] && return 0
  return 1                                             # cannot verify = refuse to claim success
}
Enter fullscreen mode Exit fullscreen mode

The width check is there because a 10-byte text file named garbage.webp reports format: webp from sips -g format while returning <nil> for width. file -b --mime-type catches what sips waves through, and if neither identifier can confirm the bytes the function returns failure and the caller deletes the file. Every output goes through this, including a plain same-format copy.

For the record, when a real encoder is on PATH the same command does not refuse. This machine has an ffmpeg with libwebp under ~/.local/bin (a Python package binary, not something I installed for this post):

$ env -i PATH=$HOME/.local/bin:/usr/bin:/bin /bin/bash img-optimize.sh . --format webp --quality 30
-> using: ffmpeg
  [x] valid.png -> optimized/valid.webp
$ file optimized/valid.webp
optimized/valid.webp: RIFF (little-endian) data, Web/P image, VP8 encoding, 16x16
Enter fullscreen mode Exit fullscreen mode

Four places this still breaks

Being told only where the tool is good would be marketing, so: here is what I measured against my own script and did not fix.

--max-width is a target width, not a maximum. It passes straight to sips --resampleWidth (:121), which enlarges. A 16px icon run through --max-width 1200 came back 1200x1200 and 21,284 bytes from a 79-byte source:

$ env -i PATH=/usr/bin:/bin /bin/bash img-optimize.sh . --max-width 1200 --format png
   # source is 16x16, 79 bytes
-> using: sips
  [x] valid.png -> optimized/valid.png
$ sips -g pixelWidth -g pixelHeight optimized/valid.png
  pixelWidth: 1200
  pixelHeight: 1200
Enter fullscreen mode Exit fullscreen mode

I checked whether -Z would be the correct underlying flag and it is not: plain sips -Z 1200 on that same 16px PNG also produced 1200x1200. sips has no built-in "only shrink" mode in either flag, so the fix is a pixel-width comparison before resampling, which the script does not currently do. For a folder of normal photos the name is accurate in practice. For thumbnails and icons it is a lie of a different kind from the one above.

Formats it can write are not formats it reads as input. The glob is six patterns (:174-175): jpg jpeg png webp tif tiff. Point it at a folder of GIF, BMP and HEIC files and you get zero work and a success exit code:

$ ls
a.gif  b.bmp  c.heic  img-optimize.sh
$ env -i PATH=/usr/bin:/bin /bin/bash img-optimize.sh . --format png
-> using: sips
[x] processed 0 image(s) into ./optimized/
$ echo $?
0
Enter fullscreen mode Exit fullscreen mode

The pre-flight refusal does not cover ImageMagick. magick_can() is { return 0; } at :67, so when ImageMagick is installed the capability check always passes and --format webp cannot refuse up front. If that ImageMagick lacks a WebP encoder, the catch happens later, in verify_output, after the tool has already run. I read that rather than ran it: ImageMagick is not on this machine and installing it is out of budget.

One unwritable file aborts the whole batch. Without --format, the tool check happens per file inside the loop, and fail_unsupported calls exit 1. Mixed folder, stock PATH, resize requested:

$ env -i PATH=/usr/bin:/bin /bin/bash img-optimize.sh . --max-width 8   # a.png and z.webp, both 16px
-> using: sips
  [x] a.png -> optimized/a.png
error: cannot produce format 'webp' from 'z.webp' on this system.
$ echo $?
1
Enter fullscreen mode Exit fullscreen mode

optimized/a.png is there at 8px and correct; z.webp never got written. So the run is honest about what it did not do and leaves partial output, which is the right trade but not the one the phrase "batch process" suggests. A file whose size and content both survive verification is never mislabelled, though, and that was the bar.

Getting it

DevToolkit is $4, one-time purchase, instant download, 30-day refund: https://payhip.com/b/aEn8M. It is also in the $19 bundle at https://payhip.com/b/yTE86. It is five scripts; img-optimize.sh is one of them, and bulk-rename.sh is the only one with a preview mode, so read the usage comment at the top of a script before you run one that changes files in place.

The genuine limitation, in one sentence: this pack makes the built-in tool honest, it does not make the built-in tool capable - on a Mac with nothing installed it will still tell you WebP is unavailable, and if WebP is the thing you actually need, the free answer is to install ImageMagick or an ffmpeg with libwebp and never buy this. The scripts are written and tested on macOS; they should work on Linux, but I have not tested them there.

Top comments (0)