This changelog follows Keep a Changelog. Releases use Semantic Versioning.
Unreleased
0.5.0 - 2026-10-26
Added
- An
[:encode, :search, :probe, :ssimulacra2, :reference]span times how long autoquality waits for its comparison image (see probe cost spans). The default Logger doesn't log it.
Changed
- A JPEG, WebP, or AVIF output without alpha that no earlier step copied to
memory, such as a plain re-encode, a crop, or a resize, is encoded in full
before the response starts, instead of being copied to memory first. The
first byte arrives no later. On Apple Silicon, an 18.7 megapixel JPEG
re-encode takes 130 ms instead of 200 ms, with half the memory peak, and
WebP and AVIF outputs of that size take the same time and use 30-40 MB
less memory. This needs the libvips operation cache off
(
Vix.Vips.cache_set_max(0)), asimage_pipe_serversets it. PNG and progressive JPEG outputs still stream. A corrupt original still returns415. The[:transform, :materialize]span no longer runs for these outputs, and[:encode]times the whole encode. - EXIF-rotated images use less memory and are faster to resize or crop. On
Apple Silicon, a full-size 18.7 megapixel EXIF-rotated JPEG uses 40 MB
less memory, and resizing or cropping an EXIF-rotated image takes 13-24%
less time. This needs the libvips operation cache off
(
Vix.Vips.cache_set_max(0)), asimage_pipe_serversets it. - A CMYK image, or another one whose ICC profile is converted to sRGB on
input, is converted after a crop instead of before it. On Apple Silicon,
cropping a 2000x2000 region from an 18.7 megapixel CMYK JPEG takes 230 ms
instead of 430 ms, with a memory peak of 49 MB instead of 153 MB.
Converting the whole image takes 370 ms instead of 500 ms. This needs the
libvips operation cache off (
Vix.Vips.cache_set_max(0)), asimage_pipe_serversets it. - Converting an image with an ICC profile other than sRGB, such as a Display
P3 phone photo, to sRGB at full size takes up to 8% less time and, for an
18.7 megapixel image on Apple Silicon, uses about 45 MB less memory. This
needs the libvips operation cache off (
Vix.Vips.cache_set_max(0)), asimage_pipe_serversets it. With the cache on, a metadata edit copies the image to memory, as before. - An image whose alpha channel is opaque everywhere, as in many PNG and WebP files, is encoded without the alpha channel. The pixels don't change, but a PNG output is saved as RGB instead of RGBA, or as gray instead of gray with alpha. On Apple Silicon, an 18.7 megapixel PNG output encodes 9% faster and is 9% smaller, and an AVIF output encodes 7% faster.
- Resizing an image with transparency takes up to 40% less time. On Apple Silicon, resizing a 3368x2872 PNG to 800 pixels wide takes 111 ms instead of 188 ms, with half the memory peak. Resizing it to 2000 pixels wide takes 261 ms instead of 377 ms, with a peak 25 MB higher.
- PNG output is 15-35% smaller and encodes 7-29% faster. A PNG that isn't
a palette image is saved at zlib compression 3, with the
paethrow filter unless it is grayscale atbitdepth1,2, or4, instead of compression 6 with no filter. Afilterset inpng-optionsstill wins. - JPEG output is up to 18% smaller, with the same pixels. The encoder optimizes the entropy coding for each image, which adds 1 to 4 ms to an encode of 1-2 megapixels.
- WebP and AVIF autoquality on outputs of up to 6 megapixels is about 5-10% faster, with the same output: 110-260 ms on a 4-core x86 machine and 30-50 ms on Apple Silicon. The search builds its comparison image while it encodes the first quality, instead of before.
- A FileSystem cache's startup scan is several times faster and uses a quarter of the memory. On a 12-core machine, a cache of 1M entries is scanned in 3.5 s instead of 19 s, with a memory peak of 0.6 GB instead of 2.4 GB. The first scan after an upgrade that changes the metadata format takes 28 s instead of 320 s.
- The startup scan deletes leftover files
while it counts the entries, so
:counting?in the cache's usage staystrueuntil the scan finishes. - A hit on a recently served cache entry checks its body's size without
opening it, which makes a warm
GETabout 11% faster and aHEADabout 8% faster. AGETwhose body is evicted after that check sends nothing and looks the entry up again, which usually generates the image again. - The cache keeps recently served entries in memory without their
debug header details, so each takes about
1.1 KB instead of 1.8 KB, at most about 55 MB by default instead of
90 MB. A
debugrequest reads its entry from disk. - A file source checks its path for symlinks that leave the root when it
checks or reads the file, not on every request. A
304for an image four directories deep is about 10% faster. A request within a fallback lifetime, or for an immutable source, can still be served from the cache after a symlink in its path is changed to point outside the root. - Checking whether to build a
request summary no longer
gets slower as more telemetry handlers are attached. With
ImagePipe.Telemetry.attach_default_logger/1andImagePipe.Telemetry.attach_tracer/1, it took 4.6 µs per request and now takes 0.13 µs. - A request computes its cache key and
ETagin 7 µs instead of 12 µs. The cache key is computed differently, so after the upgrade each processed image in the cache misses once and is generated again.ETags don't change.
Fixed
- An embedded profile that matches sRGB is used as sRGB even when images
with other profiles are processed at the same time. Before, with the
libvips operation cache off, as
image_pipe_serversets it, such a profile could be judged not to match sRGB. Its images were then converted through the profile on every request, up to a node restart, and their output could differ by 1 out of 255 in a few channel values. - A response from a source configured to deny storage gets
Cache-Control: no-storeeven when an earlier Plug setCache-ControlorSet-Cookie. Before, it kept the earlier Plug's caching headers and sent nono-store. - Identical requests for a processed image share one render while up to 4,096 other images are processing or waiting for a turn. Before, past 64 such images, each identical request rendered its own copy: a cold burst of 160 thumbnails, each requested 3 times, made 342 renders instead of 160 and took twice as long.
- A streamed image gives back its processing turn when its encode ends,
instead of when the client has read the last byte. The part of the
response the client hasn't read yet waits in memory, up to 4 MB, and the
rest in a file in the system temporary directory. All requests on a node
together keep about 256 MB in memory and 2 GB in files this way, so the
temporary directory needs room for 2 GB (see
slow clients). Before, a few slow
clients downloading large images could hold every turn, so other requests
got
503, and identical requests waited for the slowest client. While ImagePipe has room to hold what the client hasn't read (see deadlines),processing_timeoutno longer counts the client's download, so a slow download of a large image is no longer cut short at the deadline. - On a CMYK source without a usable embedded ICC profile, colors from the
request, such as
bg,colorizeandduotone, come out as requested. Before,pad=5/bg=00ff00painted(192, 208, 11)instead of(0, 255, 0), and a progressive blur or several blur groups could crash the BEAM. - An RGB or grayscale source whose embedded ICC profile is for another color
space, or is damaged, is treated as having no profile. Colors from the
request come out as requested, and the output doesn't carry that profile.
Before, the output carried it, a JPEG with RGB pixels and a grayscale
profile painted
pad=5/bg=00ff00as(220, 220, 220), and one with a damaged profile returned422forbg,extendandcolorize. - An RGB or grayscale image whose ICC profile only supports the perceptual
rendering intent, such as a PQ or HLG JPEG XL, is converted through that
profile. Before, its colors went out unconverted, and
bgandcolorizereturned422. - An HTTP source that redirects to a protocol-relative
Locationon the same host, such as//images.example.com/moved.jpg, keeps its configuredauth,aws_sigv4and headers on the next request. Before, ImagePipe treated it as another origin and dropped them, so the origin usually answered401or403. - A file source and the filesystem cache treat a path under their root
that follows more than 40 symlinks as unsafe. A file source request for
such a path fails as a denied path (
404). Before, some symlink loops inside the root, such as one that passes through a link to., hung the request, or the cache's startup check or first lookup, and grew memory without bound. - With
stages: true, the default Logger logs:processing_erroron[:source, :watermark]and[:transform, :execute], and:erroron[:transform, :operation], at:warning. Before, it logged them at the configured level. - The tracer ends an
image_pipe.http.clientspan with an error status when a step of the HTTP request raises an exception. Before, the span stayed open and the trace lacked it. - An HTTP or S3 source whose response declares a
Content-Lengthovermax_body_bytesfails as soon as the headers arrive. Before, the body kept arriving into memory until ImagePipe read past the limit. - The filesystem cache deletes an entry's temporary metadata file when writing it fails, such as on a full disk. Before, the file stayed until the daily sweep.
- The filesystem cache stores an image larger than its
limit on unfinished writes (256 MiB, or a
twentieth of
max_size_byteswhen that is smaller) when no other write is in progress. Before, it never stored such an image and loggedstage_dropped (pending_limit)each time, so small caches regenerated large images on every request. - An AVIF whose major brand is a generic HEIF brand, such as
mif1, withavifamong its compatible brands is detected as AVIF. Before, it was detected as HEIF, soskip_processing_formats: [:avif]didn't pass it through andinforeportedheif. - A
srcorsrc64source that isn't valid UTF-8 fails with400invalid sourcebefore the source is fetched, as a watermark source does. Before, it reached the source and usually failed with404. - In telemetry, a query part without a value, such as
&tokenin a source URL or in a request's query string, is replaced withREDACTED. Before, it was kept, so a bare token that an origin authorizes with appeared in:source_url, the logged path, and traces.
0.4.0 - 2026-10-23
Added
- A
[:source, :svg_parse]span wraps parsing an SVG watermark, and names the limit that rejected it, such as:svg_bytes. The default Logger logs it withstages: true. - The
[:parse]and[:request]stops and the request summary carry:source_url, the request's source as ImagePipe parsed it, decoded fromsrc64or decrypted fromenc, and:watermark_source_urls, each group's watermark source. Each query value, such as anX-Amz-Signature, and a URL's user name and password are replaced withREDACTED. The default Logger writes them assource_url=andwatermark_source_url=fields, and the tracer records them on theimage_pipe.parseandimage_pipe.requestspans. See[:parse]. ImagePipe.cache_stats/1reports the cache's stored bytes and entries, its size cap, its hits, misses, and failed reads and writes since start, and the entries it keeps in memory, orrefused: truewhen another node owns its root. It also reports how many originals the instance remembers. A cache that doesn't report its size within a second hasavailable: false. See Usage and hit rate.max_remembered_originalssets how many originals an instance remembers in memory: a local file's hash, or an origin image's cache headers. It defaults to one for each 50 KB of:cache's:max_size_bytes, at least 10,000 and at most 200,000, and forgets originals that weren't used recently first. A remembered file isn't read and hashed again while it's unchanged, and the processed images of a remembered origin image are served without downloading it again. Each takes about 1 KB for a local file and about 1.4 KB for an image from an origin.- The
X-ImagePipe-Source-Progressivedebug header says whether a JPEG original is progressive. See Debug response headers. ImagePipe.Source.HTTPandImagePipe.Source.S3takepool_size, the maximum number of open connections to each origin address or S3 endpoint. It defaults to50, the limit that applied before. Over HTTP/1.1, raise it when more downloads from one origin run at once, so they don't wait for a free connection and fail with504afterpool_timeout.max_search_pixelslimits the pixels thatautoqualityandmax-bytesencode in one request, 100,000,000 by default. A search stops early when the next quality would pass it. On a 40-megapixel AVIF or WebP result it tries two qualities, plus up to two encodes to deliver one, where a search with both options could encode up to 11 times and take up to 45 seconds.:infinityturns the limit off. The[:encode, :search]stop event carries:pixel_limited, and its:limiting_factorcan be:search_pixels. See Limiting quality search work.
Changed
A cache whose
rootcan't be created or written logs an error at startup that names the root and the reason, such aspermission denied, and says images are served without being stored. Before, it logged a warning that it "could not claim" the root.Breaking: The
:errormetadata of[:debug, :collect, :error]is the module of the raised exception, such asRuntimeError, instead of always:error. Handlers that match onerror: :errormust match the module.Breaking: The HTTP source refuses origins in
192.0.0.0/24,192.88.99.0/24, and2001::/23, which are reserved for protocol use (the last includes Teredo tunnel addresses). They were treated as public. To reach one, set:allow_reservedor list its range in:allowin the source's:address_policy.The HTTP source allows an IPv4 origin reached through a NAT64 gateway from an IPv6-only network, at an address such as
64:ff9b::93.184.216.34, when the IPv4 address is public. Before, every such address was refused. An IPv4 range in:allowalso matches the NAT64 form of its addresses.Breaking:
ImagePipe.Source.Pathhas only asegmentsfield. Itsschemefield was alwaysnilin a path an adapter receives, so removescheme:from any pattern that matches the struct.Breaking:
storage_inputsis nowvary_on, and theImagePipe.run/4optionrequest_inputsis nowvary_values. Rename them in your configuration and calls.Breaking: An
ImagePipe.Plugmount without:keysraisesArgumentErroron every request unless its configuration setsallow_unsigned: true. Serve public traffic with signed URLs, and setallow_unsigned: trueonly for a mount that nothing outside a private network can reach. An instance withallow_unsigned: trueand no:keyslogs a warning when it starts. Running a plan withImagePipe.run/4orImagePipe.write/5needs neither. See Signing URLs.Breaking: The
[:request]event's:path, the request summary's path, and theurl.pathtrace attribute end at the source: the firstsrc,src64, orencsegment is followed byREDACTED, and the rest of the path and the query string are left out, as in/w=200/src/REDACTED. Eachwm-src64=value isREDACTEDtoo, including the rest of a standard-base64 value split at its/. A path without a source keeps its query string, andurl.query, with every value replaced withREDACTED. Read the source from the new:source_urlinstead. Before, the path was as sent, with only the values of a fixed list of credential parameters redacted, so values such as atokenorapi_keywere logged.Breaking:
encandwm-enc=sources are now visible to telemetry handlers, logs, and traces, decrypted, in:source_urland:watermark_source_urls. Source encryption hides a source from whoever requests the image, not from whoever reads your telemetry. If you send telemetry somewhere you don't want your source URLs, drop these keys in your handler.A request with
crop=100pct,100pctand nocrop-ratio, or withregion=0,0,100pct,100pct, is processed as the same request without them, since they keep the whole image, and gets its cache key and ETag. The[:request, :ignored_options]event, and withdebugtheX-ImagePipe-Ignored-Optionsheader, list the crop or region and the options only it made useful, such asanchor.Requests that use
autoqualityormax-byteshave new ETags, so clients and CDNs download them again once after the upgrade.A filesystem cache misses once after upgrading for every image it stored before, and its startup sweep deletes those entries.
A filesystem cache's startup sweep reads its metadata files only when the cache was last checked by an ImagePipe version with another metadata format, so restarting a cache with many entries reads far less from disk. The cache records the format it checked in
.metadata-versionin itsroot. The first start after this upgrade still reads every metadata file.Breaking:
:max_blur_worklimits the total work of allblur,progressive-blur, andsharpensteps in a request, across all its groups, instead of each blur on its own. Each step counts the pixels of its image times (sigma² + 10,000), so a single blur may use a slightly smaller sigma than before (up to 624 on a 10-megapixel image). A progressive blur counts (2 × sigma² + 50,000), so it allows a smaller sigma (up to 418 on a 10-megapixel image), and the default limit refuses any progressive blur on a 100-megapixel image. A request over the limit fails with422and the message "blur and sharpen work exceeds the limit". Raise the limit if your requests chain large blurs, or set it to:infinityto turn it off. See Limiting blur and sharpen work.When a cache with
max_size_bytesis full, it keeps images made from progressive JPEGs longer, since they cost more to render again than images from baseline JPEGs of the same size. Their:estimated_cost_uson[:cache, :write]is higher.A
detectoranchor=smart-facecrop of a large JPEG, WebP, or SVG with a small crop box decodes at a reduced size. Detection's minimum of 640 pixels on the long edge applies to the whole image the detector searches, not to the crop box. A crop box that covers the whole image runs no detection, so without afit=coverresize after it, it decodes as small as the output allows.Concurrent requests for one new original share its download in more configurations, and no longer wait for a download they would repeat. See request coalescing.
- Requests for an original from an HTTP or S3 source download it once, whatever size or format they ask for, with or without a cache, while up to 4,096 originals are being fetched at once. Before, without an originals cache, requests for the same processed image waited for the first download and then downloaded it again, and requests for other sizes each downloaded it themselves.
- Requests for an original from a source with caching turned off download it at the same time instead of one after another.
- When the original may not be stored, waiting requests start their own downloads as soon as the origin responds, instead of after the first download finishes.
gradienton an 8-bit image takes less time, since it blends in one pass. A blended color can differ from before by one level.autoqualityon an image with transparency takes less time. Each quality it tries is scored against a dark and a light background at the same time instead of one after the other.[:cache, :coordination]events for:sourcecome only from requests that download an original, check a remote original with its origin, read a local file, or wait for another request doing so. No event is emitted for an original from a source withinternal_cache: :disabled, or for a local file from an immutable source.:sourcecan report:bypasswithreason: :not_storedwhen the original may not be stored.Breaking:
:presetsmust be a map. Change a list of{name, fragment}pairs to a map such as%{"card" => "w=400"}.Breaking: An
ImagePipe.Source.S3.CredentialProvider'sfetch_credentials/1takes only the provider's options, without the bucket, andImagePipe.Source.S3.CredentialWarmuphas no:scopeoption. Credentials are now cached per provider and options and shared by every bucket. Remove the bucket argument fromfetch_credentials, give buckets that need different credentials different provider options under:buckets, and removescope:from each warmup, keeping one per provider and options.A WebP original scaled down a lot matches the same image as a JPEG or PNG original. Like a JPEG, it's decoded at 1/2, 1/4, or 1/8 of its size, the smallest that stays at or above the requested size, and the resize does the rest. Before, it was decoded straight at the requested size, so a thumbnail under about 200 pixels wide differed visibly from the JPEG or PNG one.
A cache's
max_size_bytescounts each entry's body rounded up to whole 4 KiB blocks, plus one block for its metadata file, instead of the body alone, so many tiny entries, such as BlurHash or LQIP responses, can't take far more disk than the cap. A cache whose entries now count past its cap evicts down to it at startup after the upgrade.When a cache with
max_size_bytesis full, a new processed image that many requests want is kept sooner. The cache now counts each request that misses it, including requests that wait for another request's copy, instead of counting how often it's written.A cache with
max_size_bytesuses less than half the memory to track its entries: about 175 bytes per stored image instead of over 400, which saves at least 45 MB for a cache of 200,000 images.The default Logger shows a coordination event's reason after its result, as in
bypass (deadline). It logs a:bypasswith reason:not_storedat its base level.An HTTP source whose host doesn't resolve fails with
{:source, :unresolved_host}in logs and telemetry instead of{:source, :denied_address}. It still answers404.autoqualitywithmax-bytesscores only the quality it delivers, not each quality it tries to fit the byte limit, and reuses the encodesautoqualityalready made to narrow that search, so such a request takes less time.[:encode, :search, :probe]spans of the:capphase no longer carry:score.The hash and timestamps kept for each local file, and the cache headers kept for each origin's image, are held in memory instead of in the processed-image cache, for up to
max_remembered_originalsoriginals. They no longer take its space or get evicted with its images. After a restart, a local file is hashed again, and an image from an origin is downloaded again, on its first request. Remembering them uses none of the cache's 64 concurrent writes, so it never causes a processed image's write to be dropped.A cache with
max_size_bytesdeletes a rejected write without syncing it to disk. Once the popular images don't fit in the cache, that saves most of its write I/O. A write that waits more than 5 seconds to be kept or rejected is dropped and reported aserror: :admission_timeout.[:cache, :admission]is now emitted before the sync, and its:victim_countcounts the entries the write was compared with.[:transform, :detect, :skipped]is also emitted whenanchor=smart-faceuses attention becausedetector_requiredis set and the face model isn't loaded yet (result: :not_ready) or the detector has no face model (result: :unavailable). The default Logger logs both atwarning.When an HTTP source follows a redirect to another host, port, or scheme, the request no longer carries the headers or credentials set in
req_options, such as an API key. Before, onlyauthorization,proxy-authorization, andcookiewere dropped. A redirect fromhttpstohttp, or to a URL with a user name or password, now fails with502.A
blur,progressive-blur, orsharpenresult is copied to memory before a later resize or a step that rereads pixels (rotate,trim,pixelate,blur,progressive-blur,sharpen, or a crop withanchor=smartordetect). Before, each of those steps recomputed the blur for every region it read, so a request could take many times the CPU thatmax_blur_workallows. Arotateby an angle other than a quarter turn is copied the same way before such a step.A request for a cached image from a local file no longer opens the file while its size and timestamps are unchanged.
A cache with
max_size_byteskeeps its counts of the requests that miss each image in 4 MB per 50 GB ofmax_size_bytes, allocated when it starts. Before, a busy 50 GB cache's counts grew to about 70 MB and slowed its garbage collection.A downloaded original is written to disk in 64 KiB batches instead of one write per network read, which takes less CPU for a large original.
With
format=jpeg,padandextendfill with white directly, instead of adding transparency that the encoder then flattens onto white. This takes less time to encode and gives the same image, unless:max_result_width,:max_result_height, or:max_result_pixelsthen scale it down. Then pixels next to the padding can differ.A
detectoranchor=smart-facerequest decodes and buffers less when no detection will run:- The detector isn't available for any of the requested classes.
- With
detector_required: true, the detector's face model isn't ready yet, and the request usesanchor=smart-face. - The image already has the
fit=covertarget's aspect ratio, so the crop can't move.
When the crop falls back to attention cropping, it runs on the smaller decode, so its result can differ slightly.
Pixels of a source whose embedded RGB profile matches sRGB to within 1 level are used as sRGB, without converting them through the profile, for the output, for placeholders, and for
bg,gray,bitonal,monochrome,duotone,colorize,gradient,trim, andwm. The output still embeds the profile withprofile=preserve. With the libvips operation cache off, this saves about 7 ms on a small JPEG resize and up to about 220 ms on a largetrimrequest. Some pixels can differ from before by 1 out of 255.The
[:request]event's:path, and the request line and theurl.pathtrace attribute that come from it, include the path thatImagePipe.Plugis mounted at, such as/imagesforforward "/images", ImagePipe.Plug. Before, the mount path was left out.A
FileSystemcache keeps the metadata of recently served entries in memory, so a repeated hit reads only the body file from disk. It takes about 36 MB for a 1 GB cache, and at most about 90 MB unlessmax_remembered_entriessets the number of entries. See Cache process and restarts.A second instance whose cache
rootanother instance in the VM already uses raisesArgumentErrornaming the root when it starts. Before, its start failed with{:already_started, pid}.The cache, downloads, and decoding no longer queue their file operations behind the Erlang VM's single file-server process.
An instance keeps the bytes of up to 64 recently used watermark images in memory, so a watermark from an origin is no longer downloaded for every processed image. See What is stored.
The processing pool's
max_queuedefaults to 1,024 instead of 64, so a burst of quick requests waits for a turn instead of answering503. See choosing values.With
meta=strip, a JPEG, WebP, or AVIF image at 72 dpi no longer carries an EXIF block, so it's about 190 bytes smaller. An image at another density, set by thedpiURL option or thestripped_dpisetting, keeps an EXIF block with only the density. PNG still stores its density inpHYs.
Removed
- Breaking: The originals cache: the
input_cacheoption, and the file source'scopyoption, which kept copies of local files in it. Remove them from your configuration, and delete the originals cache's directory. The[:cache, :input]event and the request summary's:input_cachefield are gone, and[:cache, :sweep]no longer reports:pins. - Breaking: The
ImagePipe.Plugoptionallow_debug_headers. A request'sdebugoption always adds the debug headers. Remove the option from your configuration. With signed URLs, only your app can adddebug. - Breaking: The
ImagePipe.config/1optionrequest_watermarks. Requests may always name their own watermark withwm-src64orwm-enc, and its image still has to come from a configured source, like the main image. Remove the option from your configuration. - Breaking:
max_preset_lookups. A request may look up any number of presets. Remove the option from your configuration.ImagePipe.run/4no longer returns{:preset, :too_many_presets}, and[:preset, :lookup]no longer reports that reason. - Breaking: The instance option
mounts:, andmount:onImagePipe.PlugandImagePipe.url_config/2, which is nowImagePipe.url_config/1. An instance checks URLs against its own URL settings only. To serve URLs with different keys, such as signed and unsigned URLs, start an instance for each, and mount it or build its URLs by its name. Instances that use caches each need their own cache roots.
Fixed
- A
debugresponse from a configuration without a cache leaves outX-ImagePipe-Cache-Key, as documented. Before, it carried a key that nothing could be stored under. - When a later preset or URL option unsets or replaces a value from a
preset or the request defaults, the value that one replaced still
applies. With
request defaults of
format-q=avif:50and a presetq80defined asq=80,/preset=q80/q=unsetencodes AVIF at 50. A preset that names other presets gives the same result as listing them in the URL. Requests that hit these cases get new cache keys and ETags. See clearing inherited values. - A request that uses a preset your
:preset_lookupreturns, naming a watermark the configuration doesn't define, or carrying awm-enctoken that no key in:source_encryption_keysdecrypts, answers500, andImagePipe.run/4returns{:preset, :invalid_definition}, like other invalid stored presets. Before, it failed with400or404, as if the requester had made a mistake. - The daily sweep of a filesystem cache's leftover files keeps running after one of the cache's processes restarts, and after a sweep fails. Before, it stopped until ImagePipe restarted.
- The request summary and the default Logger's request line carry
:reasonfor a403invalid signature, a400for a signed URL sent to a mount without signing keys, a410expired URL, and a404concealed source that doesn't decrypt. Before, these lines had no reason. - A crash report from a streaming response, or from
ImagePipe.Source.S3.CredentialWarmup, no longer prints the configured origin credentials, such as S3 keys and authorization headers. - A request path with bytes that aren't valid UTF-8 no longer makes the
telemetry path invalid UTF-8. They become
U+FFFD. ImagePipe.config/1raises a duplicate-option error for an option given more than once. Before, it raised an unknown-option error for it.- Requests that differ only in the header or cookie values named in
vary_onshare one download of their original and one remembered original. Only the processed images differ. A new cachebuster still downloads the original again. - A preset or
:request_defaultswith awm-enctoken that the:source_encryption_keyscan't decrypt raisesArgumentErrorwhen ImagePipe starts. Before, the configuration was accepted and every request using the token failed with404. - A preset with
wm-enc=unsetno longer needs:source_encryption_keys. - The request defaults apply to a URL's first group even when that group
holds only
unsetoptions. Before, they moved to the next group, sow=unset/-/blur=2with a defaultw=200was still resized to 200 pixels wide. - A cached image whose delivery failed after its headers were sent logs its
request line at
error, with status200. Before, the line had no status and logged atwarning. - A request that stopped waiting for another request's download of an original, just as that download finished, no longer keeps the original locked, even when the server takes more than 5 seconds to respond. Later requests for that original waited behind the lock until the process that served the request exited.
- An
enlargeor akernel=nearestresize of an image with an EXIF orientation,rotate, orflipnow gives the same pixels as rotating or flipping first and then resizing. Before, enlarged pixels could be off by a fraction of a pixel, and akernel=nearestdownscale picked different source pixels. - An image delivered in sRGB without a color profile, with its metadata
kept, has the EXIF
ColorSpacetag set to sRGB when the original has the tag. Before, the tag said Uncalibrated. - An image decoded while its original downloads no longer waits behind
newer requests for its encode. The encode's turn in the processing pool
goes ahead of waiting requests and keeps the deadline of its first turn.
Before, it queued at the back and could fail with a
503after its decode had finished. - An image whose EXIF date fields aren't plain numbers (such as
2024:01:01 02+02:00:00) or aren't stored as text no longer fails with a500when metadata is stripped. All of its metadata is removed, including the copyright and artist thatkeep_copyrightwould keep. - A watermark with an opacity below
1blends with the image at its exact faded alpha. Before, the faded alpha was rounded to a whole level first, so a watermark's edges or faint areas could be off by one level. - An S3 credential fetch from a provider that hangs fails after 30 seconds, and the next request fetches again. Before, a hung fetch held up every request for those credentials until the server restarted.
- An HTTP source fetch that runs out of
fetch_timeoutwhile waiting for a pooled connection reports:receive_timeoutin logs and telemetry instead of:pool_timeout. When the source's host resolves to several addresses, a fetch no longer tries the next one with less than 100 ms offetch_timeoutleft. - A request with
format=webpand losslesswebp-optionsno longer fails with400when it, a preset, or the request defaults setautoqualityormax-bytes. The options are ignored, as withformat=png. - A preset group holding only
unsetoptions is kept when the preset is compiled, so a first group like that clears the request defaults, as the same groups written in the URL do. Before, the defaults applied to the preset's next group:blur=unset/-/w=100with a defaultblur=3still blurred. A preset with such a group, in any position, is a pipeline preset, so adding other options to it answers400. - A
:presetsoption that isn't a map with string keys, such as a string ornil, raises anArgumentErrorthat names:presetsinstead of aProtocol.UndefinedError. - A
regionwhose x or y is0pctgets the cache key and ETag of the same region written with0. - A detector whose
warmup/1raises no longer stops itsImagePipeinstance. The warmup logs a warning and retries, as it does for{:error, reason}. A detector whoseavailable?/1raises is skipped with a warning. Before, these crashes used up the instance's restart limit, and every later request raised. A crash report from the warmup no longer prints the detector's options. detect=all:3, one weight for every class, gets the cache key and ETag ofdetect=all, since a weight that every class shares doesn't move the crop.- The request summary of a
HEADrequest that misses the cache no longer reports body bytes (:bytes, and the size in its log line), since none are sent. - A request whose
srcURL holds a user name or password that isn't valid UTF-8 once decoded keeps its request summary log line. - A
cropplaced by an anchor, including the default center, or by a focus point, followed by a resize, now lands where it would on a full-size decode. Before, a source decoded at a reduced size for the resize could place it up to one output pixel off. - Downscaling a 16-bit image or watermark without transparency, including the scale-down to fit the result limits or a format's size limit, no longer darkens it slightly. Each value lost about 0.1%, so white came back as 65455 instead of 65535.
- An image scaled down to fit
:max_result_width,:max_result_height,:max_result_pixels, or a format's size limit keeps the exact colors of its semi-transparent pixels and uses the request'skernel. Before, a semi-transparent pixel could lose a level, so[200, 120, 37, 100]became[200, 119, 37, 100], and akernel=nearestresult was smoothed. - When the first load of the face or object model failed, each detection
request waiting for it tried the load again in turn, so the last one
waited for every attempt. Those requests now fail with the first load's
error at once: the crop falls back to attention, or the request fails with
500whendetector_requiredis set. The next request loads again. png-options=palette,bitdepth:16multiplies 8-bit color by 257, asbitdepth:16withoutpalettedoes. Before, it multiplied by 256.png-options=bitdepth:1,2, or4on a 16-bit color or gray-and-alpha result encodes at 8 bits, as on an 8-bit one. Before, it encoded at 16 bits.- A
crop-ratiofar from thecropsize's own ratio, such as1:4000for a square crop, no longer fails with a500. Each side of the crop keeps at least one pixel. - A client error, such as a
404for a missing source, on a URL withexpirescaps a host'sCache-Controlat the URL's expiry, as an image response does. Before, a host'smax-age=86400kept the error cached after the URL expired. Errors found while parsing the URL, and the410for an expired URL, keep the host'sCache-Controlunchanged. - A thin
croporregionof a JPEG or WebP image blends in far fewer pixels from just outside it. A crop under 16 pixels on its short side, such as an 8-pixel strip, decodes at full size and takes in none. Otherwise the reduced-size decode shrinks by at most an eighth of the crop's short side. - When none of an HTTP source's addresses accepts a connection or answers in time, the source keeps its cached DNS answer while it looks the host up again, and replaces the answer only when it changed. Before, the cached answer was dropped first, so other requests to that origin waited for a new lookup.
output=inforeports an exactdprfor a tall, narrow image that the result limits scale down. Before, a 7×4000 image scaled to 2×1000 reporteddpr0.29 instead of 0.25. The:scalemeasurement of[:output, :clamp]now uses the long side too.- A URL's
anchor-offset,extend-at, orextend-offsetno longer replaces the crop target or canvas from a preset or the request defaults, and itsunsetremoves only that option. Before,anchor-offset=5,5dropped a preset'sanchor. See presets. - A
trimtolerance with hundreds of digits no longer fails with a500. Tolerances above2147483647now fail with a400. - Resizing a JPEG or WebP image only a few pixels high or wide, such as a
2000×3 image at
w=100, no longer fails with a415. Neither does shrinking a WebP so far that its short side would be under one pixel, such asw=1/dpr=0.5. - S3 credentials that a provider returns with less than five minutes left are fetched again at half their remaining time, instead of every five seconds until the provider rotates them.
- A cache's own events,
[:cache, :admission],[:cache, :eviction, :stop], and[:cache, :sweep], use the instance'stelemetry_prefix. Before, they always used[:image_pipe]. Handlers attached for a custom prefix, includingattach_default_logger(prefix: ...)andattach_tracer, now get them, and handlers attached under[:image_pipe]for such an instance no longer do. - An issue that
ImagePipe.validate/2orImagePipe.run/4reports, and the:locationsof[:request, :ignored_options], name the group as it's written in the URL or in the URL builder. Before, when a group held onlyunsetoptions, issues in later groups named the group before theirs. png-options=bitdepth:1on a grayscale result now turns values below 128 black and the rest white. Before, every gray above black came out white.- With a processing pool, a request that
waits for its turn while its original is still downloading:
- Answers
503once it has waitedqueue_timeout. Before, it waited for a second turn after the download finished. - Answers an error from processing, such as a
415for an image that can't be decoded, without waiting for a second turn. Before, it could answer503instead when that wait timed out. - No longer fails with
50360 seconds after its original finished downloading whenqueue_timeoutplusprocessing_timeoutallows longer.
- Answers
- S3 credentials whose access key, secret, or session token holds a line
break or NUL are rejected: static ones when the configuration is checked,
and ones from a credential provider when they're fetched, which answers
500and logs which field was invalid. Before, every fetch with them failed with502, or with404when the secret held one. - A
detectoranchor=smart-facecrop whose detector isn't available in this build no longer adds a detection to the work the cache estimates for the image, which the cache uses to choose what to evict and reports as:estimated_cost_uson[:cache, :write]. - The default Logger logs every
4xxrequest line at:warning. Before, a4xxfor a failed decode, an input limit, a transform, or an invalid source logged at:error. - A request with an invalid source has the result
:parser_error, not:processing_error. Like other parser errors, its[:request]stop fromImagePipe.run/4has no:error. - The request line for an invalid source or invalid output gives the
reason:
reason="invalid source"orreason="invalid output". - A 16-bit original delivered at 8 bits per channel has each value rounded
to the nearest 8-bit level. Before, about a quarter of the values were one
level off, so 65400 became 255 instead of 254. This also applies to
png-options=bitdepth:8withhdr=preserve. - A request that chains many operations, such as a watermark in each of a dozen groups, no longer crashes the Erlang VM. libvips ran out of stack rendering the chain. The image is now copied to memory once six operations have run since its last copy, which keeps the chain short.
png-options=palettegives a palette PNG from a 16-bit result, such as one withhdr=preserve. Without abitdepth, the palette was dropped and the PNG stayed 16-bit.- With
meta=keepandprofile=srgb,display-p3, oradobe-rgb, the kept EXIF no longer carries the original's white point and primaries, and its ColorSpace tag is sRGB only forprofile=srgband Uncalibrated otherwise. Before, a Display P3 result could keep ColorSpace sRGB from its original.
Security
- Breaking:
ImagePipe.Source.S3.ContainerCredentialsrejects abase_urlthat doesn't usehttps, a loopback host, or the ECS or EKS credential endpoint, as it already did forfull_uri. Before, arelative_urisent the authorization token to anybase_url. The defaultbase_urlis unaffected. Change any otherbase_urlto usehttpsor a loopback host. - Redacting a request path for telemetry, and echoing it in a
400response, take time in proportion to the path's length. A path with manywm-src64=segments took seconds to redact, and other crafted 10 KB paths took tens of milliseconds. Telemetry redacts every request path before its URL signature is checked, so an unsigned request could cause this. - Plain-text error responses, such as the
400and the405, now sendX-Content-Type-Options: nosniff. A400body quotes the request path, so a browser that sniffs content types could otherwise render it as HTML. - The default Logger and the span tracer cap how much of each error reason and attribute value they print, so a large term can't produce an unbounded log line or span. Before, the Logger relied on Logger's own truncation, which a host can turn off.
0.3.0 - 2026-10-18
Added
- The
kernelURL option sets the filter a group's resize uses:nearest,linear,cubic,mitchell,lanczos2,lanczos3(the default),mks2013, ormks2021. Withkernel=nearestin the first group, a large JPEG or WebP source is decoded at full size, so the request takes longer and uses more memory. See kernel. dns_cache: falseturns off an HTTP source's DNS cache, for a server whose DNS resolver already caches and whose TTLs should apply exactly.max_blur_worklimitsblurandprogressive-blur: the pixels of the image being blurred, times the square of the sigma, can't exceed it. A blur over the limit fails with422. See Limiting blurs.- A
[:request, :summary]telemetry event, emitted once per request, with what the request did as flat fields: method, path, status, duration, bytes sent, the signing key, the output and input cache outcomes, source, the original's format and size, the operations, the output format and size, the quality a search chose, and the time spent fetching, decoding, encoding, and waiting for the processing pool. It also records a result scaled down to the limits, ignored options, an original sent unprocessed, an original held in memory because it couldn't be written to a temporary file, and a client that disconnected. It gathers them from every process that served the request. The event reference lists them. - The
[:request]span's start carries:methodand:pathfor Plug requests. The path is as sent, except that a presigned source URL's credentials, such as anX-Amz-Signaturevalue, are replaced withREDACTED. The tracer records them on the request span ashttp.request.method,url.path, andurl.query. - A file system cache logs at
infowhen it claims its cache root, naming the host. A node that refused the root, because another node used it, logs atinfowhen it takes the root over and requests use the cache again. - ImagePipe logs a warning, once per directory, when the
image_pipedirectory in the system's temporary directory can't be used to stage originals, so they are held in memory instead. The[:source, :stage]span reports each original held in memory, and why, in:stagingand:staging_reason, which the request summary also carries. - An S3 source logs a warning when a fetch of its provider's credentials
fails, naming the bucket and the provider module, and logs at
infowhen a fetch succeeds again. - A
priority=lowrequest waits for spare capacity in the processing pool: it starts only when no normal request is waiting, and a normal request that finds the queue full takes its place. The pool'slow_priority_max_concurrencyoption limits how many run at once. It defaults to half ofmax_concurrency, rounded down, and at least 1. A low-priority request waits for the original to finish downloading before processing starts. See Low-priority requests. - The
503for a full processing queue or a queue timeout has aRetry-Afterheader, the pool'squeue_timeoutin whole seconds, rounded up. - The
[:processing, :admission]and[:processing, :execute]spans carry the request's:priority, andImagePipe.ProcessingPool.stats/1also returns:low_activeand:low_queued. The[:request, :summary]event haspriority: :lowfor a low-priority request, and the default Logger logs one that a full pool turned away at its base level instead of as an error.
Changed
- An instance without
cacheorinput_cacheno longer reads and hashes an unchanged local file on every request. It keeps the hashes of up to 10,000 files in memory and checks each file's size, timestamps, inode, and device instead. For a 5.5 MB PNG this saves about 3 ms per request. For a remote original, a request withIf-None-Matchgets304without contacting the origin while the origin's cache lifetime lasts.[:cache, :lookup]reports these lookups withpool: :memory. - An HTTP source keeps the addresses a hostname resolves to for 10 seconds,
so a busy origin is no longer looked up before every fetch. The addresses
are still checked against
:address_policyon every fetch. A resolver passed in:address_resolverreplaces the cache. - When none of an HTTP source's addresses accepts a connection or answers in
time, the source looks the hostname up again and tries once more if the
answer changed. A resolver passed in
:address_resolvercan therefore be called twice for one fetch. - AVIF's default quality is 56, down from 63. Files are about 18% smaller.
The median image scores 78 instead of 82 on
SSIMULACRA2,
close to the JPEG default's 77. To keep 63, set
format_quality: %{avif: 63}. - A response's cache key and
ETagno longer depend on the quality and encoder settings of other formats. Changing the AVIF quality oravif_optionsleaves them unchanged for WebP responses and for requests with aformat, such asformat=jpeg, so those stay cached and clients keep their copies. A response withoutformat, for a client that accepts neither AVIF nor WebP, takes the original's format and still depends on every format's settings. After upgrading, cached image responses miss once and clients download each image again once. - Breaking: a
blurorprogressive-blurover the defaultmax_blur_work, 4,000,000,000,000, fails with422.blur=1000on a 10-megapixel photo took about 7 seconds and now fails. Raisemax_blur_workto allow larger blurs. blurandsharpenbuffer the image in memory before filtering it. They recomputed earlier steps, such as an enlarge or a color conversion, many times over, sosharpen=10/w=8000/h=8000/enlargetook 14 s. It now takes under 1 s. A request withblurorsharpenholds one copy of the image in memory at that point.- Cached
output=blurhash,output=lqip-css, andoutput=inforesponses that include a placeholder miss once after upgrading, since placeholders of transparent images changed. - Output cache write failures are no longer logged directly. Attach the
default Logger to see them, as
image_pipe cache stageandimage_pipe cache writewarnings with the error category. [:cache, :write]carries:erroron input cache write errors too.- Breaking:
ImagePipe.Telemetry.attach_default_logger/1logs one line per request, from the new[:request, :summary]event, instead of one line per stage. A5xx, or a response that failed partway through sending, logs aterror, and a4xx, a result that didn't come out as requested, or a failed cache read atwarning. Failed and dropped cache writes log their ownwarninglines. Passstages: trueto also log the stage lines as before. - Breaking: An
ImagePipeinstance starts a processing pool unless its configuration names one inprocessing_pool. It processes as many images at once as the VM has online schedulers and queues up to 64 more for up to 10 seconds. Beyond that, requests get a503, andImagePipe.run/4returns{:processing, :overloaded}or{:processing, :queue_timeout}. An image that takes longer than 30 seconds gets a503, or{:processing, :timeout}fromrun/4. Set the newpoolinstance option to change the limits, orpool: falseto process every request as it arrives, as before. - Breaking:
ImagePipe.config/1raisesArgumentErrorfor a:detectormodule that isn't loaded or doesn't implementImagePipe.Transform.Detector. Before, such a configuration started, and everydetectoranchor=smart-facerequest failed with500. Fix the module name, or setdetector: nil. - Breaking:
ImagePipe.config/1raisesArgumentErrorfor a preset or request defaults whose own options can't be combined, such asanchor=top/detect=faceorq=80/autoquality=50. It also raises for awmname missing from:watermarks, and forwm-src64orwm-encwithoutrequest_watermarks: true. Requests that used them failed with400unless the URL replaced the conflicting options. Remove one of the conflicting options, or the watermark option. - A preset from a preset lookup whose own options can't be combined answers
500, like other invalid stored presets, instead of400, andImagePipe.run/4returns{:preset, :invalid_definition}for it. - A request with
pad=0gets the cache key and ETag of the same request withoutpad, and agradientorprogressive-blurthat starts or stops at-0.0gets those of one at0. Cached copies of such requests miss once after an upgrade. - A
format-qentry forpngapplies only when PNG has a palette, likeq. Before, a URL with one and no palette failed with400, even when the response wasn't PNG, and one inrequest_defaultsor a preset made every request fail. - A watermark on an image with a color profile is about 5 ms faster when the
output is sRGB, which is the case with the default
profile=stripand withprofile=srgb. Partly transparent watermark pixels over colors outside sRGB, such as on a Display P3 photo, blend slightly differently.
Fixed
autoqualityscored images with transparency as if they were shown on black, so changes at their edges and in their transparency didn't count, and transparent margins raised the score. A logo could get AVIF quality 22 where it needed 31. It now scores the visible area only, against both a dark and a light background, and keeps the lower score. Images with transparency can get a higher quality and larger files.- Detection requests that arrived before the bundled face or object model
had loaded, for example during startup warmup with
detector_required: false, each downloaded and loaded the model. They now wait for one load, so the 175 MB object model downloads once. png-options=bitdepth:16multiplied 8-bit color by 256 instead of 257, so 128 became 32768 instead of 32896 and only white reached 65535. Color now scales by 257, as gray already did.output=inforeporteddprfrom before the scale-down to the result limits, so dividing its width bydprgave less than the CSS width.dprnow includes that scale-down:w=4000/dpr=3on a 9,000-pixel-wide image with an 8,192-pixel limit reports width 8192 atdpr2.048, not 2.25.- Content-aware crops with
detectoranchor=smart-facefor a small output detected on the resized image.w=64/h=64/fit=coveron a 6000×4000 source gave the detector 96×64 pixels, so small faces and objects were missed. Detection now gets at least 640 pixels on the long edge, or the whole source when it's smaller, and such requests decode at that size. - An
extend-offsetthat landed exactly on a half pixel, such asextend-offset=0,1.25withdpr=2, rounded away from zero, placing the image 1 pixel off. It now rounds ties to even, as crops, watermarks, and padding do. - With a processing pool, a streamed response that finished just after
processing_timeout, before the pool's timer fired, ended with{:session, {:producer_down, :normal}}instead of{:processing, :timeout}. output=blurhashandoutput=lqip-cssrendered transparency on black, so a transparent PNG's placeholder could be dark while the JPEG the same URL returns is light. Placeholders now render transparency on white, unlessbgsets a background.- An
ImagePipe.Plugconfigured at compile time, such as in a router'sforwardoptions, could fail to compile with "does not implement ImagePipe.Transform.Detector" when its custom detector was compiled in the same pass. Configuration now waits for the detector module to compile. - The
[:transform, :detect, :model]span reported a model that is unavailable, such as a face model withoutimage_vision, asresult: :error, and the default Logger logged it as a model error. It now reportsresult: :unavailable, as[:transform, :detect]does. - Watermarks,
bg,colorizeover transparent areas, JPEG output of an image with transparency, and resizing or rotating an image with transparency could make a color one level darker than the exact result, such as 169 for 169.51. These now round to the nearest level. format=pngwithq, from the URL, the request defaults, or a preset, failed with400when PNG had no palette. Aqinrequest_defaultsor a preset made everyformat=pngrequest fail. PNG without a palette now ignoresq, as negotiated PNG already did.- On a URL with
expires, a response getsCache-Control: no-store, even when an earlier Plug setCache-Control, if storage is denied by its origin or by configuration, or if a crop fell back because content detection failed. Before, it got the earlier Plug'sCache-Controlcapped at the expiry, and a CDN could store it. - An SVG source with
fit=stretchand one axis, such asw=480/fit=stretch, or with onlymin-wormin-h, stayed at its intrinsic size instead of rendering at the requested size. It now renders at that size, like any other first-group resize of an SVG. - On a URL with
expires, aCache-Controlset by an earlier Plug is capped at the expiry in more cases:- A quoted value, such as
max-age="86400", is read as seconds. Amax-agethat isn't a whole number of seconds, such asmax-age=86400.5, is replaced, and ans-maxagelike that is dropped. Before, both failed the request with500. - Directive names match in any case, so
MAX-AGE=86400is lowered too. - A
Cache-Controlwithoutmax-age, such aspublic, gets one that ends at the expiry.
- A quoted value, such as
- A
croporregionfollowed by a resize could give a different size for a JPEG source than for the same image as PNG, because JPEG decodes at a smaller size first. A narrow crop was off by several pixels, such ascrop=101,1500/w=50giving 50×750 instead of 50×743. The size is now the same for every source format, in the image and inoutput=info. - A server error (
5xx), such as a502for a failing origin, getsCache-Control: no-store, even when an earlier Plug setCache-Control. Before, it kept the earlier Plug'sCache-Control, and a CDN could serve the error for that header's lifetime. A client error (4xx) still keeps it. output=infowith an explicitformatwhose encoder has a lower size limit than the server's result limits, such asformat=webpwith limits above 16,383 pixels, shared its cache entry and ETag with other formats. A cache could then serve one format'sresultdimensions for another.- A request to an HTTP or S3 source that waited past
pool_timeoutfor a free connection crashed the request process. Behind Bandit, the client got no response and the connection stayed open. Such requests now fail with a{:source, :pool_timeout}error, which the Plug answers with504. autoqualitytogether withmax-bytescould ship a lower quality thanmax-bytesalone, because the quality search used up the encodes the byte budget needed. The byte budget now gets its own encodes.- A conditional header in an HTTP or S3 source's
req_options, such asif-none-match, made the request fail when the origin answered a first fetch with304. Sources now dropif-none-match,if-modified-since,if-match,if-unmodified-since, andif-rangefromreq_options. - The detector warmup gave up after three failed attempts, about a third of
a second, so with
detector_required: truedetection requests answered503until a restart. It now retries, at most a minute apart, until the models load, and logs atinfowhen they do. - Req's
:rangeoption inreq_optionsset arangeheader even on sources that droprangeheaders, which is every source except one withinternal_cache: :disabledthat isn't immutable. The origin then sent part of the image, which was processed as the whole original. These sources now drop the:rangeoption too. - A watermark whose stored copy in the input cache was corrupt made every
request using it fail with
415until the copy was evicted. The copy is now discarded after the first failure, and the next request fetches the watermark again. - An HTTP source URL whose query has a space or a non-ASCII character, such
as
?a b, is fetched with those characters percent-encoded. Before, the request failed with502. png-optionswithbitdepth:2orbitdepth:4wrote wrong gray levels, such as gray 150 coming back as 102 at 4 bits. Abitdepthof1,2, or4on a result with alpha or color failed with500. Grayscale results without alpha now keep their gray levels, and other results without a palette encode at 8 bits.- A
detect,anchor=smart, oranchor=smart-facecrop whose box covers the whole image, such ascrop=8000,8000/detect=faceon a smaller image, ran the detector or saliency anyway. With a failing detector the response wasn't stored, or failed with500underdetector_required: true. Such a crop now runs no detection. - An HTTP source could run past
fetch_timeoutin two ways. Looking up the host name could take as long as theaddress_resolverallowed. A name that resolved to several addresses made the fetch wait for a response at each address in turn. Fetches now fail with504atfetch_timeout, except that a connection attempt can run past it by up toconnect_timeout. - A FileSystem cache could hold more than
max_size_byteson disk and not bring it back down:- A read of an entry the startup scan hadn't counted yet counted it without evicting. The cache now evicts back under its cap.
- A startup scan that crashed skipped evicting down to a lowered cap. It now evicts as a completed scan does.
- A write during the startup scan that was rejected or failed left its key's existing entry on disk uncounted. A write that replaced such an entry left its old body on disk. Both are now counted or deleted.
- An entry written again after the startup sweep removed its unreadable metadata, as after an upgrade that changes the metadata format, wasn't counted.
- A request that reached
processing_timeoutwhile its source was still downloading failed with500or got no response. It now fails with{:processing, :timeout}, which the Plug answers with503. - A large
pixelateblock size, such aspixelate=2000on a 4000×2667 JPEG, failed with415. It now works. - A
regionwhose width or height came to less than half a pixel, such asregion=0,0,0.3,1, failed with500. It now keeps one pixel. - With
detector_required: true, ananchor=smart-facecrop called the detector before its models were ready, so the built-in detector could download a model during the request. Until the face model is ready, such a crop now uses attention cropping without calling the detector, and the response isn't stored while the face model can still become ready. ImagePipe.Source.S3.ContainerCredentialsaccepted anauth_tokenwith a line break or NUL, and every credential fetch then failed. Such a token is now rejected when the source is configured.- A
regionthat started inside the last pixel column or row, such asregion=2002.6,0,10,10on a 2003-pixel-wide image orregion=99.99pct,0,10,10, failed with400because its origin rounded past the edge. It now starts at the last pixel. Only a region that starts at or beyond the edge is rejected. output=inforeported aresultsize for a URL whose image request fails with422because it exceedsmax_intermediate_pixels, such asw=20000/enlargeon a small image. It now fails with422too, asoutput=blurhashandoutput=lqip-cssalready did.- A request for another variant of a source that was still downloading, such
as
w=50whilew=100was fetching the same image, could fail with504. It waited until the first request's processing, which can start before the download finishes, was done too, and gave up a second after the source'sfetch_timeout. It now waits only for the download. - An SVG source ignored
page, sopage=1on an SVG answered200with the only image, and eachpagevalue stored its own copy. Apagepast0now fails with422, as it does for any other single-page image. - A FileSystem cache that restarted while a write was stuck on disk for more than 25 seconds, as on a hung network mount, kept counting the bytes the response still sent to that write as waiting to be written. Until the next restart, the cache dropped more writes and evicted more entries than it needed to. Those bytes are no longer counted.
- A FileSystem cache that was full and busy with other writes could turn away an original's new expiry time or content identity after checking the original with the origin, and keep the outdated ones. Requests then checked with the origin, or downloaded the changed original, every time until the cache stored the update. The cache now removes the outdated details when it turns an update away.
0.2.0 - 2026-10-13
Added
- SVG sources, when the optional
image_pipe_svgpackage is installed. An SVG renders straight at the size the request needs, so it stays sharp at any size, and a resize can make it larger than its ownwidthandheightwithoutenlarge, up to the input pixel limit, past which the rendered image is enlarged. Regions and crops in pixels measure the SVG at its own size. With noformatand no negotiated format, the result is a PNG. Watermark images can be SVGs too, and render at the size they're drawn. Withoutimage_pipe_svg, SVG sources still fail with415. - The
:svgoption configures SVG sources::font_dirsand:fallback_familyfor the fonts SVG text renders in (no system fonts are used), and:max_bytes,:max_render_work, and:max_render_pixelsto limit the work an SVG can cause. - A
[:source, :svg_render]span wraps rendering an SVG original or watermark, with its intrinsic and rendered sizes and the estimated work, and names the limit that rejected an SVG. The default Logger logs it. cacheandinput_cachedelete metadata files they can't read, such as ones another ImagePipe version wrote, and their bodies, once the files are more than an hour old. The[:cache, :sweep]event reports them as:unreadable.[:cache, :stage]reports writes the cache drops to keep up with its storage as:stage_dropped, for processed images and originals, and carries:pool. The default Logger logs dropped writes at:warning.[:cache, :coordination]carriesreason: :deadlinewhen a request stops waiting for another request's image because it wasn't stored in time.- The
image_pipe.requestspan carries arequest_idattribute when Logger metadata has a:request_id, such as the onePlug.RequestIdsets. - The
[:cache, :lookup],[:cache, :write], and[:cache, :stage]telemetry events carry:cache_key, the hash of the entry's cache key, and[:cache, :lookup]carries:entry, which says whether it read a processed response or a source record. The tracer adds them to their spans and span events. - The
[:cache, :write]stop event carries:estimated_cost_us, the work the cache estimates it took to make a processed image. The tracer adds it to the span. - The
[:cache, :write]stop event carries:bytes, the size of the stored body, so dividing it by the span's duration gives a rough write speed for the cache storage.[:cache, :lookup]carries:byteson a hit for a processed response. The tracer adds:bytesto both spans, and the default Logger logs it. - A
[:source, :decode_open]span wraps each time libvips opens the original, so a trace shows how often a request opens it. The default Logger doesn't log it.
Changed
- With
image_pipe_svginstalled, cache keys and ETags include the resvg version, the fonts, and the fallback family, so every cached image misses once after installing it, and again after changing the fonts or fallback family, or upgradingimage_pipe_svgto a version that renders SVGs differently. - Breaking: An HTTP source's
allowed_hostsentry without a port allows only the scheme's default port, 80 or 443, including for redirect targets. Before, any port on a listed host was reachable. Add an entry with the port, such as"assets.example.com:8443", for an origin on another port. Abase_urlwith a non-default port needs that port inallowed_hostswhen you set the list. A malformed entry, such as"assets.example.com:https", now fails at startup. - A response to a URL with
expiresdropsstale-while-revalidateandstale-if-error, including ones the host sets, because caches can't use them alongside itsmust-revalidate. - Processed images and originals cached by an earlier version are not reused, because the cache key changed and entry files are named differently. The cache refills as requests come in, and deletes the old files as leftover files.
- Requests for PNG and other sources that need no shrink-on-load open the source once instead of twice, which saves about 0.3 ms per request.
- A custom detector's
identity/1,available?/1, andready?/1receive only:classes, asdetect/2already did. They no longer see the mount's other configuration. - Breaking: The source identifiers a custom source adapter receives are
ImagePipe.Source.Path,ImagePipe.Source.URL, andImagePipe.Source.Object. ReplaceImagePipe.Plan.Source.Path,.URL, and.Objectin your adapter. - Breaking: A source's
internal_cachetakes:enabledor:disabled, and defaults to:enabled. Replaceinternal_cache: :auto, which meant the same as:enabled. - Breaking:
c:ImagePipe.Source.S3.CredentialProvider.fetch_credentials/2takes the bucket and the provider's options. Change your provider'sfetch_credentials/3tofetch_credentials/2by dropping the third argument, which was always[]. - Breaking:
cacheandinput_cachetake the file system cache's options directly. Replacecache: {ImagePipe.Cache.FileSystem, root: "..."}withcache: [root: "..."], and the same forinput_cache. The server's[cache]configuration is unchanged. - Breaking: The tracer now creates OpenTelemetry spans as they happen, and
needs the OpenTelemetry SDK in your application. Remove the
exporter:option fromImagePipe.Telemetry.attach_tracer/1and theImagePipe.Telemetry.Trace.OtelIdGeneratorid_generatorsetting from your:opentelemetryconfiguration. To print spans without a backend, use the SDK's stdout exporter. Span and trace IDs now come from the SDK, and each span reaches the SDK when it ends, not when the request finishes. - A request's spans join the trace of the span that is current when the
request starts, such as one from your Phoenix or Bandit instrumentation.
This includes
ImagePipe.run/4in an instrumented job. Withextract_inbound: true, the inboundtraceparentapplies only when no span is current. - Spans your own code opens inside ImagePipe's processes, such as in a custom source or detector, nest under the request's spans, with or without the tracer attached.
- A request that continues an inbound trace follows the caller's sampled flag under the SDK's default parent-based sampler. It was always recorded before.
- Breaking:
[:cache, :eviction, :stop]reports the entries a write evicts, withtrigger: :admission, and the startup scan's batches withtrigger: :startup. Update handlers that matchtrigger: :reconcile. - Responses no longer wait for the cache's disk. A processed image is stored
in the background after it's sent, and a downloaded original while the
request processes it. The
[:cache, :write]span can end after the request. - A request waiting for another request's image or original stops waiting when the image or original isn't stored 2 seconds after it was sent or downloaded.
- When the disk can't keep up, writes are dropped, and unfinished writes
count against
max_size_bytes. An entry larger than a twentieth ofmax_size_bytes, or 256 MiB, is never stored (see writing entries). trimis faster on images under a megapixel: 2 ms instead of 6.5 ms on a 256×256 image.- Breaking:
cacheandinput_cacheneedmax_size_bytes, and an ImagePipe instance (seeImagePipe.child_spec/1). Addmax_size_bytesto each cache, and to the options ofImagePipe.Cache.FileSystem.get/2, and move a configuration with a cache fromImagePipe.Plugoptions or anImagePipe.run/4call into an instance.run/4with an instance name can't setcacheorinput_cacheper call. - Breaking: Each
rootofcacheandinput_cacheserves one node. An instance writes a.image-pipe-ownerfile into each cache'sroot. While another node keeps that file updated, the instance logs an error and runs without that cache, and it takes therootover once the other node stops. Give each replica its own cache directories, such as its own volume. When a node is killed, a node on another host that starts after the kill takes over itsrootabout 30 seconds after starting. cacheandinput_cachekeep the entries that are requested often and took the most work to make for their size. For a processed image, that work is estimated from the request instead of timed (see work to make an entry). On startup a cache reads only file names, so a large cache is ready sooner.
Removed
- Breaking:
ImagePipe.Transform.Detector.Composite.new/1anddefault/0. A detector is configured as a module, so a composite struct could not be configured. ConfigureImagePipe.Transform.Detector.Compositeitself to get the default face and object detectors. - Breaking: Bounded caches keep request counts in memory only. Remove
node_id,state_dir,flush_interval,cleanup_interval, andstate_ttlfromcacheandinput_cache. After a restart, a bounded cache keeps its entries and learns again which are requested most. You can delete the.cache_statedirectory under each bounded cache'sroot. - The
[:cache, :warm_start],[:cache, :flush, :stop], and[:cache, :cleanup, :stop]telemetry events. - Breaking: The
eviction_victim_limitandreconcile_intervaloptions ofcacheandinput_cache. Remove them from your configuration. A write now evicts every entry it needs before it's stored, so the stored bodies stay undermax_size_bytesonce the startup scan has run, and the cache no longer runs a background eviction pass every minute. - Breaking: The
window_ratio,sketch_depth,sketch_width,aging_sample_size,doorkeeper_cardinality,doorkeeper_fpr, andpath_prefixoptions ofcacheandinput_cache. Remove them from your configuration. The cache no longer reads or cleans up the old entries under apath_prefixdirectory, so delete that directory. - Breaking: The
rescan_intervaloption ofcacheandinput_cache, and the[:cache, :rescan]telemetry event. Removerescan_intervalfrom your configuration. A cache deletes leftover files after its startup scan and then every 24 hours. [:cache, :stage]no longer reportscache: :stage_cleanup_error. Discarding a staged entry doesn't fail.- Breaking:
ImagePipe.Telemetry.Trace.LogExporter,ImagePipe.Telemetry.Trace.OpenTelemetryExporter,ImagePipe.Telemetry.Trace.OtelIdGenerator,ImagePipe.Telemetry.Trace.Span,ImagePipe.Telemetry.Trace.Context, and theImagePipe.Telemetry.Trace.Exporterbehaviour.
Fixed
[:cache, :write]reports a write error instead ofcache: :writewhen the cache's process isn't running, since nothing was stored.- A
cropplaced withfocuson an image with an EXIF orientation other than 1 or 5 could land one pixel off from the same crop on an untagged image, when the crop's position fell on a half pixel. - With
max-bytes, a quality search that ran out of encode attempts shipped the lowest quality, even when it had already encoded a higher one that fit the budget. It now ships the highest encoded quality that fits. When only the lowest quality fits, theX-ImagePipe-AQ-Outcomedebug header and the[:encode, :search]telemetry event reporthitinstead ofbest_effort. - With
allow_originset, cross-origin scripts can send conditional requests such asIf-None-Match, and can read response headers such asETagandContent-Disposition. Preflight requests now getAccess-Control-Allow-Headers: *, and responses getAccess-Control-Expose-Headers: *. - A comma inside a quoted
Acceptparameter, as inimage/webp;x="a,b";q=0, no longer splits the entry. The split dropped theq=0, so ImagePipe could choose a format the client had excluded. input_cacherejectsmax_body_bytesinstead of ignoring it, and configuration fails with an unknown-option error. Remove the option frominput_cache.- Concurrent fetches from an HTTP source (
ImagePipe.Source.HTTP) just after startup could exit with:noproc. They now wait for the connection pool to start. - A CMYK or other non-RGB source with an embedded color profile and more
pixels than
max_intermediate_pixelsfails with422, like other oversized images, instead of415. - The source cache stores and reuses originals whose
Cache-Controlhas a qualifiedprivate="..."orno-cache="..."naming only headers ImagePipe doesn't keep, such asprivate="Set-Cookie". They were treated as unqualifiedprivate(never stored) orno-cache(revalidated on every request).
0.1.0 - 2026-10-09
Added
First release of ImagePipe, with image processing powered by elixir-image/image, Vix, and libvips.
- Serve transformed images on demand from Phoenix or any Plug application
through
ImagePipe.Plug. Reuse the same processing plans and configuration for uploads, files, and background jobs through the Elixir API. - Read originals from local files, HTTP(S), S3-compatible storage, or custom source adapters.
- Compose resizing, cropping, orientation, effects, and watermarks in explicit processing groups, with optional face and object detection, or your own detector.
- Define reusable presets and request defaults in your configuration, statically or through a host preset lookup backed by a database or cache.
- Encode JPEG, PNG, WebP, and AVIF, with format negotiation, quality controls, byte budgets, and color-profile policies. Automatic quality gives each image the lowest quality that still looks as good as a target you choose. Generate BlurHash, LQIP CSS, and image-info JSON.
- Configure input and output caches, conditional HTTP responses, streamed delivery, request safety limits, and processing concurrency. Observe requests through telemetry, logging, and tracing.