From 5a2d81ebbb7a993ac9f18e08057965c9e56a064d Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 11 Oct 2026 18:46:34 +0200 Subject: [PATCH] fix(media-proxy): bound animated transforms by output pixels (#3402) --- fluxer_media_proxy/src/constants.rs | 2 +- .../src/media_process/animated_transform.rs | 2 +- .../src/media_process/transform.rs | 32 ++++++++++++++++-- .../src/server/transform/mod.rs | 4 +-- .../src/server/transform/tests.rs | 32 ++++++++++++++++++ .../animated_webp_1024_17_frames.webp | Bin 0 -> 1344 bytes .../src/test_fixtures/images.rs | 1 + fluxer_media_proxy/src/test_fixtures/mod.rs | 9 ++--- 8 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 fluxer_media_proxy/src/test_fixtures/animated_webp_1024_17_frames.webp diff --git a/fluxer_media_proxy/src/constants.rs b/fluxer_media_proxy/src/constants.rs index 8b1f06c48..e509831c2 100644 --- a/fluxer_media_proxy/src/constants.rs +++ b/fluxer_media_proxy/src/constants.rs @@ -16,7 +16,7 @@ pub const MAX_ANIMATED_FRAMES_DEFAULT: u32 = 20_000; pub const MAX_ANIMATED_TOTAL_PIXELS_DEFAULT: usize = 4 * MAX_MEDIA_IMAGE_PIXELS_DEFAULT; pub const MAX_JXL_DECODE_PIXELS: usize = 4096 * 4096; pub const MAX_STATIC_OUTPUT_PIXELS: usize = 6144 * 4096; -pub const MAX_GIF_RESIZE_OUTPUT_PIXELS: usize = 16 << 20; +pub const MAX_ANIMATED_OUTPUT_PIXELS: usize = 16 << 20; pub const SVG_RASTER_MAX_DIMENSION: u32 = 4096; pub const LARGE_SOURCE_LOG_PIXELS: usize = 4096 * 4096; const _: () = assert!(MAX_ANIMATED_FRAMES_DEFAULT >= 20_000); diff --git a/fluxer_media_proxy/src/media_process/animated_transform.rs b/fluxer_media_proxy/src/media_process/animated_transform.rs index 4d0431e27..d8542b4d7 100644 --- a/fluxer_media_proxy/src/media_process/animated_transform.rs +++ b/fluxer_media_proxy/src/media_process/animated_transform.rs @@ -182,7 +182,7 @@ impl GifResizeDims { (self.width as usize) .saturating_mul(self.height as usize) .saturating_mul(frames.max(1) as usize) - <= constants::MAX_GIF_RESIZE_OUTPUT_PIXELS + <= constants::MAX_ANIMATED_OUTPUT_PIXELS } } diff --git a/fluxer_media_proxy/src/media_process/transform.rs b/fluxer_media_proxy/src/media_process/transform.rs index 46d126884..ff797a812 100644 --- a/fluxer_media_proxy/src/media_process/transform.rs +++ b/fluxer_media_proxy/src/media_process/transform.rs @@ -13,8 +13,8 @@ use super::image_probe::{ }; 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, + static_output_pixels, 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::{ @@ -128,6 +128,31 @@ fn ensure_decode_cost_admissible( admitted } +fn ensure_animated_output_admissible( + input: &[u8], + options: &ImageOptions, + media_limits: &MediaLimits, +) -> Result<(), MediaError> { + let dims = probe_image_dims(media_limits, input)?; + if dims.pages <= 1 { + return Ok(()); + } + let output_pixels = static_output_pixels(options, dims).saturating_mul(dims.pages as usize); + if output_pixels <= constants::MAX_ANIMATED_OUTPUT_PIXELS { + return Ok(()); + } + tracing::warn!( + 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 transform refused by the output work budget" + ); + Err(MediaError::TooLargeToProcess) +} + pub fn transform_image( input: &[u8], options: &ImageOptions, @@ -184,6 +209,9 @@ pub fn transform_image( content_type: "image/gif", }); } + if animated { + ensure_animated_output_admissible(input, options, media_limits)?; + } if should_transform_animated_webp_direct(sniffed, options, format) { validate_dimensions_u32(media_limits, sniffed.width, sniffed.height)?; let resolved = resolve_auto_quality( diff --git a/fluxer_media_proxy/src/server/transform/mod.rs b/fluxer_media_proxy/src/server/transform/mod.rs index 24280d9d9..6b0b97238 100644 --- a/fluxer_media_proxy/src/server/transform/mod.rs +++ b/fluxer_media_proxy/src/server/transform/mod.rs @@ -574,8 +574,8 @@ async fn serve_image_transform( requested_download: transform.requested_download, }); } - let can_serve_original = transform.width.is_none() - && transform.height.is_none() + let can_serve_original = ((transform.width.is_none() && transform.height.is_none()) + || options.is_animated()) && is_browser_displayable_image(sniffed.mime); let mut too_large_original = None; let leader_original = &mut too_large_original; diff --git a/fluxer_media_proxy/src/server/transform/tests.rs b/fluxer_media_proxy/src/server/transform/tests.rs index 48221ee7e..bebeca729 100644 --- a/fluxer_media_proxy/src/server/transform/tests.rs +++ b/fluxer_media_proxy/src/server/transform/tests.rs @@ -325,3 +325,35 @@ fn a_cache_larger_than_the_pod_takes_at_most_half_of_it() { fn the_budget_never_drops_below_its_floor() { assert_eq!(128 << 20, native_memory_budget_bytes(64 << 20, 0)); } + +#[tokio::test] +async fn an_animated_transform_over_the_work_budget_serves_the_original() { + let webp = crate::test_fixtures::ANIMATED_WEBP_1024_17_FRAMES; + let response = attachment_transform( + webp, + "anim.webp", + &[("format", "webp"), ("animated", "true"), ("width", "1000")], + ) + .await; + assert_eq!(StatusCode::OK, response.status()); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + assert_eq!(webp, body.as_ref()); +} + +#[tokio::test] +async fn an_animated_transform_within_the_work_budget_is_transformed() { + let webp = crate::test_fixtures::ANIMATED_WEBP_1024_17_FRAMES; + let response = attachment_transform( + webp, + "anim.webp", + &[("format", "webp"), ("animated", "true"), ("width", "128")], + ) + .await; + assert_eq!(StatusCode::OK, response.status()); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + assert_ne!(webp, body.as_ref()); +} diff --git a/fluxer_media_proxy/src/test_fixtures/animated_webp_1024_17_frames.webp b/fluxer_media_proxy/src/test_fixtures/animated_webp_1024_17_frames.webp new file mode 100644 index 0000000000000000000000000000000000000000..d3fab919879bc76db136b76f13eac1da784a9345 GIT binary patch literal 1344 zcmWIYbaS&{Wnc(*bqWXzu!!JdU|^5};{VJby`!NK>$QW3IhWZ zP@hjAP*DH>;r|T$2LC_X|NkssVQ_hy@6XA4P4i|e|FUPyJ$66-&-wo(p#O3#U>nU~ z7Bc8FFz_=l0BvLd*(eLLQU3t5fH>5~26jcphf5|OJ03%?w?hjNDF}!0Lep zf`LUc1H%hu1_l@nEYBveGq3>V42%p77$-v5j6f3@7C^-f4Gb6;KuiRcdrS);CW16E J!`Lnm(*Yt!Yt;Y% literal 0 HcmV?d00001 diff --git a/fluxer_media_proxy/src/test_fixtures/images.rs b/fluxer_media_proxy/src/test_fixtures/images.rs index 1bde1e10d..b87958279 100644 --- a/fluxer_media_proxy/src/test_fixtures/images.rs +++ b/fluxer_media_proxy/src/test_fixtures/images.rs @@ -32,6 +32,7 @@ pub fn apng_header(frames: u32) -> Vec { pub const JXL_4100_LOSSY_ALPHA: &[u8] = include_bytes!("jxl_4100_lossy_alpha.jxl"); pub const JXL_64_LOSSY_ALPHA: &[u8] = include_bytes!("jxl_64_lossy_alpha.jxl"); +pub const ANIMATED_WEBP_1024_17_FRAMES: &[u8] = include_bytes!("animated_webp_1024_17_frames.webp"); pub const JPEG_ROTATED_MISMATCHED_SCAN: &[u8] = include_bytes!("jpeg_rotated_mismatched_scan.jpg"); pub fn synthetic_png(width: u32, height: u32) -> Vec { diff --git a/fluxer_media_proxy/src/test_fixtures/mod.rs b/fluxer_media_proxy/src/test_fixtures/mod.rs index a7e8354e8..b331bb638 100644 --- a/fluxer_media_proxy/src/test_fixtures/mod.rs +++ b/fluxer_media_proxy/src/test_fixtures/mod.rs @@ -10,10 +10,11 @@ pub use adversarial::{ }; pub use ffmpeg_cli::{ffmpeg_gen_media, ffmpeg_gen_mp4, ffmpeg_gen_rotated_mp4, ffmpeg_mirror_mp4}; pub use images::{ - JPEG_ROTATED_MISMATCHED_SCAN, JXL_64_LOSSY_ALPHA, JXL_4100_LOSSY_ALPHA, animated_gif_fixture, - animated_gif_frames, apng_header, first_webp_anim_frame_size, gif_frame_delays_cs, - gif_loop_count, minimal_gif, png_dimensions, synthetic_bmp, synthetic_png, - webp_animation_loop_count, webp_canvas_size, webp_chunk_payloads, webp_with_metadata_chunk, + ANIMATED_WEBP_1024_17_FRAMES, JPEG_ROTATED_MISMATCHED_SCAN, JXL_64_LOSSY_ALPHA, + JXL_4100_LOSSY_ALPHA, animated_gif_fixture, animated_gif_frames, apng_header, + first_webp_anim_frame_size, gif_frame_delays_cs, gif_loop_count, minimal_gif, png_dimensions, + synthetic_bmp, synthetic_png, webp_animation_loop_count, webp_canvas_size, webp_chunk_payloads, + webp_with_metadata_chunk, }; pub use media::{ fixture_audio_mp3_with_png_cover_art, fixture_audio_mp4_with_attached_picture,