ImageMagick-based image processing module.
Handles image operations using ImageMagick command-line tools:
identify- Extract image metadata (dimensions, format)convert/magick- Resize and format conversion
This replaces Vix with ImageMagick, which is more widely trusted and has better long-term support.
Summary
Functions
Whether file_path is an animation, and how long: {:ok, %{frames:, duration_ms:}}
with frames of 1 for a still picture. The duration is the sum of the frames'
delays. {:error, reason} for something ImageMagick cannot read.
The limits on an animation that is resized: %{frames:, pixels:} (frames times pixels).
Extract both width and height from an image file.
Get the height of an image file using ImageMagick identify.
Get the width of an image file using ImageMagick identify.
The size of the first frame after EXIF auto-orientation — the frame an edit's percentages refer to — and the number of frames in the file.
A small, upright JPEG of the first frame of input_path: at most max_edge
pixels on its longest side, never enlarged, with the EXIF orientation applied
and any transparency flattened onto white.
Renders an image edit (PhoenixKit.Modules.Storage.ImageEdit) of the
first frame of input_path into output_path. size is the input's size
after auto-orientation (oriented_info/1).
Resize an image to fit within specified dimensions.
Resize and center-crop an image to exact dimensions.
Crops an image to width x height around a focal point: the largest window
of that shape that fits in the photo, centered on {x, y} (fractions of the
photo as it is displayed, 0..1) as far as the edges allow, then scaled to the
size. A subject near an edge stays in the frame where resize_and_crop_center/5
would cut it off. Like it, never enlarges: a box bigger than the photo shrinks,
keeping its shape.
Re-encodes an image to known-good bytes, discarding everything else.
Functions
@spec animation_info(String.t()) :: {:ok, %{frames: pos_integer(), duration_ms: non_neg_integer()}} | {:error, String.t()}
Whether file_path is an animation, and how long: {:ok, %{frames:, duration_ms:}}
with frames of 1 for a still picture. The duration is the sum of the frames'
delays. {:error, reason} for something ImageMagick cannot read.
@spec animation_limits() :: %{frames: pos_integer(), pixels: pos_integer()}
The limits on an animation that is resized: %{frames:, pixels:} (frames times pixels).
Extract both width and height from an image file.
Uses ImageMagick's identify command to extract image dimensions.
Returns:
{:ok, {width, height}}- Dimensions in pixels{:error, reason}- If extraction fails
Get the height of an image file using ImageMagick identify.
Returns the height in pixels or nil if extraction fails.
Get the width of an image file using ImageMagick identify.
Returns the width in pixels or nil if extraction fails.
@spec oriented_info(String.t()) :: {:ok, {pos_integer(), pos_integer(), pos_integer()}} | {:error, String.t()}
The size of the first frame after EXIF auto-orientation — the frame an edit's percentages refer to — and the number of frames in the file.
Returns {:ok, {width, height, frames}} or {:error, reason}.
@spec preview_jpeg(String.t(), String.t(), pos_integer()) :: {:ok, String.t()} | {:error, String.t()}
A small, upright JPEG of the first frame of input_path: at most max_edge
pixels on its longest side, never enlarged, with the EXIF orientation applied
and any transparency flattened onto white.
For a tool that cannot decode the original itself. libvips as shipped with the
vix package has no HEVC decoder, so it cannot read an iPhone's HEIC photo,
which ImageMagick can; the subject finder asks for this preview then. The
decoder is pinned and the pixel and resource limits are those of every other
call here. Returns {:ok, output_path} or {:error, reason}.
@spec render_edit(String.t(), String.t(), map() | nil, {pos_integer(), pos_integer()}) :: :ok | {:error, String.t()}
Renders an image edit (PhoenixKit.Modules.Storage.ImageEdit) of the
first frame of input_path into output_path. size is the input's size
after auto-orientation (oriented_info/1).
Resize an image to fit within specified dimensions.
Maintains aspect ratio by scaling to fit within bounds. Optionally converts format based on output_format parameter.
Parameters:
input_path- Path to input image fileoutput_path- Path to save resized imagewidth- Target width (nil to use original)height- Target height (nil to use original)opts- Additional options:quality- JPEG quality 1-100 (default: 85):format- Output format override (jpg, png, webp, etc):animated- keep every frame of an animated GIF or WebP (default: false, the first frame only, including GIF/WebP output). Each frame is flattened onto the canvas before it is resized, and the result is re-optimised;animation_info/1says whether a file is animated, and an animation overanimation_limits/0is refused.:strip_metadata- leave the camera's metadata (EXIF, XMP, comment) out and apply the orientation to the pixels; the colour profile stays (default: false).resize_and_crop_center/5andresize_and_crop_focus/6take it too.
Returns:
{:ok, output_path}- Success{:error, reason}- If resize fails
Resize and center-crop an image to exact dimensions.
Zooms into the image to fill the target dimensions completely, then center-crops to extract the exact target size. No padding borders - the entire output is filled with the image content.
This is ideal for thumbnails where you want perfect squares (e.g., 150x150) with the image zoomed in and centered, no white/black borders.
The algorithm:
- Resizes image to fill the target box (scales to cover both dimensions)
- Centers the image using gravity
- Crops from center to exact target dimensions
Parameters:
input_path- Path to input image fileoutput_path- Path to save cropped imagewidth- Target width (required)height- Target height (required)opts- Additional options:quality- JPEG quality 1-100 (default: 85):format- Output format override (jpg, png, webp, etc):background- Background color (rarely used, default: "white")
Returns:
{:ok, output_path}- Success{:error, reason}- If processing fails
Crops an image to width x height around a focal point: the largest window
of that shape that fits in the photo, centered on {x, y} (fractions of the
photo as it is displayed, 0..1) as far as the edges allow, then scaled to the
size. A subject near an edge stays in the frame where resize_and_crop_center/5
would cut it off. Like it, never enlarges: a box bigger than the photo shrinks,
keeping its shape.
The photo is auto-oriented first, because the focal point is in displayed
coordinates. Accepts the same :quality, :format and :background options.
Re-encodes an image to known-good bytes, discarding everything else.
For uploads from people you do not trust. The point is not to inspect the file — that is a losing game — but to stop serving the uploader's bytes at all: what ends up stored is this encoder's output, produced by decoding the input and writing a fresh image. A polyglot file (valid GIF, valid JavaScript), an EXIF payload, a trailing ZIP, an embedded colour profile — none of them survive being decoded to pixels and written out again.
Also enforces a maximum edge length, because a 30000×30000 PNG is a decompression bomb whatever its byte size, and rejects anything ImageMagick cannot read as an image regardless of what the upload claimed to be.
Returns {:ok, output_path} or {:error, reason}. Never raises.
⚠️ Nothing in core calls this yet
It is a primitive, not a policy: no upload path in PhoenixKit routes
through it, so adding it did not change what any existing endpoint
stores. A caller that accepts untrusted uploads — a public portal
submission, an avatar from an unauthenticated form — has to invoke it
itself and store the OUTPUT path, discarding the original. Wiring it
into Storage.upload/* wholesale is not a drop-in: it re-encodes, which
is right for images from strangers and wrong for a designer uploading a
master PNG they expect back byte-for-byte.
Options
:max_edge— longest side in pixels (default 2500); larger images are scaled down, never up:quality— output quality (default 82):format— output format (default "jpeg"; use "png" to keep transparency)
Example
ImageProcessor.sanitize(upload.path, dest, format: "png")