mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
505 lines
17 KiB
Rust
505 lines
17 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use super::animated_transform::{
|
|
DecodedAnimation, decode_heif_sequence, gif_resize_dims, resize_animated_gif_with_ffmpeg,
|
|
try_decode_apng, try_decode_heif_primary_still,
|
|
};
|
|
use super::encoding::{
|
|
VipsEncodeRequest, anim_limits_from_options, encode_vips_image,
|
|
try_transform_animated_webp_direct,
|
|
};
|
|
use super::image_probe::{
|
|
animated_probe_from_image, load_image, probe_animated, probe_image_dims, try_decode_bmp,
|
|
};
|
|
use super::loaded_image::{
|
|
normalize_vips_image_to_uchar, page_height, resize_loaded_image, resize_loaded_image_by_scale,
|
|
validate_dimensions_u32, validate_jxl_decode_pixels, validate_static_output_pixels,
|
|
validate_vips_image,
|
|
};
|
|
use super::native_runtime::{clear_vips_error, ensure_vips_init, last_vips_error};
|
|
use super::transform_plan::{
|
|
AutoQualitySource, output_is_sdr, resolve_auto_quality, should_transform_animated_webp_direct,
|
|
};
|
|
use super::{
|
|
AnimatedProbe, MediaError, ProcessedMedia, ensure_deadline_pending,
|
|
native_animated_frame_limit, native_optional_deadline, native_status_error,
|
|
};
|
|
use crate::{
|
|
constants,
|
|
image_transform::ImageOptions,
|
|
media_limits::MediaLimits,
|
|
metrics::transform::TransformMetrics,
|
|
mime,
|
|
native::{self, NativeStatus, VipsImageHandle},
|
|
output_format::OutputFormat,
|
|
};
|
|
use libc::c_int;
|
|
use std::ptr;
|
|
|
|
pub(super) fn source_supports_pages(mime: &str) -> bool {
|
|
matches!(
|
|
mime,
|
|
"image/webp" | "image/gif" | "image/apng" | "image/heic" | "image/heif" | "image/avif"
|
|
)
|
|
}
|
|
|
|
fn should_use_resize_path(options: &ImageOptions, probe: Option<AnimatedProbe>) -> bool {
|
|
if options.width.is_none() && options.height.is_none() {
|
|
return false;
|
|
}
|
|
if options.is_animated() {
|
|
return true;
|
|
}
|
|
match probe {
|
|
Some(p) => p.pages <= 1,
|
|
None => true,
|
|
}
|
|
}
|
|
|
|
fn effective_transform_format(
|
|
sniffed_mime: &str,
|
|
requested: OutputFormat,
|
|
animated: bool,
|
|
) -> OutputFormat {
|
|
if animated && sniffed_mime == "image/apng" && requested == OutputFormat::PNG {
|
|
OutputFormat::APNG
|
|
} else {
|
|
requested
|
|
}
|
|
}
|
|
|
|
fn is_heif_source(mime: &str) -> bool {
|
|
matches!(mime, "image/avif" | "image/heic" | "image/heif")
|
|
}
|
|
|
|
fn heif_source_may_be_a_sequence(sniffed: mime::SniffInfo) -> bool {
|
|
match sniffed.mime {
|
|
"image/avif" => sniffed.animated,
|
|
"image/heic" | "image/heif" => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn ensure_decode_cost_admissible(
|
|
input: &[u8],
|
|
sniffed_mime: &str,
|
|
options: &ImageOptions,
|
|
media_limits: &MediaLimits,
|
|
) -> Result<(), MediaError> {
|
|
let animated = options.is_animated();
|
|
if animated && sniffed_mime != "image/jxl" {
|
|
return Ok(());
|
|
}
|
|
let dims = probe_image_dims(media_limits, input)?;
|
|
let source_pixels = dims.width as usize * dims.height as usize;
|
|
if source_pixels >= constants::LARGE_SOURCE_LOG_PIXELS {
|
|
tracing::info!(
|
|
mime = sniffed_mime,
|
|
input_bytes = input.len(),
|
|
width = dims.width,
|
|
height = dims.height,
|
|
pages = dims.pages,
|
|
out_width = options.width.unwrap_or(0),
|
|
out_height = options.height.unwrap_or(0),
|
|
animated,
|
|
"large native transform starting"
|
|
);
|
|
}
|
|
let admitted = validate_jxl_decode_pixels(sniffed_mime, dims).and_then(|()| {
|
|
if animated {
|
|
Ok(())
|
|
} else {
|
|
validate_static_output_pixels(options, dims)
|
|
}
|
|
});
|
|
if admitted.is_err() {
|
|
tracing::warn!(
|
|
mime = sniffed_mime,
|
|
input_bytes = input.len(),
|
|
width = dims.width,
|
|
height = dims.height,
|
|
pages = dims.pages,
|
|
out_width = options.width.unwrap_or(0),
|
|
out_height = options.height.unwrap_or(0),
|
|
animated,
|
|
"native transform refused by decode cost limits"
|
|
);
|
|
}
|
|
admitted
|
|
}
|
|
|
|
pub fn transform_image(
|
|
input: &[u8],
|
|
options: &ImageOptions,
|
|
media_limits: &MediaLimits,
|
|
metrics: &TransformMetrics,
|
|
) -> Result<ProcessedMedia, MediaError> {
|
|
if input.len() > constants::MAX_MEDIA_PROXY_BYTES {
|
|
return Err(MediaError::StreamTooLong);
|
|
}
|
|
if let Some(width) = options.width
|
|
&& (width == 0 || width > media_limits.image_dimension())
|
|
{
|
|
return Err(MediaError::InvalidImageDimensions);
|
|
}
|
|
if let Some(height) = options.height
|
|
&& (height == 0 || height > media_limits.image_dimension())
|
|
{
|
|
return Err(MediaError::InvalidImageDimensions);
|
|
}
|
|
ensure_vips_init()?;
|
|
let sniffed = mime::sniff(input);
|
|
let animated = options.is_animated();
|
|
ensure_decode_cost_admissible(input, sniffed.mime, options, media_limits)?;
|
|
let format = effective_transform_format(sniffed.mime, options.format, animated);
|
|
let animated_avif = sniffed.mime == "image/avif" && sniffed.animated;
|
|
let full_canvas_animation = animated
|
|
&& format == OutputFormat::WebP
|
|
&& (matches!(sniffed.mime, "image/gif" | "image/apng") || animated_avif);
|
|
if animated
|
|
&& format == OutputFormat::GIF
|
|
&& !options.wants_cover_crop()
|
|
&& sniffed.mime == "image/gif"
|
|
{
|
|
let bytes = match gif_resize_dims(sniffed, options) {
|
|
Some(dims) => {
|
|
let frames = probe_animated(media_limits, input)?.map_or(1, |probe| probe.pages);
|
|
if dims.fits_work_budget(frames) {
|
|
resize_animated_gif_with_ffmpeg(input, dims, options, media_limits)?
|
|
} else {
|
|
tracing::info!(
|
|
input_bytes = input.len(),
|
|
width = dims.width,
|
|
height = dims.height,
|
|
frames,
|
|
"animated gif resize over the work budget, serving the source"
|
|
);
|
|
input.to_vec()
|
|
}
|
|
}
|
|
None => input.to_vec(),
|
|
};
|
|
return Ok(ProcessedMedia {
|
|
bytes,
|
|
content_type: "image/gif",
|
|
});
|
|
}
|
|
if should_transform_animated_webp_direct(sniffed, options, format) {
|
|
validate_dimensions_u32(media_limits, sniffed.width, sniffed.height)?;
|
|
let resolved = resolve_auto_quality(
|
|
AutoQualitySource {
|
|
format,
|
|
animated,
|
|
sniffed_mime: sniffed.mime,
|
|
input,
|
|
quality: options.quality,
|
|
probe: None,
|
|
},
|
|
options.effort_override,
|
|
);
|
|
let direct = try_transform_animated_webp_direct(
|
|
input,
|
|
options,
|
|
resolved.quality,
|
|
resolved.effort_override,
|
|
media_limits,
|
|
)?;
|
|
if let Some(bytes) = direct {
|
|
return Ok(ProcessedMedia {
|
|
bytes,
|
|
content_type: format.mime(),
|
|
});
|
|
}
|
|
}
|
|
let heif_source = is_heif_source(sniffed.mime);
|
|
let decoded_animation = if sniffed.mime == "image/apng" {
|
|
try_decode_apng(input, animated, media_limits)?
|
|
} else if animated && heif_source_may_be_a_sequence(sniffed) {
|
|
decode_heif_sequence(input, media_limits)?
|
|
} else {
|
|
None
|
|
};
|
|
let animated_probe = if let Some(decoded) = decoded_animation.as_ref() {
|
|
animated_probe_from_image(&decoded.image)?
|
|
} else if animated && !heif_source {
|
|
probe_animated(media_limits, input)?
|
|
} else {
|
|
None
|
|
};
|
|
let resolved_quality = resolve_auto_quality(
|
|
AutoQualitySource {
|
|
format,
|
|
animated,
|
|
sniffed_mime: sniffed.mime,
|
|
input,
|
|
quality: options.quality,
|
|
probe: animated_probe,
|
|
},
|
|
options.effort_override,
|
|
);
|
|
let requires_heif_sdr_decode = heif_source && output_is_sdr(format);
|
|
let use_heif_primary_still_path = heif_source
|
|
&& !animated_avif
|
|
&& decoded_animation.is_none()
|
|
&& (animated || requires_heif_sdr_decode);
|
|
if use_heif_primary_still_path
|
|
&& let Some(decoded) = try_decode_heif_primary_still(input, media_limits, metrics)?
|
|
{
|
|
let mut image = decoded.image;
|
|
validate_vips_image(media_limits, &image)?;
|
|
if decoded.hdr_tone_mapped {
|
|
metrics.record_hdr_tone_map();
|
|
}
|
|
image = resize_loaded_image(media_limits, image, options)?;
|
|
let page_height = if animated { page_height(&image)? } else { None };
|
|
let bytes = encode_vips_image(
|
|
&image,
|
|
VipsEncodeRequest {
|
|
format,
|
|
quality: resolved_quality.quality,
|
|
page_height,
|
|
effort_override: resolved_quality.effort_override,
|
|
animation: anim_limits_from_options(options),
|
|
animation_loop_count: None,
|
|
media_limits,
|
|
full_canvas_animation,
|
|
},
|
|
)?;
|
|
return Ok(ProcessedMedia {
|
|
bytes,
|
|
content_type: format.mime(),
|
|
});
|
|
}
|
|
|
|
if let Some(decoded) = decoded_animation {
|
|
let DecodedAnimation { image, loop_count } = decoded;
|
|
validate_vips_image(media_limits, &image)?;
|
|
let image = resize_loaded_image(media_limits, image, options)?;
|
|
let page_height = if animated { page_height(&image)? } else { None };
|
|
let bytes = encode_vips_image(
|
|
&image,
|
|
VipsEncodeRequest {
|
|
format,
|
|
quality: resolved_quality.quality,
|
|
page_height,
|
|
effort_override: resolved_quality.effort_override,
|
|
animation: anim_limits_from_options(options),
|
|
animation_loop_count: loop_count,
|
|
media_limits,
|
|
full_canvas_animation,
|
|
},
|
|
)?;
|
|
return Ok(ProcessedMedia {
|
|
bytes,
|
|
content_type: format.mime(),
|
|
});
|
|
}
|
|
|
|
if sniffed.mime == "image/bmp"
|
|
&& let Some(image) = try_decode_bmp(input, media_limits)?
|
|
{
|
|
validate_vips_image(media_limits, &image)?;
|
|
let image = resize_loaded_image(media_limits, image, options)?;
|
|
let page_height = if animated { page_height(&image)? } else { None };
|
|
let bytes = encode_vips_image(
|
|
&image,
|
|
VipsEncodeRequest {
|
|
format,
|
|
quality: resolved_quality.quality,
|
|
page_height,
|
|
effort_override: resolved_quality.effort_override,
|
|
animation: anim_limits_from_options(options),
|
|
animation_loop_count: None,
|
|
media_limits,
|
|
full_canvas_animation,
|
|
},
|
|
)?;
|
|
return Ok(ProcessedMedia {
|
|
bytes,
|
|
content_type: format.mime(),
|
|
});
|
|
}
|
|
|
|
if should_use_resize_path(options, animated_probe) {
|
|
let mut raw = ptr::null_mut();
|
|
let crop =
|
|
if options.wants_cover_crop() && options.width.is_some() && options.height.is_some() {
|
|
native::THUMB_CROP_CENTRE
|
|
} else {
|
|
native::THUMB_CROP_NONE
|
|
};
|
|
let n_pages: c_int = if animated && source_supports_pages(sniffed.mime) {
|
|
-1
|
|
} else {
|
|
1
|
|
};
|
|
let (max_pages, max_total_pixels) = if n_pages == -1 {
|
|
(
|
|
native_animated_frame_limit(media_limits),
|
|
media_limits.animated_total_pixels(),
|
|
)
|
|
} else {
|
|
(1, media_limits.image_pixels())
|
|
};
|
|
let rc = unsafe {
|
|
native::fluxer_vips_thumbnail_buffer_ex(
|
|
input.as_ptr().cast(),
|
|
input.len(),
|
|
0,
|
|
&mut raw,
|
|
options.width.unwrap_or(0) as c_int,
|
|
options.height.unwrap_or(0) as c_int,
|
|
n_pages,
|
|
crop,
|
|
max_pages,
|
|
max_total_pixels,
|
|
)
|
|
};
|
|
if rc != 0 || raw.is_null() {
|
|
let err = last_vips_error();
|
|
clear_vips_error();
|
|
tracing::error!(
|
|
target: "fluxer_media_proxy::transform_debug",
|
|
stage = "thumbnail_buffer_ex",
|
|
sniffed_mime = %sniffed.mime,
|
|
animated = animated,
|
|
w = options.width.unwrap_or(0),
|
|
h = options.height.unwrap_or(0),
|
|
pages = animated_probe.map(|p| p.pages).unwrap_or(0),
|
|
vips_err = %err,
|
|
"transform failed"
|
|
);
|
|
return Err(MediaError::MediaTransformFailed);
|
|
}
|
|
let image = unsafe { VipsImageHandle::from_raw_borrowing(raw, input) }
|
|
.ok_or(MediaError::MediaTransformFailed)?;
|
|
let image = normalize_vips_image_to_uchar(media_limits, image)?;
|
|
let page_height = if animated { page_height(&image)? } else { None };
|
|
let bytes = encode_vips_image(
|
|
&image,
|
|
VipsEncodeRequest {
|
|
format,
|
|
quality: resolved_quality.quality,
|
|
page_height,
|
|
effort_override: resolved_quality.effort_override,
|
|
animation: anim_limits_from_options(options),
|
|
animation_loop_count: None,
|
|
media_limits,
|
|
full_canvas_animation,
|
|
},
|
|
)?;
|
|
return Ok(ProcessedMedia {
|
|
bytes,
|
|
content_type: format.mime(),
|
|
});
|
|
}
|
|
|
|
let loader_options = if animated && source_supports_pages(sniffed.mime) {
|
|
"n=-1,access=sequential,fail=true"
|
|
} else if sniffed.mime == "image/jpeg" {
|
|
""
|
|
} else {
|
|
"access=sequential,fail=true"
|
|
};
|
|
let loaded = load_image(input, loader_options)?;
|
|
let loaded = normalize_vips_image_to_uchar(media_limits, loaded)?;
|
|
let mut oriented_raw = ptr::null_mut();
|
|
let status = NativeStatus::from_code(unsafe {
|
|
native::fluxer_vips_autorot(loaded.as_ptr(), 0, &mut oriented_raw)
|
|
});
|
|
if let Some(error) = native_status_error(status, MediaError::MediaTransformFailed) {
|
|
clear_vips_error();
|
|
return Err(error);
|
|
}
|
|
let base = unsafe { loaded.adopt_derived_raw(oriented_raw) }
|
|
.ok_or(MediaError::MediaTransformFailed)?;
|
|
validate_vips_image(media_limits, &base)?;
|
|
let page_height = if animated { page_height(&base)? } else { None };
|
|
let bytes = encode_vips_image(
|
|
&base,
|
|
VipsEncodeRequest {
|
|
format,
|
|
quality: resolved_quality.quality,
|
|
page_height,
|
|
effort_override: resolved_quality.effort_override,
|
|
animation: anim_limits_from_options(options),
|
|
animation_loop_count: None,
|
|
media_limits,
|
|
full_canvas_animation,
|
|
},
|
|
)?;
|
|
Ok(ProcessedMedia {
|
|
bytes,
|
|
content_type: format.mime(),
|
|
})
|
|
}
|
|
|
|
pub(super) struct StaticThumbnailRequest<'a> {
|
|
pub(super) media_limits: &'a MediaLimits,
|
|
pub(super) input: &'a [u8],
|
|
pub(super) width: u32,
|
|
pub(super) height: u32,
|
|
pub(super) deadline_ms: Option<i64>,
|
|
}
|
|
|
|
pub(super) fn load_static_thumbnail<'source>(
|
|
request: StaticThumbnailRequest<'source>,
|
|
) -> Result<VipsImageHandle<'source>, MediaError> {
|
|
let StaticThumbnailRequest {
|
|
media_limits,
|
|
input,
|
|
width,
|
|
height,
|
|
deadline_ms,
|
|
} = request;
|
|
ensure_vips_init()?;
|
|
ensure_deadline_pending(deadline_ms)?;
|
|
if mime::sniff(input).mime == "image/bmp"
|
|
&& let Some(image) = try_decode_bmp(input, media_limits)?
|
|
{
|
|
let image = normalize_vips_image_to_uchar(media_limits, image)?;
|
|
let scale = fit_inside_scale(&image, width, height);
|
|
return resize_loaded_image_by_scale(media_limits, image, scale);
|
|
}
|
|
let mut raw = ptr::null_mut();
|
|
let rc = unsafe {
|
|
native::fluxer_vips_thumbnail_buffer_ex(
|
|
input.as_ptr().cast(),
|
|
input.len(),
|
|
native_optional_deadline(deadline_ms),
|
|
&mut raw,
|
|
c_int::try_from(width).map_err(|_| MediaError::InvalidImageDimensions)?,
|
|
c_int::try_from(height).map_err(|_| MediaError::InvalidImageDimensions)?,
|
|
1,
|
|
native::THUMB_CROP_NONE,
|
|
1,
|
|
media_limits.image_pixels(),
|
|
)
|
|
};
|
|
let image = unsafe { VipsImageHandle::from_raw_borrowing(raw, input) };
|
|
ensure_deadline_pending(deadline_ms)?;
|
|
if let Some(error) = native_status_error(
|
|
NativeStatus::from_code(rc),
|
|
MediaError::MediaTransformFailed,
|
|
) {
|
|
clear_vips_error();
|
|
return Err(error);
|
|
}
|
|
let image = image.ok_or_else(|| {
|
|
clear_vips_error();
|
|
MediaError::MediaTransformFailed
|
|
})?;
|
|
normalize_vips_image_to_uchar(media_limits, image)
|
|
}
|
|
|
|
fn fit_inside_scale(image: &VipsImageHandle<'_>, width: u32, height: u32) -> f64 {
|
|
let image_width = unsafe { native::fluxer_vips_image_get_width(image.as_ptr()) };
|
|
let image_height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) };
|
|
if image_width <= 0 || image_height <= 0 {
|
|
return 1.0;
|
|
}
|
|
(f64::from(width) / f64::from(image_width))
|
|
.min(f64::from(height) / f64::from(image_height))
|
|
.min(1.0)
|
|
}
|