Files
fluxer/fluxer_app/rust/libfluxcore/src/rgba.rs
T

477 lines
14 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-or-later
const MAX_TRANSFORM_PIXELS: u64 = 200_000_000;
const RGBA_BYTES_PER_PIXEL: usize = 4;
const RGBA_RESULT_HEADER_BYTES: usize = 8;
const NULL_U32: u32 = u32::MAX;
#[derive(Clone, Copy, Debug)]
pub struct TransformRequest {
pub src_width: u32,
pub src_height: u32,
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
pub rotation_deg: u32,
pub resize_width: u32,
pub resize_height: u32,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TransformError {
InvalidDimensions,
InvalidRgbaLength,
EmptyCrop,
EmptyTarget,
ImageTooLarge,
OutOfMemory,
}
impl TransformError {
pub fn message(self) -> &'static str {
match self {
Self::InvalidDimensions => "invalid RGBA dimensions",
Self::InvalidRgbaLength => "RGBA input length does not match dimensions",
Self::EmptyCrop => "Crop area is empty",
Self::EmptyTarget => "Target dimensions are empty",
Self::ImageTooLarge => "Image is too large to crop",
Self::OutOfMemory => "out of memory",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Crop {
x: u32,
y: u32,
width: u32,
height: u32,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct OutputGeometry {
crop: Crop,
rotation: u32,
base_width: u32,
base_height: u32,
target_width: u32,
target_height: u32,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Point {
x: usize,
y: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct PixelLayout {
src_width: usize,
dst_width: usize,
}
pub fn crop_rotate_rgba_alloc(
input: &[u8],
request: TransformRequest,
) -> Result<Vec<u8>, TransformError> {
let geometry = output_geometry(request)?;
let expected_len = rgba_byte_len(request.src_width, request.src_height, 0)?;
if input.len() != expected_len {
return Err(TransformError::InvalidRgbaLength);
}
let output_len = rgba_byte_len(
geometry.target_width,
geometry.target_height,
RGBA_RESULT_HEADER_BYTES,
)?;
let mut output = try_zeroed_vec(output_len)?;
write_u32_le(&mut output, 0, geometry.target_width);
write_u32_le(&mut output, 4, geometry.target_height);
let dst = &mut output[RGBA_RESULT_HEADER_BYTES..];
if geometry.target_width == geometry.base_width
&& geometry.target_height == geometry.base_height
{
copy_rotated_without_resize(input, dst, request, geometry);
} else {
copy_rotated_with_nearest_resize(input, dst, request, geometry);
}
Ok(output)
}
fn output_geometry(request: TransformRequest) -> Result<OutputGeometry, TransformError> {
if request.src_width == 0 || request.src_height == 0 {
return Err(TransformError::InvalidDimensions);
}
let crop = clamped_crop(
request.src_width,
request.src_height,
request.x,
request.y,
request.width,
request.height,
);
if crop.width == 0 || crop.height == 0 {
return Err(TransformError::EmptyCrop);
}
let rotation = quarter_turn_rotation(request.rotation_deg);
let (base_width, base_height) = if rotation == 90 || rotation == 270 {
(crop.height, crop.width)
} else {
(crop.width, crop.height)
};
let target_width = if has_resize(request.resize_width) {
request.resize_width
} else {
base_width
};
let target_height = if has_resize(request.resize_height) {
request.resize_height
} else {
base_height
};
if target_width == 0 || target_height == 0 {
return Err(TransformError::EmptyTarget);
}
rgba_byte_len(target_width, target_height, RGBA_RESULT_HEADER_BYTES)?;
Ok(OutputGeometry {
crop,
rotation,
base_width,
base_height,
target_width,
target_height,
})
}
fn clamped_crop(src_width: u32, src_height: u32, x: u32, y: u32, width: u32, height: u32) -> Crop {
let crop_x = x.min(src_width);
let crop_y = y.min(src_height);
Crop {
x: crop_x,
y: crop_y,
width: width.min(src_width - crop_x),
height: height.min(src_height - crop_y),
}
}
fn rgba_byte_len(width: u32, height: u32, extra_bytes: usize) -> Result<usize, TransformError> {
let pixels = u64::from(width) * u64::from(height);
if pixels > MAX_TRANSFORM_PIXELS {
return Err(TransformError::ImageTooLarge);
}
let byte_len = pixels
.checked_mul(RGBA_BYTES_PER_PIXEL as u64)
.and_then(|len| len.checked_add(extra_bytes as u64))
.ok_or(TransformError::ImageTooLarge)?;
usize::try_from(byte_len).map_err(|_| TransformError::ImageTooLarge)
}
fn try_zeroed_vec(len: usize) -> Result<Vec<u8>, TransformError> {
let mut output = Vec::new();
output
.try_reserve_exact(len)
.map_err(|_| TransformError::OutOfMemory)?;
output.resize(len, 0);
Ok(output)
}
fn normalized_rotation(rotation_deg: u32) -> u32 {
rotation_deg % 360
}
fn quarter_turn_rotation(rotation_deg: u32) -> u32 {
match normalized_rotation(rotation_deg) {
90 | 180 | 270 => normalized_rotation(rotation_deg),
_ => 0,
}
}
fn has_resize(value: u32) -> bool {
value != NULL_U32 && value > 0
}
fn write_u32_le(output: &mut [u8], offset: usize, value: u32) {
output[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
fn copy_rotated_without_resize(
input: &[u8],
dst: &mut [u8],
request: TransformRequest,
geometry: OutputGeometry,
) {
let src_width = request.src_width as usize;
let crop = geometry.crop;
let crop_x = crop.x as usize;
let crop_y = crop.y as usize;
let crop_width = crop.width as usize;
let crop_height = crop.height as usize;
let layout = PixelLayout {
src_width,
dst_width: geometry.target_width as usize,
};
match geometry.rotation {
0 => {
for row in 0..crop_height {
let src_offset = ((crop_y + row) * src_width + crop_x) * RGBA_BYTES_PER_PIXEL;
let dst_offset = row * crop_width * RGBA_BYTES_PER_PIXEL;
let len = crop_width * RGBA_BYTES_PER_PIXEL;
dst[dst_offset..dst_offset + len]
.copy_from_slice(&input[src_offset..src_offset + len]);
}
}
90 => {
for y0 in 0..crop_height {
for x0 in 0..crop_width {
let dst_x = crop_height - 1 - y0;
let dst_y = x0;
copy_pixel(
input,
dst,
layout,
Point {
x: crop_x + x0,
y: crop_y + y0,
},
Point { x: dst_x, y: dst_y },
);
}
}
}
180 => {
for y0 in 0..crop_height {
for x0 in 0..crop_width {
let dst_x = crop_width - 1 - x0;
let dst_y = crop_height - 1 - y0;
copy_pixel(
input,
dst,
layout,
Point {
x: crop_x + x0,
y: crop_y + y0,
},
Point { x: dst_x, y: dst_y },
);
}
}
}
270 => {
for y0 in 0..crop_height {
for x0 in 0..crop_width {
let dst_x = y0;
let dst_y = crop_width - 1 - x0;
copy_pixel(
input,
dst,
layout,
Point {
x: crop_x + x0,
y: crop_y + y0,
},
Point { x: dst_x, y: dst_y },
);
}
}
}
_ => unreachable!("rotation is normalized to quarter turns"),
}
}
fn copy_rotated_with_nearest_resize(
input: &[u8],
dst: &mut [u8],
request: TransformRequest,
geometry: OutputGeometry,
) {
let src_width = request.src_width as usize;
let crop = geometry.crop;
let crop_x = crop.x as usize;
let crop_y = crop.y as usize;
let crop_width = u64::from(crop.width);
let crop_height = u64::from(crop.height);
let base_width = u64::from(geometry.base_width);
let base_height = u64::from(geometry.base_height);
let target_width = geometry.target_width as usize;
let target_height = geometry.target_height as usize;
let target_width_u64 = u64::from(geometry.target_width);
let target_height_u64 = u64::from(geometry.target_height);
let layout = PixelLayout {
src_width,
dst_width: target_width,
};
for dst_y in 0..target_height {
let rotated_y = (dst_y as u64 * base_height) / target_height_u64;
for dst_x in 0..target_width {
let rotated_x = (dst_x as u64 * base_width) / target_width_u64;
let cropped_x = match geometry.rotation {
90 => rotated_y,
180 => crop_width - 1 - rotated_x,
270 => crop_width - 1 - rotated_y,
_ => rotated_x,
} as usize;
let cropped_y = match geometry.rotation {
90 => crop_height - 1 - rotated_x,
180 => crop_height - 1 - rotated_y,
270 => rotated_x,
_ => rotated_y,
} as usize;
copy_pixel(
input,
dst,
layout,
Point {
x: crop_x + cropped_x,
y: crop_y + cropped_y,
},
Point { x: dst_x, y: dst_y },
);
}
}
}
#[inline]
fn copy_pixel(input: &[u8], dst: &mut [u8], layout: PixelLayout, src: Point, target: Point) {
let src_offset = (src.y * layout.src_width + src.x) * RGBA_BYTES_PER_PIXEL;
let dst_offset = (target.y * layout.dst_width + target.x) * RGBA_BYTES_PER_PIXEL;
dst[dst_offset..dst_offset + RGBA_BYTES_PER_PIXEL]
.copy_from_slice(&input[src_offset..src_offset + RGBA_BYTES_PER_PIXEL]);
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
fn request(src_width: u32, src_height: u32) -> TransformRequest {
TransformRequest {
src_width,
src_height,
x: 0,
y: 0,
width: src_width,
height: src_height,
rotation_deg: 0,
resize_width: NULL_U32,
resize_height: NULL_U32,
}
}
fn rgba(values: &[u8]) -> Vec<u8> {
values
.iter()
.flat_map(|value| [*value, 0, 0, 255])
.collect()
}
fn payload(output: &[u8]) -> &[u8] {
&output[RGBA_RESULT_HEADER_BYTES..]
}
fn dimensions(output: &[u8]) -> (u32, u32) {
(
u32::from_le_bytes(output[0..4].try_into().unwrap()),
u32::from_le_bytes(output[4..8].try_into().unwrap()),
)
}
#[test]
fn rotate_90_uses_clockwise_quarter_turns() {
let mut transform = request(2, 3);
transform.rotation_deg = 90;
let output = crop_rotate_rgba_alloc(&rgba(&[1, 2, 3, 4, 5, 6]), transform).unwrap();
assert_eq!(dimensions(&output), (3, 2));
assert_eq!(payload(&output), rgba(&[5, 3, 1, 6, 4, 2]));
}
#[test]
fn rotate_180_reverses_rows_and_columns() {
let mut transform = request(2, 2);
transform.rotation_deg = 180;
let output = crop_rotate_rgba_alloc(&rgba(&[1, 2, 3, 4]), transform).unwrap();
assert_eq!(dimensions(&output), (2, 2));
assert_eq!(payload(&output), rgba(&[4, 3, 2, 1]));
}
#[test]
fn rotate_270_uses_clockwise_quarter_turns() {
let mut transform = request(2, 3);
transform.rotation_deg = 270;
let output = crop_rotate_rgba_alloc(&rgba(&[1, 2, 3, 4, 5, 6]), transform).unwrap();
assert_eq!(dimensions(&output), (3, 2));
assert_eq!(payload(&output), rgba(&[2, 4, 6, 1, 3, 5]));
}
#[test]
fn applies_nearest_resize_after_rotation() {
let mut transform = request(4, 1);
transform.x = 1;
transform.width = 2;
transform.resize_width = 4;
let output = crop_rotate_rgba_alloc(&rgba(&[1, 2, 3, 4]), transform).unwrap();
assert_eq!(dimensions(&output), (4, 1));
assert_eq!(payload(&output), rgba(&[2, 2, 3, 3]));
}
#[test]
fn clamps_crop_to_source_bounds() {
let mut transform = request(3, 2);
transform.x = 2;
transform.y = 1;
transform.width = 99;
transform.height = 99;
let output = crop_rotate_rgba_alloc(&rgba(&[1, 2, 3, 4, 5, 6]), transform).unwrap();
assert_eq!(dimensions(&output), (1, 1));
assert_eq!(payload(&output), rgba(&[6]));
}
#[test]
fn rejects_invalid_source_shape() {
assert_eq!(
crop_rotate_rgba_alloc(&[], request(0, 1)).unwrap_err(),
TransformError::InvalidDimensions
);
assert_eq!(
crop_rotate_rgba_alloc(&[0, 0, 0, 0], request(2, 1)).unwrap_err(),
TransformError::InvalidRgbaLength
);
}
#[test]
fn rejects_empty_crop_and_target() {
let mut empty_crop = request(2, 2);
empty_crop.x = 2;
assert_eq!(
crop_rotate_rgba_alloc(&rgba(&[1, 2, 3, 4]), empty_crop).unwrap_err(),
TransformError::EmptyCrop
);
let mut empty_target = request(2, 2);
empty_target.resize_width = 0;
empty_target.resize_height = 1;
assert!(crop_rotate_rgba_alloc(&rgba(&[1, 2, 3, 4]), empty_target).is_ok());
}
proptest! {
#[test]
fn identity_transform_preserves_pixels(width in 1u32..16, height in 1u32..16) {
let pixel_count = (width * height) as usize;
let input = rgba(&(0..pixel_count).map(|index| index as u8).collect::<Vec<_>>());
let output = crop_rotate_rgba_alloc(&input, request(width, height)).unwrap();
prop_assert_eq!(dimensions(&output), (width, height));
prop_assert_eq!(payload(&output), input.as_slice());
}
}
}