PhoenixKit.Modules.Storage.ImageProcessor (phoenix_kit v2.62.2)

Copy Markdown View Source

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

animation_info(file_path)

@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.

animation_limits()

@spec animation_limits() :: %{frames: pos_integer(), pixels: pos_integer()}

The limits on an animation that is resized: %{frames:, pixels:} (frames times pixels).

extract_dimensions(file_path)

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_height(file_path)

Get the height of an image file using ImageMagick identify.

Returns the height in pixels or nil if extraction fails.

get_width(file_path)

Get the width of an image file using ImageMagick identify.

Returns the width in pixels or nil if extraction fails.

oriented_info(file_path)

@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}.

preview_jpeg(input_path, output_path, max_edge \\ 512)

@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}.

render_edit(input_path, output_path, edit, size)

@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(input_path, output_path, width, height, opts \\ [])

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 file
  • output_path - Path to save resized image
  • width - 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/1 says whether a file is animated, and an animation over animation_limits/0 is 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/5 and resize_and_crop_focus/6 take it too.

Returns:

  • {:ok, output_path} - Success
  • {:error, reason} - If resize fails

resize_and_crop_center(input_path, output_path, width, height, opts \\ [])

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:

  1. Resizes image to fill the target box (scales to cover both dimensions)
  2. Centers the image using gravity
  3. Crops from center to exact target dimensions

Parameters:

  • input_path - Path to input image file
  • output_path - Path to save cropped image
  • width - 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

resize_and_crop_focus(input_path, output_path, width, height, focal, opts \\ [])

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.

sanitize(input_path, output_path, opts \\ [])

@spec sanitize(String.t(), String.t(), keyword()) ::
  {:ok, String.t()} | {:error, String.t()}

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")