Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ Query params take precedence when both styles are combined.
| `contrast` | -100 to 100 | Contrast adjustment |
| `blur` | float (sigma) | Gaussian blur |
| `wm` | URL | Watermark image URL |
| `bg` | hex color, e.g. `ffffff` (default) | Background used to flatten transparency when the output is JPEG, which cannot store it. Formats with alpha (PNG, WebP, AVIF, JXL) keep the transparency. `best` never picks JPEG for a transparent image while another allowed format can store it |
| `seek` | `5.0`, `0.5r`, `auto` (default: `0`) | Video seek: absolute seconds, relative ratio, or auto (middle frame) |
| `gif_anim` | `all`, `N`, `N-M`, `-N` | Animated GIF: output all frames, apply transforms starting at frame N, to frame range N-M, or to last N frames |
| `gif_af` | `true`, `1` | GIF all-frames: output all frames, not just the `gif_anim` range; style transforms apply only to frames in range, geometric transforms (resize, rotate, flip) apply to all frames |
Expand Down
49 changes: 49 additions & 0 deletions src/modules/proxy/dto/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ pub struct TransformParams {
pub wm_scale: Option<f32>,
/// Text watermark content (URL-safe string). Takes lower priority than `wm`.
pub wmt: Option<String>,
/// Background hex color without `#`, e.g. `ffffff` (default), used to flatten
/// transparency when the output format cannot store it (JPEG).
pub bg: Option<String>,
/// Text watermark hex color without `#`, e.g. `ff0000` (default `000000`).
pub wmt_color: Option<String>,
/// Text watermark font size in pixels (default 24).
Expand Down Expand Up @@ -209,6 +212,9 @@ impl TransformParams {
if other.wmt.is_some() {
self.wmt = other.wmt;
}
if other.bg.is_some() {
self.bg = other.bg;
}
if other.wmt_color.is_some() {
self.wmt_color = other.wmt_color;
}
Expand Down Expand Up @@ -305,6 +311,9 @@ impl TransformParams {
if let Some(v) = &self.wmt {
parts.push(format!("wmt={v}"));
}
if let Some(v) = &self.bg {
parts.push(format!("bg={v}"));
}
if let Some(v) = &self.wmt_color {
parts.push(format!("wmt_color={v}"));
}
Expand Down Expand Up @@ -498,6 +507,11 @@ fn parse_options(opts: &str) -> Result<TransformParams, ProxyError> {
p.wmt = Some(val.to_string());
continue;
}
// bg:ffffff
if let Some(val) = token.strip_prefix("bg:") {
p.bg = Some(val.to_string());
continue;
}
// wmt_color:ff0000
if let Some(val) = token.strip_prefix("wmt_color:") {
p.wmt_color = Some(val.to_string());
Expand Down Expand Up @@ -681,6 +695,7 @@ fn parse_options(opts: &str) -> Result<TransformParams, ProxyError> {
)
}
"wmt" => p.wmt = Some(val.to_string()),
"bg" => p.bg = Some(val.to_string()),
"wmt_color" => p.wmt_color = Some(val.to_string()),
"wmt_size" => {
p.wmt_size = Some(
Expand Down Expand Up @@ -789,6 +804,9 @@ pub fn from_query(
if let Some(v) = query.get("wmt") {
p.wmt = Some(v.clone());
}
if let Some(v) = query.get("bg") {
p.bg = Some(v.clone());
}
if let Some(v) = query.get("wmt_color") {
p.wmt_color = Some(v.clone());
}
Expand Down Expand Up @@ -1910,4 +1928,35 @@ mod tests {
"got: {result:?}"
);
}

#[test]
fn test_bg_parsed_from_path_token() {
let (p, _) =
TransformParams::from_path("300x200,bg:ff8800/https://example.com/img.png").unwrap();
assert_eq!(p.bg, Some("ff8800".to_string()));
}

#[test]
fn test_bg_in_canonical_string() {
let params = TransformParams {
bg: Some("ff8800".to_string()),
..Default::default()
};
// The cache key must differ per background.
assert!(
params
.canonical_string("https://example.com/i.png")
.contains("bg=ff8800")
);
}

#[test]
fn test_bg_counts_as_no_transform_by_itself() {
// bg only changes how transparency is flattened, so alone it is not a transform.
let params = TransformParams {
bg: Some("ff8800".to_string()),
..Default::default()
};
assert!(!params.has_transforms());
}
}
110 changes: 110 additions & 0 deletions src/modules/transform/ops/alpha.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
use crate::common::errors::ProxyError;
use image::{DynamicImage, Rgb, RgbImage};

/// Background used to flatten transparency when a format cannot store it.
pub const DEFAULT_BACKGROUND: [u8; 3] = [255, 255, 255];

/// Returns true when at least one pixel is not fully opaque.
///
/// A color type with an alpha channel is not enough: most RGBA PNGs are fully
/// opaque and can safely go to formats without alpha.
pub fn has_transparency(img: &DynamicImage) -> bool {
if !img.color().has_alpha() {
return false;
}
img.to_rgba8().pixels().any(|p| p[3] < 255)
}

/// Composites the image over a solid background and drops the alpha channel.
///
/// Encoders for formats without alpha (JPEG) discard the channel, which exposes
/// the color stored under transparent pixels - usually black. Blending first
/// makes the result match what a viewer shows.
pub fn flatten(img: &DynamicImage, bg: [u8; 3]) -> DynamicImage {
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
let mut out = RgbImage::new(w, h);
for (x, y, p) in rgba.enumerate_pixels() {
let a = p[3] as u32;
let blend = |fg: u8, bg: u8| ((fg as u32 * a + bg as u32 * (255 - a) + 127) / 255) as u8;
out.put_pixel(
x,
y,
Rgb([blend(p[0], bg[0]), blend(p[1], bg[1]), blend(p[2], bg[2])]),
);
}
DynamicImage::ImageRgb8(out)
}

/// Parses a `RRGGBB` hex color, with or without a leading `#`.
pub fn parse_background(hex: &str) -> Result<[u8; 3], ProxyError> {
let invalid = || ProxyError::InvalidParams(format!("invalid bg: {hex}"));
let digits = hex.trim_start_matches('#');
if digits.len() != 6 || !digits.is_ascii() {
return Err(invalid());
}
let channel =
|range: std::ops::Range<usize>| u8::from_str_radix(&digits[range], 16).map_err(|_| invalid());
Ok([channel(0..2)?, channel(2..4)?, channel(4..6)?])
}

#[cfg(test)]
mod tests {
use super::*;
use image::{ImageBuffer, Rgba};

fn rgba(pixels: &[[u8; 4]]) -> DynamicImage {
DynamicImage::ImageRgba8(ImageBuffer::from_fn(pixels.len() as u32, 1, |x, _| {
Rgba(pixels[x as usize])
}))
}

#[test]
fn test_opaque_rgba_has_no_transparency() {
assert!(!has_transparency(&rgba(&[[1, 2, 3, 255], [4, 5, 6, 255]])));
}

#[test]
fn test_rgb_has_no_transparency() {
assert!(!has_transparency(&DynamicImage::new_rgb8(2, 2)));
}

#[test]
fn test_any_translucent_pixel_counts() {
assert!(has_transparency(&rgba(&[[1, 2, 3, 255], [0, 0, 0, 0]])));
assert!(has_transparency(&rgba(&[[1, 2, 3, 254]])));
}

#[test]
fn test_flatten_transparent_pixel_takes_background() {
let out = flatten(&rgba(&[[0, 0, 0, 0]]), [10, 20, 30]).to_rgb8();
assert_eq!(out.get_pixel(0, 0).0, [10, 20, 30]);
}

#[test]
fn test_flatten_opaque_pixel_is_unchanged() {
let out = flatten(&rgba(&[[200, 100, 50, 255]]), [255, 255, 255]).to_rgb8();
assert_eq!(out.get_pixel(0, 0).0, [200, 100, 50]);
}

#[test]
fn test_flatten_blends_partial_alpha() {
// Half black over white lands mid-way.
let out = flatten(&rgba(&[[0, 0, 0, 128]]), [255, 255, 255]).to_rgb8();
let v = out.get_pixel(0, 0)[0];
assert!((126..=128).contains(&v), "got {v}");
}

#[test]
fn test_parse_background() {
assert_eq!(parse_background("ff8000").unwrap(), [255, 128, 0]);
assert_eq!(parse_background("#FF8000").unwrap(), [255, 128, 0]);
}

#[test]
fn test_parse_background_rejects_bad_input() {
for bad in ["", "fff", "gggggg", "ff80001", "ééé"] {
assert!(parse_background(bad).is_err(), "{bad}");
}
}
}
Loading
Loading