Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,12 @@ listed under **Changed** and are the reason this is a minor bump rather than a p
`IMGFORGE_RETURN_ATTACHMENT`, and `IMGFORGE_QUALITY` set the starting value for the matching option, which a URL
still overrides.

- **Smart gravity.** `gravity:sm` hands the window choice to libvips' `smartcrop`, which scores the image for the
region a viewer's eye would settle on. It is the answer when no fixed anchor is right for every image in a
catalogue, and it applies to `crop` and to the implicit crop a `fill` resize performs. imgproxy gates this one
behind its Pro tier.
- **Seven options imgproxy gates behind its Pro tier**, free here. `gravity:sm` hands the window choice to
libvips' `smartcrop`, which scores the image for the region a viewer's eye would settle on — the answer when no
fixed anchor is right for every image in a catalogue — and applies to `crop` and to the implicit crop a `fill`
performs. `crop_aspect_ratio` (`car`) corrects the crop area's shape without moving it. `monochrome` (`mc`),
`duotone` (`dt`) and `colorize` (`col`) recolour the image from its luminance or wash a flat colour over it.
`watermark_size` (`wms`) and `watermark_rotate` (`wmr`) size and turn the watermark explicitly.

- **New processing options.** `extend_aspect_ratio` (`exar`) pads to the requested shape without reaching its size.
`keep_copyright` (`kcr`) carries the EXIF copyright across a metadata strip for JPEG, PNG and WebP output — an
Expand Down
21 changes: 10 additions & 11 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ Measurements below were taken on libvips 8.16.1 (the version the published image

imgproxy gates a number of options behind its Pro tier that imgforge implements for free: `resizing_algorithm`,
`background_alpha`, `watermark_url`, all four `*_options` encoder groups, the `adjust`/`brightness`/`contrast`/
`saturation` family, `page`/`pages`/`disable_animation`, and smart gravity (`gravity:sm`).
`saturation` family, `page`/`pages`/`disable_animation`, smart gravity (`gravity:sm`), `crop_aspect_ratio`, the
`monochrome`/`duotone`/`colorize` tone effects, and `watermark_size`/`watermark_rotate`.

0.18.0 closed the free-tier gap that remained. Every option in imgproxy's free tier is implemented rather than
merely parsed, and every option imgforge parses is applied.
Expand All @@ -30,18 +31,16 @@ request, and each logged when it takes effect:

## imgproxy Pro options imgforge does not implement

Listed so the comparison is honest rather than because they are planned: `autoquality`, `crop_aspect_ratio`,
`objects_position` and the object-detection family, `monochrome`, `duotone`, `colorize`, `gradient`,
`unsharp_masking`, `blur_areas`, `style`, `dpi`, `color_profile`, `hashsum`, `watermark_text`/`_size`/`_rotate`/
`_shadow`, `fallback_image_url`, and the `video_thumbnail_*` family.
Listed so the comparison is honest rather than because they are planned: `autoquality`, `objects_position` and the
object-detection family, `gradient`, `unsharp_masking`, `blur_areas`, `style`, `dpi`, `color_profile`, `hashsum`,
`watermark_text`, `watermark_shadow`, `fallback_image_url`, and the `video_thumbnail_*` family.

Most of these need something libvips does not provide on its own — an object detector, a quality search loop, a CSS
parser. The ones that do not are `crop_aspect_ratio` (pure arithmetic on the crop extents), `monochrome`, `duotone`,
and `colorize` (each a recombination matrix or a blend), and `watermark_size`/`watermark_rotate`. None is hard;
none has been asked for either, which is why they sit here rather than in the code.
Each needs something libvips does not provide on its own — an object detector, a quality search loop, a CSS parser,
a video decoder — or a design decision nobody has had to make yet.

Smart gravity used to be on this list with a note that it would be cheap. It was, and it shipped: `gravity:sm`
hands the window choice to libvips' `smartcrop`, which is the one thing a geometric anchor cannot do.
Everything on this list that was merely *cheap* has now shipped, because "easy and undone" is not a defensible
place for an option to sit: smart gravity (`gravity:sm`), `crop_aspect_ratio`, `monochrome`, `duotone`, `colorize`,
`watermark_size`, and `watermark_rotate` are all implemented and free here.

## Performance

Expand Down
27 changes: 26 additions & 1 deletion doc/5_processing_options.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Unrecognised directive *names* are ignored rather than rejected, so a typo silen
| `min-height` | `mh`, `min_height` | `value` | Floor for result height. Upscales regardless of `enlarge`. |
| `zoom` | `z` | `factor` or `zoom_x:zoom_y` | Multiplies dimensions after resizing. Defaults to `1.0`. |
| `crop` | `c` | `width:height[:gravity]` | Crops before resizing. Values below 1 are a fraction of the source. Gravity positions the window. |
| `crop_aspect_ratio` | `car` | `ratio[:enlarge]` | Corrects the crop area's shape. `0` disables. Defaults to shrinking the long axis. |
| `trim` | `t` | `threshold[:color[:equal_hor[:equal_ver]]]` | Removes a uniform border before cropping and resizing. Ignored for animated sources. |
| `rotate` | `rot` | `0\|90\|180\|270` | Applies fixed rotation. Defaults to `0`. |
| `auto_rotate` | `ar` | `bool` | Honours EXIF orientation (`true` by default). |
Expand All @@ -35,6 +36,9 @@ Unrecognised directive *names* are ignored rather than rejected, so a typo silen
| `blur` | `bl` | `sigma` | Gaussian blur (0 disables). |
| `sharpen` | `sh` | `sigma` | Sharpens edges. |
| `pixelate` | `pix` | `amount` | Pixelation strength. |
| `monochrome` | `mc` | `intensity[:color]` | Recolours from one base colour. Defaults to `0:b3b3b3`. |
| `duotone` | `dt` | `intensity[:shadow[:highlight]]` | Maps the tonal range between two colours. Defaults to `0:000000:ffffff`. |
| `colorize` | `col` | `opacity[:color[:keep_alpha]]` | Washes a flat colour over the image. Defaults to `0:000000:false`. |
| `background` | `bg` | `RRGGBB[AA]` | Canvas colour for extend/padding/flatten. Defaults to transparent unless JPEG output. |
| `background_alpha` | `bga` | `0.0-1.0` | Sets the alpha channel for `background`. |
| `quality` | `q` | `1-100` | Compression quality. Defaults to `85` for lossy formats. |
Expand Down Expand Up @@ -67,6 +71,8 @@ Unrecognised directive *names* are ignored rather than rejected, so a typo silen
| `max_animation_frame_resolution` | `mafr` | `megapixels` | Request-level override of the per-frame resolution ceiling. Requires server opt-in. |
| `watermark` | `wm` | `opacity[:position[:x_offset[:y_offset[:scale]]]]` | Enables watermarking. Requires a watermark asset. |
| `watermark_url` | `wmu` | `base64url(url)` | Fetches watermark per request. Overrides server default path. |
| `watermark_size` | `wms` | `width:height` | Explicit watermark size in pixels, overriding `scale`. Fits rather than stretches; a zero axis is unbounded. |
| `watermark_rotate` | `wmr` | `0\|90\|180\|270` | Rotates the watermark after sizing. |

## Presets

Expand Down Expand Up @@ -306,6 +312,24 @@ Listed earlier under geometry, but keep in mind it also affects the intensity of

Brightness and contrast go through a single pass over the pixels, with contrast applied first. The alpha channel is left alone: brightening it would fade the image in or out rather than lighten it.

### `monochrome`, `duotone`, and `colorize`

Three recolourings that share a shape — derive a colour per pixel, then blend it over the original by an intensity — and differ in how the colour is derived. All three run after `adjust`, `blur`, `sharpen`, and `pixelate`, and before the watermark, which is not part of the image being toned. None of them touches the alpha channel.

- **`monochrome:intensity[:color]`** scales the base colour by each pixel's luminance, so the result keeps the image's tonal structure and loses only its hue. `monochrome:1:0000ff` is a blue-toned photograph, not a blue rectangle.
- **`duotone:intensity[:shadow[:highlight]]`** interpolates between two colours across the tonal range: the darkest pixels reach `shadow`, the brightest reach `highlight`.
- **`colorize:opacity[:color[:keep_alpha]]`** ignores luminance entirely and washes the colour flat over everything. `keep_alpha:true` leaves transparent areas transparent; the default lets the wash reach them, which is what you want when the result is about to be flattened anyway.

An intensity or opacity of `0` is a no-op, which is why it is the default for all three.

imgproxy charges for these.

### `crop_aspect_ratio`

`crop_aspect_ratio:ratio[:enlarge]` corrects the *shape* of the crop area without changing where it sits. By default it shrinks whichever axis is too long, which can never ask for pixels the source does not have; `enlarge:true` grows the short axis instead, and the result is still clamped to the image. A ratio of `0` disables the correction.

Useful when the crop size comes from somewhere that does not know the target shape — a fixed 300×300 editorial crop that has to become a 16:9 hero, say.

## Animation and multi-page sources

An animated GIF or WebP, or a multi-page PDF or TIFF, is read as many frames and every frame goes through the whole pipeline independently — resize, crop, rotate, pad, effects — before the frames are stacked back together and the encoder is told where they divide. Rotating an animation by 90° therefore works, which it cannot when the stacked frames are treated as one tall image.
Expand All @@ -321,7 +345,8 @@ An animated GIF or WebP, or a multi-page PDF or TIFF, is read as many frames and

1. Add `watermark:<opacity>[:<position>[:<x_offset>[:<y_offset>[:<scale>]]]]` to enable the overlay. Opacity ranges from `0.0` (invisible) to `1.0` (solid). Position accepts the gravity anchors (`ce`, `soea`, ...) plus `re`, which tiles the watermark across the whole image. Offsets follow the same absolute-or-fractional rule as gravity offsets, and unlike a crop they may push part of the watermark off the edge. `scale` sets the watermark's width as a fraction of the result; imgforge defaults to `0.25`, where imgproxy leaves an unscaled watermark at its natural pixel size.
2. Supply the watermark image via `watermark_url:<base64url>` or configure `IMGFORGE_WATERMARK_PATH` on the server (see [Configuration](3_configuration.md) for details). When both are present, the URL value wins.
3. Watermarks render after resizing, padding, and effects. Oversized or missing watermark assets fail the request with `400 Bad Request`.
3. `watermark_size:width:height` sets an explicit pixel size, overriding `scale`. The watermark is *fitted* to that box and never distorted, matching imgproxy — a 100x50 logo asked for `wms:100:100` comes back 100x50, with whichever axis binds deciding the scale. A zero axis leaves that side unbounded. Unlike `padding`, the size is not scaled by `dpr`; the watermark's *offsets* are, so its inset from the edge stays visually constant on a high-density request. `watermark_rotate` turns it by a right angle after sizing, so the requested size describes the watermark itself rather than its bounding box once turned. imgproxy charges for both.
4. Watermarks render after resizing, padding, and effects. Oversized or missing watermark assets fail the request with `400 Bad Request`.

## Cache control & concurrency

Expand Down
147 changes: 146 additions & 1 deletion src/processing/options/effects.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
//! Pixel-effect options: colour adjustment, zoom and watermarking.

use super::error::{arg, parse_float, parse_integer, parse_positive_f32, OptionParseError};
use super::error::{arg, parse_float, parse_integer, parse_positive_f32, parse_unit_f32, OptionParseError};
use super::geometry::GravityType;
use crate::processing::utils::{parse_boolean, parse_hex_color};

/// Represents the parameters for colour adjustment.
#[derive(Debug, Clone, Copy, PartialEq)]
Expand Down Expand Up @@ -185,3 +186,147 @@ impl Watermark {
})
}
}

/// Recolouring an image from a single base colour.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Monochrome {
/// How much of the effect to apply, 0 to 1.
pub intensity: f64,
/// The colour a fully lit pixel becomes.
pub color: [u8; 4],
}

impl Default for Monochrome {
fn default() -> Self {
Self {
intensity: 0.0,
// imgproxy's default base, a neutral mid grey.
color: [0xb3, 0xb3, 0xb3, 255],
}
}
}

impl Monochrome {
/// Parses `intensity[:color]`.
pub fn parse(args: &[String]) -> Result<Self, OptionParseError> {
let Some(intensity) = arg(args, 0) else {
return Err(OptionParseError::invalid("monochrome option requires an intensity"));
};

Ok(Self {
intensity: f64::from(parse_unit_f32(intensity, "monochrome intensity")?),
color: match arg(args, 1) {
Some(value) => parse_hex_color(value).map_err(OptionParseError::Color)?,
None => Self::default().color,
},
})
}
}

/// Mapping the tonal range between two colours.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Duotone {
pub intensity: f64,
/// The colour the darkest pixels become.
pub shadow: [u8; 4],
/// The colour the brightest pixels become.
pub highlight: [u8; 4],
}

impl Default for Duotone {
fn default() -> Self {
Self {
intensity: 0.0,
shadow: [0, 0, 0, 255],
highlight: [255, 255, 255, 255],
}
}
}

impl Duotone {
/// Parses `intensity[:shadow_color[:highlight_color]]`.
pub fn parse(args: &[String]) -> Result<Self, OptionParseError> {
let Some(intensity) = arg(args, 0) else {
return Err(OptionParseError::invalid("duotone option requires an intensity"));
};
let defaults = Self::default();

Ok(Self {
intensity: f64::from(parse_unit_f32(intensity, "duotone intensity")?),
shadow: match arg(args, 1) {
Some(value) => parse_hex_color(value).map_err(OptionParseError::Color)?,
None => defaults.shadow,
},
highlight: match arg(args, 2) {
Some(value) => parse_hex_color(value).map_err(OptionParseError::Color)?,
None => defaults.highlight,
},
})
}
}

/// Washing a flat colour over the image.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Colorize {
pub opacity: f64,
pub color: [u8; 4],
/// Whether the transparent parts stay transparent.
pub keep_alpha: bool,
}

impl Default for Colorize {
fn default() -> Self {
Self {
opacity: 0.0,
color: [0, 0, 0, 255],
keep_alpha: false,
}
}
}

impl Colorize {
/// Parses `opacity[:color[:keep_alpha]]`.
pub fn parse(args: &[String]) -> Result<Self, OptionParseError> {
let Some(opacity) = arg(args, 0) else {
return Err(OptionParseError::invalid("colorize option requires an opacity"));
};

Ok(Self {
opacity: f64::from(parse_unit_f32(opacity, "colorize opacity")?),
color: match arg(args, 1) {
Some(value) => parse_hex_color(value).map_err(OptionParseError::Color)?,
None => Self::default().color,
},
keep_alpha: arg(args, 2).map(parse_boolean).unwrap_or(false),
})
}
}

/// An explicit size for the watermark, in place of the `scale` fraction.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct WatermarkSize {
pub width: u32,
pub height: u32,
}
Comment on lines +307 to +310

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor watermark_size's enlarge argument

Imgproxy-compatible watermark_size URLs include a third enlarge argument, but this type has nowhere to store it and parse silently ignores it. Consequently, both wms:100:100:false and wms:100:100:true follow the unconditional scaling in size_watermark, enlarging a smaller logo even when the URL forbids it and producing different output for migrated URLs. Preserve the flag and cap the scale at the watermark's natural size when it is false.

AGENTS.md reference: AGENTS.md:L1-L2

Useful? React with 👍 / 👎.


impl WatermarkSize {
/// Parses `width:height`, where a zero axis is derived from the other.
pub fn parse(args: &[String]) -> Result<Self, OptionParseError> {
let width = match arg(args, 0) {
Some(value) => parse_integer(value, "watermark width")?,
None => 0,
};
let height = match arg(args, 1) {
Some(value) => parse_integer(value, "watermark height")?,
None => 0,
};

if width == 0 && height == 0 {
return Err(OptionParseError::invalid(
"watermark_size requires at least one non-zero dimension",
));
}

Ok(Self { width, height })
}
}
Loading
Loading