fix(media-proxy): budget native image work by pod memory (#3385)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-11 02:07:19 +02:00
1 parent 575a5adbef
commit 7d2c51a57f
20 files changed
+663 -133

No files matched your search

+4
View File
@@ -14,6 +14,10 @@ pub const MAX_VIDEO_PACKETS_FOR_THUMBNAIL: usize = 512;
pub const DEFAULT_IMAGE_SIZE: u32 = 128; pub const DEFAULT_IMAGE_SIZE: u32 = 128;
pub const MAX_ANIMATED_FRAMES_DEFAULT: u32 = 20_000; 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_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 SVG_RASTER_MAX_DIMENSION: u32 = 4096;
pub const LARGE_SOURCE_LOG_PIXELS: usize = 4096 * 4096;
const _: () = assert!(MAX_ANIMATED_FRAMES_DEFAULT >= 20_000); const _: () = assert!(MAX_ANIMATED_FRAMES_DEFAULT >= 20_000);
pub const IMAGE_SIZES: &[u32] = &[ pub const IMAGE_SIZES: &[u32] = &[
@@ -1,8 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use super::native_runtime::clear_vips_error; use super::native_runtime::clear_vips_error;
use super::{MediaError, native_status_error}; use super::{ImageDimensions, MediaError, native_status_error};
use crate::{ use crate::{
constants,
image_transform::{ImageOptions, ResizeMode}, image_transform::{ImageOptions, ResizeMode},
media_limits::MediaLimits, media_limits::MediaLimits,
native, native,
@@ -55,6 +56,51 @@ pub(crate) fn validate_dimensions_u32(
Ok(()) Ok(())
} }
pub(crate) fn validate_jxl_decode_pixels(
sniffed_mime: &str,
dims: ImageDimensions,
) -> Result<(), MediaError> {
if sniffed_mime != "image/jxl" {
return Ok(());
}
let pixels = (dims.width as usize)
.saturating_mul(dims.height as usize)
.saturating_mul(dims.pages.max(1) as usize);
if pixels > constants::MAX_JXL_DECODE_PIXELS {
return Err(MediaError::InvalidImageDimensions);
}
Ok(())
}
pub(crate) fn static_output_pixels(options: &ImageOptions, dims: ImageDimensions) -> usize {
let source_width = dims.width as f64;
let source_height = dims.height as f64;
let source_pixels = dims.width as usize * dims.height as usize;
if options.wants_cover_crop()
&& let (Some(width), Some(height)) = (options.width, options.height)
{
return (width as usize * height as usize).min(source_pixels);
}
let width_scale = options
.width
.map_or(1.0, |width| width as f64 / source_width);
let height_scale = options
.height
.map_or(1.0, |height| height as f64 / source_height);
let scale = width_scale.min(height_scale).min(1.0);
((source_width * scale).round() * (source_height * scale).round()) as usize
}
pub(crate) fn validate_static_output_pixels(
options: &ImageOptions,
dims: ImageDimensions,
) -> Result<(), MediaError> {
if static_output_pixels(options, dims) > constants::MAX_STATIC_OUTPUT_PIXELS {
return Err(MediaError::InvalidImageDimensions);
}
Ok(())
}
pub(crate) fn validate_dimensions( pub(crate) fn validate_dimensions(
media_limits: &MediaLimits, media_limits: &MediaLimits,
width: c_int, width: c_int,
@@ -3,7 +3,7 @@
use super::MediaError; use super::MediaError;
use super::av_metadata::{AVMetadata, AVProbe, NSFW_PREVIEW_MAX_DIMENSION, probe_av_metadata}; use super::av_metadata::{AVMetadata, AVProbe, NSFW_PREVIEW_MAX_DIMENSION, probe_av_metadata};
use super::image_probe::probe_image_dims; use super::image_probe::probe_image_dims;
use super::loaded_image::validate_dimensions_u32; use super::loaded_image::{validate_dimensions_u32, validate_jxl_decode_pixels};
use super::nsfw_processing::{ use super::nsfw_processing::{
NSFWScanPreparation, NSFWScanSource, classify_nsfw_buffers, nsfw_scan_buffers, NSFWScanPreparation, NSFWScanSource, classify_nsfw_buffers, nsfw_scan_buffers,
}; };
@@ -22,6 +22,7 @@ use sha2::{Digest, Sha256};
pub struct MetadataOptions { pub struct MetadataOptions {
pub placeholder: bool, pub placeholder: bool,
pub nsfw: NSFWPolicy, pub nsfw: NSFWPolicy,
pub deadline_ms: Option<i64>,
} }
impl Default for MetadataOptions { impl Default for MetadataOptions {
@@ -29,6 +30,7 @@ impl Default for MetadataOptions {
Self { Self {
placeholder: true, placeholder: true,
nsfw: NSFWPolicy::Disabled, nsfw: NSFWPolicy::Disabled,
deadline_ms: None,
} }
} }
} }
@@ -50,12 +52,12 @@ struct MetadataResponse {
nsfw_probability: f32, nsfw_probability: f32,
} }
struct MetadataBlocking { pub struct PreparedMetadata {
response: MetadataResponse, response: MetadataResponse,
nsfw_scan: Option<NSFWScanPreparation>, nsfw_scan: Option<NSFWScanPreparation>,
} }
impl MetadataBlocking { impl PreparedMetadata {
fn take_nsfw_scan(&mut self) -> Option<NSFWScanPreparation> { fn take_nsfw_scan(&mut self) -> Option<NSFWScanPreparation> {
self.nsfw_scan.take() self.nsfw_scan.take()
} }
@@ -128,7 +130,7 @@ fn probe_av_metadata_without_requiring_a_frame(
} }
} }
fn metadata_blocking(request: MetadataBlockingRequest<'_>) -> Result<MetadataBlocking, MediaError> { fn metadata_blocking(request: MetadataBlockingRequest<'_>) -> Result<PreparedMetadata, MediaError> {
let MetadataBlockingRequest { let MetadataBlockingRequest {
media_limits, media_limits,
metrics, metrics,
@@ -145,7 +147,9 @@ fn metadata_blocking(request: MetadataBlockingRequest<'_>) -> Result<MetadataBlo
let initial_category = mime::category(sniffed.mime).ok_or(MediaError::UnsupportedMediaType)?; let initial_category = mime::category(sniffed.mime).ok_or(MediaError::UnsupportedMediaType)?;
let is_image = initial_category == mime::Category::Image; let is_image = initial_category == mime::Category::Image;
let dims = if is_image { let dims = if is_image {
Some(probe_image_dims(media_limits, input)?) let dims = probe_image_dims(media_limits, input)?;
validate_jxl_decode_pixels(sniffed.mime, dims)?;
Some(dims)
} else { } else {
None None
}; };
@@ -190,7 +194,7 @@ fn metadata_blocking(request: MetadataBlockingRequest<'_>) -> Result<MetadataBlo
let placeholder = if options.placeholder { let placeholder = if options.placeholder {
let hash = if is_image { let hash = if is_image {
optional_thumbhash( optional_thumbhash(
encode_thumbhash(media_limits, input, None), encode_thumbhash(media_limits, input, options.deadline_ms),
"image_metadata", "image_metadata",
) )
} else { } else {
@@ -219,7 +223,7 @@ fn metadata_blocking(request: MetadataBlockingRequest<'_>) -> Result<MetadataBlo
frame_count: frames_count.max(sniffed.frames), frame_count: frames_count.max(sniffed.frames),
input, input,
duration_seconds: av_probe.as_ref().and_then(|probe| probe.duration_seconds), duration_seconds: av_probe.as_ref().and_then(|probe| probe.duration_seconds),
deadline_ms: None, deadline_ms: options.deadline_ms,
})? })?
} else { } else {
None None
@@ -239,7 +243,7 @@ fn metadata_blocking(request: MetadataBlockingRequest<'_>) -> Result<MetadataBlo
let format = metadata_format(content_type); let format = metadata_format(content_type);
let content_hash = hex::encode(Sha256::digest(input)); let content_hash = hex::encode(Sha256::digest(input));
Ok(MetadataBlocking { Ok(PreparedMetadata {
response: MetadataResponse { response: MetadataResponse {
content_type: content_type.to_owned(), content_type: content_type.to_owned(),
size: input.len(), size: input.len(),
@@ -257,13 +261,37 @@ fn metadata_blocking(request: MetadataBlockingRequest<'_>) -> Result<MetadataBlo
}) })
} }
fn metadata_finalize(prepared: MetadataBlocking, verdict: NSFWClassification) -> MetadataResponse { fn metadata_finalize(prepared: PreparedMetadata, verdict: NSFWClassification) -> MetadataResponse {
let mut response = prepared.response; let mut response = prepared.response;
response.nsfw = verdict.is_nsfw; response.nsfw = verdict.is_nsfw;
response.nsfw_probability = verdict.probability; response.nsfw_probability = verdict.probability;
response response
} }
pub fn prepare_metadata(
input: &[u8],
options: &MetadataOptions,
media_limits: &MediaLimits,
metrics: &TransformMetrics,
) -> Result<PreparedMetadata, MediaError> {
metadata_blocking(MetadataBlockingRequest {
media_limits,
metrics,
input,
options,
})
}
pub async fn finish_metadata(
mut prepared: PreparedMetadata,
nsfw_client: &NSFWClient,
) -> Result<String, MediaError> {
let scan = prepared.take_nsfw_scan();
let verdict = classify_nsfw_buffers(nsfw_client, scan).await?;
let response = metadata_finalize(prepared, verdict);
serde_json::to_string(&response).map_err(|_| MediaError::MediaEncodeFailed)
}
pub async fn metadata_json_with_options( pub async fn metadata_json_with_options(
input: &[u8], input: &[u8],
_filename: &str, _filename: &str,
@@ -272,14 +300,6 @@ pub async fn metadata_json_with_options(
nsfw_client: &NSFWClient, nsfw_client: &NSFWClient,
metrics: &TransformMetrics, metrics: &TransformMetrics,
) -> Result<String, MediaError> { ) -> Result<String, MediaError> {
let mut prepared = metadata_blocking(MetadataBlockingRequest { let prepared = prepare_metadata(input, &options, media_limits, metrics)?;
media_limits, finish_metadata(prepared, nsfw_client).await
metrics,
input,
options: &options,
})?;
let scan = prepared.take_nsfw_scan();
let verdict = classify_nsfw_buffers(nsfw_client, scan).await?;
let response = metadata_finalize(prepared, verdict);
serde_json::to_string(&response).map_err(|_| MediaError::MediaEncodeFailed)
} }
+6 -1
View File
@@ -18,6 +18,7 @@ mod encoding;
mod image_probe; mod image_probe;
mod loaded_image; mod loaded_image;
mod metadata; mod metadata;
mod native_cost;
pub(crate) mod native_runtime; pub(crate) mod native_runtime;
mod nsfw_processing; mod nsfw_processing;
mod placeholder; mod placeholder;
@@ -32,7 +33,11 @@ mod tests;
pub use av_metadata::{ pub use av_metadata::{
AVMetadata, AVMetadataFrame, AVProbe, NSFW_PREVIEW_MAX_DIMENSION, probe_av_metadata, AVMetadata, AVMetadataFrame, AVProbe, NSFW_PREVIEW_MAX_DIMENSION, probe_av_metadata,
}; };
pub use metadata::{MetadataOptions, metadata_json_with_options}; pub use metadata::{
MetadataOptions, PreparedMetadata, finish_metadata, metadata_json_with_options,
prepare_metadata,
};
pub use native_cost::{AV_NATIVE_COST_BYTES, image_transform_cost, metadata_cost};
pub use nsfw_processing::encode_static_image_for_nsfw; pub use nsfw_processing::encode_static_image_for_nsfw;
pub use transform::transform_image; pub use transform::transform_image;
pub use video_thumbnail::{ pub use video_thumbnail::{
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use super::MediaError;
use super::image_probe::probe_image_dims;
use super::loaded_image::static_output_pixels;
use super::native_runtime::ensure_vips_init;
use crate::{
image_quality::ImageQuality, image_transform::ImageOptions, media_limits::MediaLimits, mime,
output_format::OutputFormat,
};
pub const AV_NATIVE_COST_BYTES: usize = 256 << 20;
const ANIMATED_CANVAS_BUFFERS: usize = 8;
const RGBA_BYTES_PER_PIXEL: usize = 4;
fn decode_bytes_per_pixel(sniffed_mime: &str) -> usize {
match sniffed_mime {
"image/jxl" => 40,
"image/webp" => 20,
"image/avif" | "image/heic" | "image/heif" => 12,
"image/jpeg" => 2,
"image/svg+xml" => 0,
_ => 8,
}
}
fn encode_bytes_per_pixel(format: OutputFormat, quality: ImageQuality) -> usize {
match (format, quality) {
(OutputFormat::WebP, ImageQuality::Lossless | ImageQuality::Auto) => 48,
(OutputFormat::WebP, _) => 28,
(OutputFormat::JPEG, _) => 4,
_ => 8,
}
}
pub fn image_transform_cost(
input: &[u8],
options: &ImageOptions,
media_limits: &MediaLimits,
) -> Result<usize, MediaError> {
ensure_vips_init()?;
let sniffed_mime = mime::sniff(input).mime;
let dims = probe_image_dims(media_limits, input)?;
let source_pixels = dims.width as usize * dims.height as usize;
let output_pixels = static_output_pixels(options, dims);
let encode = encode_bytes_per_pixel(options.format, options.quality);
let pixel_cost = if options.is_animated() && dims.pages > 1 {
source_pixels.max(output_pixels) * RGBA_BYTES_PER_PIXEL * ANIMATED_CANVAS_BUFFERS
+ output_pixels * encode
} else {
source_pixels * decode_bytes_per_pixel(sniffed_mime) + output_pixels * encode
};
Ok(input.len().saturating_add(pixel_cost))
}
pub fn metadata_cost(input: &[u8], media_limits: &MediaLimits) -> Result<usize, MediaError> {
let sniffed_mime = mime::sniff(input).mime;
match mime::category(sniffed_mime) {
Some(mime::Category::Image) => {
ensure_vips_init()?;
let dims = probe_image_dims(media_limits, input)?;
let source_pixels = dims.width as usize * dims.height as usize;
Ok(input
.len()
.saturating_add(source_pixels * decode_bytes_per_pixel(sniffed_mime)))
}
Some(mime::Category::Video | mime::Category::Audio) => {
Ok(input.len().saturating_add(AV_NATIVE_COST_BYTES))
}
_ => Ok(input.len()),
}
}
@@ -64,6 +64,7 @@ fn metadata_json_returns_unavailable_when_nsfw_service_fails() {
MetadataOptions { MetadataOptions {
placeholder: false, placeholder: false,
nsfw: NSFWPolicy::enabled(0.85).expect("valid threshold"), nsfw: NSFWPolicy::enabled(0.85).expect("valid threshold"),
deadline_ms: None,
}, },
&test_media_limits(), &test_media_limits(),
&client, &client,
@@ -237,3 +238,23 @@ fn metadata_succeeds_without_a_placeholder_when_thumbhash_generation_fails() {
assert_eq!(64, value["height"]); assert_eq!(64, value["height"]);
assert_eq!(None, value.get("placeholder")); assert_eq!(None, value.get("placeholder"));
} }
#[test]
fn metadata_refuses_jxl_above_the_decode_pixel_cap() {
let err = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async {
metadata_json_with_options(
crate::test_fixtures::JXL_4100_LOSSY_ALPHA,
"big.jxl",
MetadataOptions::default(),
&test_media_limits(),
&NSFWClient::disabled(),
&TransformMetrics::new(),
)
.await
.unwrap_err()
});
assert_eq!(MediaError::InvalidImageDimensions, err);
}
@@ -1,14 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use super::super::ImageOptions;
use super::super::image_probe::load_image; use super::super::image_probe::load_image;
use super::super::native_runtime::ensure_vips_init; use super::super::native_runtime::ensure_vips_init;
use super::super::transform::source_supports_pages; use super::super::transform::source_supports_pages;
use super::super::{ImageOptions, MediaError, image_transform_cost};
use super::fixtures::{animated_mode, metadata_value, transform_image}; use super::fixtures::{animated_mode, metadata_value, transform_image};
use crate::{ use crate::{
mime, native, mime, native,
output_format::OutputFormat, output_format::OutputFormat,
test_fixtures::{synthetic_bmp, synthetic_png}, test_fixtures::{
JXL_64_LOSSY_ALPHA, JXL_4100_LOSSY_ALPHA, png_dimensions, synthetic_bmp, synthetic_png,
},
}; };
#[test] #[test]
@@ -103,3 +105,105 @@ fn source_supports_pages_matches_libvips_loader_list() {
assert!(!source_supports_pages("image/bmp")); assert!(!source_supports_pages("image/bmp"));
assert!(!source_supports_pages("application/octet-stream")); assert!(!source_supports_pages("application/octet-stream"));
} }
#[test]
fn refuses_jxl_above_the_decode_pixel_cap_before_decoding() {
for width in [None, Some(128)] {
let err = transform_image(
JXL_4100_LOSSY_ALPHA,
&ImageOptions {
width,
format: OutputFormat::WebP,
..Default::default()
},
)
.unwrap_err();
assert_eq!(MediaError::InvalidImageDimensions, err);
}
}
#[test]
fn transforms_jxl_within_the_decode_pixel_cap() {
let out = transform_image(
JXL_64_LOSSY_ALPHA,
&ImageOptions {
width: Some(32),
format: OutputFormat::WebP,
..Default::default()
},
)
.unwrap();
assert_eq!("image/webp", out.content_type);
}
#[test]
fn refuses_full_size_svg_raster_above_the_static_output_cap() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="16383" height="16383"></svg>"#;
let err = transform_image(
svg,
&ImageOptions {
format: OutputFormat::WebP,
..Default::default()
},
)
.unwrap_err();
assert_eq!(MediaError::InvalidImageDimensions, err);
let err = transform_image(
svg,
&ImageOptions {
width: Some(16384),
format: OutputFormat::WebP,
..Default::default()
},
)
.unwrap_err();
assert_eq!(MediaError::InvalidImageDimensions, err);
}
#[test]
fn rasterizes_large_svg_into_a_bounded_box() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="16383" height="16383"></svg>"#;
let out = transform_image(
svg,
&ImageOptions {
width: Some(64),
height: Some(64),
format: OutputFormat::PNG,
..Default::default()
},
)
.unwrap();
assert_eq!(Some((64, 64)), png_dimensions(&out.bytes));
}
#[test]
fn transform_cost_charges_the_full_source_decode_for_jxl() {
let cost = image_transform_cost(
JXL_4100_LOSSY_ALPHA,
&ImageOptions {
width: Some(128),
format: OutputFormat::WebP,
..Default::default()
},
&super::fixtures::test_media_limits(),
)
.unwrap();
assert!(cost >= 4100 * 4100 * 40, "cost {cost}");
}
#[test]
fn transform_cost_charges_svg_at_its_output_size() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="16383" height="16383"></svg>"#;
let cost = image_transform_cost(
svg,
&ImageOptions {
width: Some(64),
height: Some(64),
format: OutputFormat::WebP,
..Default::default()
},
&super::fixtures::test_media_limits(),
)
.unwrap();
assert!(cost < 1 << 20, "cost {cost}");
}
@@ -8,10 +8,13 @@ use super::encoding::{
VipsEncodeRequest, anim_limits_from_options, encode_vips_image, VipsEncodeRequest, anim_limits_from_options, encode_vips_image,
try_transform_animated_webp_direct, try_transform_animated_webp_direct,
}; };
use super::image_probe::{animated_probe_from_image, load_image, probe_animated, try_decode_bmp}; use super::image_probe::{
animated_probe_from_image, load_image, probe_animated, probe_image_dims, try_decode_bmp,
};
use super::loaded_image::{ use super::loaded_image::{
normalize_vips_image_to_uchar, page_height, resize_loaded_image, resize_loaded_image_by_scale, normalize_vips_image_to_uchar, page_height, resize_loaded_image, resize_loaded_image_by_scale,
validate_dimensions_u32, validate_vips_image, 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::native_runtime::{clear_vips_error, ensure_vips_init, last_vips_error};
use super::transform_plan::{ use super::transform_plan::{
@@ -77,6 +80,54 @@ fn heif_source_may_be_a_sequence(sniffed: mime::SniffInfo) -> bool {
} }
} }
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( pub fn transform_image(
input: &[u8], input: &[u8],
options: &ImageOptions, options: &ImageOptions,
@@ -99,6 +150,7 @@ pub fn transform_image(
ensure_vips_init()?; ensure_vips_init()?;
let sniffed = mime::sniff(input); let sniffed = mime::sniff(input);
let animated = options.is_animated(); 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 format = effective_transform_format(sniffed.mime, options.format, animated);
let animated_avif = sniffed.mime == "image/avif" && sniffed.animated; let animated_avif = sniffed.mime == "image/avif" && sniffed.animated;
let full_canvas_animation = animated let full_canvas_animation = animated
+5 -2
View File
@@ -23,7 +23,7 @@ use fluxer_common::attachment_url_signature::is_signature_parameter_name;
use rand::RngExt; use rand::RngExt;
use stage::{StageTimingSnapshot, StageTimings}; use stage::{StageTimingSnapshot, StageTimings};
use std::{future::Future, sync::Arc, time::Instant}; use std::{future::Future, sync::Arc, time::Instant};
use tracing::{Level, event}; use tracing::{Instrument, Level, event};
const ID_ALPHABET: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; const ID_ALPHABET: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const ID_LEN: usize = 12; const ID_LEN: usize = 12;
@@ -179,7 +179,10 @@ where
} = observation; } = observation;
let stages = Arc::new(StageTimings::default()); let stages = Arc::new(StageTimings::default());
let started = Instant::now(); let started = Instant::now();
let response = stage::scope(Arc::clone(&stages), future).await; let span = tracing::info_span!("request", req = %id.as_str(), path = %path);
let response = stage::scope(Arc::clone(&stages), future)
.instrument(span)
.await;
let elapsed_ms = metrics::duration_millis(started.elapsed()); let elapsed_ms = metrics::duration_millis(started.elapsed());
let StageTimingSnapshot { let StageTimingSnapshot {
fetch_ms, fetch_ms,
@@ -7,7 +7,7 @@ pub(in crate::server) use failure::MediaFailure;
pub(in crate::server) use input::{MediaInput, MediaInputLimit, load_media_input}; pub(in crate::server) use input::{MediaInput, MediaInputLimit, load_media_input};
use crate::{ use crate::{
constants::AssetExtension, constants::{self, AssetExtension},
image_quality::ImageQuality, image_quality::ImageQuality,
image_transform::AnimationMode, image_transform::AnimationMode,
media_process, mime, media_process, mime,
@@ -16,7 +16,7 @@ use crate::{
server::{ server::{
format_policy::image_extension_from_filename, format_policy::image_extension_from_filename,
state::AppState, state::AppState,
transform::execution::{run_transform, transform_error_is_timeout}, transform::execution::{deadline_instant, run_transform, transform_error_is_timeout},
}, },
}; };
use bytes::Bytes; use bytes::Bytes;
@@ -43,21 +43,39 @@ pub(in crate::server) async fn resolve_metadata(
app.media.nsfw().record_declined_scan(); app.media.nsfw().record_declined_scan();
NSFWPolicy::Disabled NSFWPolicy::Disabled
}; };
let json = media_process::metadata_json_with_options( let runtime = app.media.transforms();
&input.data, let options = media_process::MetadataOptions {
&input.filename, placeholder: true,
media_process::MetadataOptions { nsfw,
placeholder: true, deadline_ms: runtime.transform_deadline_ms(),
nsfw, };
}, let deadline = deadline_instant(options.deadline_ms);
&app.media.limits(), let media_limits = app.media.limits();
app.media.nsfw(), let transform_metrics = app.metrics.transform();
&app.metrics.transform(), let data = input.data.clone();
) let cost_data = input.data.clone();
.await let extraction_failed = |err: &dyn std::fmt::Debug| MediaFailure::MetadataExtractionFailed {
.map_err(|err| MediaFailure::MetadataExtractionFailed {
detail: format!("filename={} err={err:?}", input.filename), detail: format!("filename={} err={err:?}", input.filename),
})?; };
let prepared = runtime
.tasks()
.run_native(
deadline,
move || Ok(media_process::metadata_cost(&cost_data, &media_limits)?),
move || {
Ok(media_process::prepare_metadata(
&data,
&options,
&media_limits,
&transform_metrics,
)?)
},
)
.await
.map_err(|err| extraction_failed(&err))?;
let json = media_process::finish_metadata(prepared, app.media.nsfw())
.await
.map_err(|err| extraction_failed(&err))?;
Ok(MetadataOutput { Ok(MetadataOutput {
metadata: serde_json::from_str(&json).unwrap_or_else(|_| serde_json::json!({})), metadata: serde_json::from_str(&json).unwrap_or_else(|_| serde_json::json!({})),
data: include_data.then_some(input.data), data: include_data.then_some(input.data),
@@ -74,6 +92,8 @@ async fn rasterize_metadata_svg(
input: LoadedMediaInput, input: LoadedMediaInput,
) -> Result<LoadedMediaInput, MediaFailure> { ) -> Result<LoadedMediaInput, MediaFailure> {
let options = media_process::ImageOptions { let options = media_process::ImageOptions {
width: Some(constants::SVG_RASTER_MAX_DIMENSION),
height: Some(constants::SVG_RASTER_MAX_DIMENSION),
format: OutputFormat::WebP, format: OutputFormat::WebP,
quality: ImageQuality::Lossless, quality: ImageQuality::Lossless,
animation: AnimationMode::Static, animation: AnimationMode::Static,
@@ -18,20 +18,20 @@ use std::{
time::Instant, time::Instant,
}; };
use tokio::sync::{Notify, oneshot}; use tokio::sync::{Notify, oneshot};
use tracing::error; use tracing::{error, warn};
pub(in crate::server) struct NativeTaskExecutorSettings { pub(in crate::server) struct NativeTaskExecutorSettings {
pub(in crate::server) max_native_transforms: usize, pub(in crate::server) max_native_transforms: usize,
pub(in crate::server) worker_queue_capacity: usize, pub(in crate::server) worker_queue_capacity: usize,
pub(in crate::server) decoded_bytes_per_transform: usize, pub(in crate::server) memory_budget_bytes: usize,
pub(in crate::server) native_metrics: Arc<NativeTransformMetrics>, pub(in crate::server) native_metrics: Arc<NativeTransformMetrics>,
pub(in crate::server) transform_metrics: Arc<TransformMetrics>, pub(in crate::server) transform_metrics: Arc<TransformMetrics>,
} }
pub(in crate::server) struct NativeTaskExecutor { pub(in crate::server) struct NativeTaskExecutor {
native_transforms: TimedSemaphore, native_transforms: TimedSemaphore,
decoded_bytes: ByteBudget, memory: ByteBudget,
decoded_bytes_per_transform: usize, memory_budget_bytes: usize,
tasks: NativeTaskTracker, tasks: NativeTaskTracker,
native_metrics: Arc<NativeTransformMetrics>, native_metrics: Arc<NativeTransformMetrics>,
transform_metrics: Arc<TransformMetrics>, transform_metrics: Arc<TransformMetrics>,
@@ -70,34 +70,33 @@ impl NativeTaskExecutor {
let NativeTaskExecutorSettings { let NativeTaskExecutorSettings {
max_native_transforms, max_native_transforms,
worker_queue_capacity, worker_queue_capacity,
decoded_bytes_per_transform, memory_budget_bytes,
native_metrics, native_metrics,
transform_metrics, transform_metrics,
} = settings; } = settings;
assert!(decoded_bytes_per_transform > 0); assert!(memory_budget_bytes > 0);
let decoded_bytes_capacity = decoded_bytes_per_transform
.checked_mul(max_native_transforms)
.expect("native decoded image budget must not overflow");
Self { Self {
native_transforms: TimedSemaphore::with_queue_capacity( native_transforms: TimedSemaphore::with_queue_capacity(
max_native_transforms, max_native_transforms,
worker_queue_capacity, worker_queue_capacity,
), ),
decoded_bytes: ByteBudget::new(decoded_bytes_capacity), memory: ByteBudget::new(memory_budget_bytes),
decoded_bytes_per_transform, memory_budget_bytes,
tasks: NativeTaskTracker::default(), tasks: NativeTaskTracker::default(),
native_metrics, native_metrics,
transform_metrics, transform_metrics,
} }
} }
pub(in crate::server) async fn run_native<T, F>( pub(in crate::server) async fn run_native<T, C, F>(
&self, &self,
deadline: Option<Instant>, deadline: Option<Instant>,
cost: C,
work: F, work: F,
) -> anyhow::Result<T> ) -> anyhow::Result<T>
where where
T: Send + 'static, T: Send + 'static,
C: FnOnce() -> anyhow::Result<usize> + Send + 'static,
F: FnOnce() -> anyhow::Result<T> + Send + 'static, F: FnOnce() -> anyhow::Result<T> + Send + 'static,
{ {
let admission = match self.native_transforms.try_admit() { let admission = match self.native_transforms.try_admit() {
@@ -116,13 +115,24 @@ impl NativeTaskExecutor {
self.native_metrics self.native_metrics
.observe_wait(metrics::duration_millis(wait_started.elapsed())); .observe_wait(metrics::duration_millis(wait_started.elapsed()));
let permit = permit?; let permit = permit?;
let decoded_bytes = self let memory = self.memory.clone();
.decoded_bytes let capacity = self.memory_budget_bytes;
.try_reserve(self.decoded_bytes_per_transform) let span = tracing::Span::current();
.ok_or(MediaError::AllocationFailed)?;
self.run_task(deadline, move || { self.run_task(deadline, move || {
let _span = span.enter();
let _permit = permit; let _permit = permit;
let _decoded_bytes = decoded_bytes; let bytes = cost()?;
if bytes > capacity {
warn!(
bytes,
capacity, "native work refused, larger than the memory budget"
);
return Err(MediaError::InvalidImageDimensions.into());
}
let Some(_reserved) = memory.try_reserve(bytes) else {
warn!(bytes, capacity, "native work refused, memory budget in use");
return Err(MediaError::AllocationFailed.into());
};
work() work()
}) })
.await .await
@@ -266,7 +276,7 @@ mod tests {
NativeTaskExecutor::new(NativeTaskExecutorSettings { NativeTaskExecutor::new(NativeTaskExecutorSettings {
max_native_transforms: permits, max_native_transforms: permits,
worker_queue_capacity: queue, worker_queue_capacity: queue,
decoded_bytes_per_transform: 1024, memory_budget_bytes: 1024,
native_metrics: metrics.native_transform(), native_metrics: metrics.native_transform(),
transform_metrics: metrics.transform(), transform_metrics: metrics.transform(),
}) })
@@ -288,6 +298,7 @@ mod tests {
let error = executor let error = executor
.run_native( .run_native(
Some(Instant::now() + Duration::from_millis(30)), Some(Instant::now() + Duration::from_millis(30)),
|| Ok(0),
move || { move || {
released.blocking_recv(); released.blocking_recv();
let _ = finished.blocking_send(()); let _ = finished.blocking_send(());
@@ -338,7 +349,11 @@ mod tests {
let metrics = metrics::Metrics::new(); let metrics = metrics::Metrics::new();
let executor = executor(&metrics, 1, 0); let executor = executor(&metrics, 1, 0);
let value = executor let value = executor
.run_native(Some(Instant::now() + Duration::from_secs(30)), || Ok(7u32)) .run_native(
Some(Instant::now() + Duration::from_secs(30)),
|| Ok(0),
|| Ok(7u32),
)
.await .await
.expect("the task completes"); .expect("the task completes");
assert_eq!(7, value); assert_eq!(7, value);
@@ -361,15 +376,19 @@ mod tests {
let holder = Arc::clone(&executor); let holder = Arc::clone(&executor);
let held = tokio::spawn(async move { let held = tokio::spawn(async move {
holder holder
.run_native(None, move || { .run_native(
released.blocking_recv(); None,
Ok(()) || Ok(0),
}) move || {
released.blocking_recv();
Ok(())
},
)
.await .await
}); });
tokio::time::sleep(Duration::from_millis(50)).await; tokio::time::sleep(Duration::from_millis(50)).await;
let error = executor let error = executor
.run_native(None, || Ok(())) .run_native(None, || Ok(0), || Ok(()))
.await .await
.expect_err("the admission queue is full"); .expect_err("the admission queue is full");
assert_eq!( assert_eq!(
@@ -393,7 +412,7 @@ mod tests {
let executor = executor(&metrics, 2, 4); let executor = executor(&metrics, 2, 4);
executor.begin_shutdown(); executor.begin_shutdown();
let error = executor let error = executor
.run_native(None, || Ok(())) .run_native(None, || Ok(0), || Ok(()))
.await .await
.expect_err("a closed executor admits nothing"); .expect_err("a closed executor admits nothing");
assert_eq!( assert_eq!(
@@ -402,4 +421,59 @@ mod tests {
); );
executor.wait_for_shutdown().await; executor.wait_for_shutdown().await;
} }
#[tokio::test]
async fn work_larger_than_the_memory_budget_is_refused_before_it_runs() {
let metrics = metrics::Metrics::new();
let executor = executor(&metrics, 1, 0);
let error = executor
.run_native(
None,
|| Ok(1025),
|| -> anyhow::Result<()> { panic!("refused work must not run") },
)
.await
.expect_err("the job exceeds the whole budget");
assert_eq!(
Some(&MediaError::InvalidImageDimensions),
error.downcast_ref::<MediaError>()
);
}
#[tokio::test]
async fn work_that_does_not_fit_beside_a_running_job_fails_fast_and_the_budget_frees() {
let metrics = metrics::Metrics::new();
let executor = Arc::new(executor(&metrics, 2, 0));
let (release, mut released) = mpsc::channel::<()>(1);
let (started, mut has_started) = mpsc::channel::<()>(1);
let holder = Arc::clone(&executor);
let held = tokio::spawn(async move {
holder
.run_native(
None,
|| Ok(1000),
move || {
let _ = started.blocking_send(());
released.blocking_recv();
Ok(())
},
)
.await
});
has_started.recv().await;
let error = executor
.run_native(None, || Ok(100), || Ok(()))
.await
.expect_err("the budget is in use");
assert_eq!(
Some(&MediaError::AllocationFailed),
error.downcast_ref::<MediaError>()
);
drop(release);
held.await.expect("held task").expect("held work");
executor
.run_native(None, || Ok(1024), || Ok(()))
.await
.expect("the released budget admits a job of the full size");
}
} }
@@ -3,6 +3,7 @@
use crate::{ use crate::{
byte_budget::BudgetedBytes, byte_budget::BudgetedBytes,
constants, constants,
image_quality::ImageQuality,
image_transform::ResizeMode, image_transform::ResizeMode,
media_process, mime, media_process, mime,
output_format::OutputFormat, output_format::OutputFormat,
@@ -20,7 +21,9 @@ use crate::{
media_response, media_response,
}, },
state::AppState, state::AppState,
transform::execution::{run_transform, transform_error_is_timeout}, transform::execution::{
VideoTransformOptions, run_transform, run_video_transform, transform_error_is_timeout,
},
}, },
}; };
use axum::{ use axum::{
@@ -176,12 +179,23 @@ pub(in crate::server) async fn thumbnail_handler(
Err(err) => return storage_error_response(&req.upload_filename, err), Err(err) => return storage_error_response(&req.upload_filename, err),
}; };
let media = if mime::category(&object.content_type) == Some(mime::Category::Video) { let media = if mime::category(&object.content_type) == Some(mime::Category::Video) {
match media_process::extract_video_thumbnail( let options = VideoTransformOptions {
&object.data, format: OutputFormat::WebP,
OutputFormat::WebP, width: None,
&app.media.limits(), height: None,
) { quality: ImageQuality::Auto,
deadline_ms: app.media.transforms().transform_deadline_ms(),
};
match run_video_transform(app.media.transforms(), object.data.clone(), options).await {
Ok(media) => media, Ok(media) => media,
Err(err) if transform_error_is_timeout(&err) => {
return text_with_source(
StatusCode::GATEWAY_TIMEOUT,
"Gateway Timeout",
"video_thumbnail_timeout",
err,
);
}
Err(err) => { Err(err) => {
return text_with_source( return text_with_source(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
@@ -253,11 +267,14 @@ pub(in crate::server) async fn frames_handler(
Ok(input) => input, Ok(input) => input,
Err(failure) => return failure.into_response(), Err(failure) => return failure.into_response(),
}; };
match media_process::extract_video_thumbnail( let options = VideoTransformOptions {
&input.data, format: OutputFormat::JPEG,
OutputFormat::JPEG, width: None,
&app.media.limits(), height: None,
) { quality: ImageQuality::Auto,
deadline_ms: app.media.transforms().transform_deadline_ms(),
};
match run_video_transform(app.media.transforms(), input.data, options).await {
Ok(frame) => { Ok(frame) => {
let encoded = general_purpose::STANDARD.encode(frame.bytes); let encoded = general_purpose::STANDARD.encode(frame.bytes);
json_response( json_response(
@@ -683,11 +700,15 @@ mod tests {
.media .media
.transforms() .transforms()
.tasks() .tasks()
.run_native(None, move || { .run_native(
let _ = started.blocking_send(()); None,
let _ = released.blocking_recv(); || Ok(0),
Ok(()) move || {
}) let _ = started.blocking_send(());
let _ = released.blocking_recv();
Ok(())
},
)
.await .await
}); });
has_started has_started
@@ -272,6 +272,8 @@ async fn serve_stored_svg_rasterized(
let format = OutputFormat::WebP; let format = OutputFormat::WebP;
let quality = ImageQuality::Lossless; let quality = ImageQuality::Lossless;
let options = media_process::ImageOptions { let options = media_process::ImageOptions {
width: Some(constants::SVG_RASTER_MAX_DIMENSION),
height: Some(constants::SVG_RASTER_MAX_DIMENSION),
format, format,
quality, quality,
animation: AnimationMode::Static, animation: AnimationMode::Static,
@@ -282,8 +284,8 @@ async fn serve_stored_svg_rasterized(
route: TransformRoute::Stored, route: TransformRoute::Stored,
asset_kind: None, asset_kind: None,
cache_identity, cache_identity,
width: None, width: options.width,
height: None, height: options.height,
format, format,
quality: Some(quality), quality: Some(quality),
animated: false, animated: false,
@@ -35,17 +35,32 @@ pub(in crate::server) async fn run_transform(
let deadline = deadline_instant(options.deadline_ms); let deadline = deadline_instant(options.deadline_ms);
let media_limits = runtime.limits(); let media_limits = runtime.limits();
let transform_metrics = runtime.metrics(); let transform_metrics = runtime.metrics();
let cost_data = data.clone();
let timed = runtime let timed = runtime
.tasks() .tasks()
.run_native(deadline, move || { .run_native(
let started = Instant::now(); deadline,
let media = move || {
media_process::transform_image(&data, &options, &media_limits, &transform_metrics)?; Ok(media_process::image_transform_cost(
Ok(TimedMedia { &cost_data,
media, &options,
elapsed_ms: metrics::duration_millis(started.elapsed()), &media_limits,
}) )?)
}) },
move || {
let started = Instant::now();
let media = media_process::transform_image(
&data,
&options,
&media_limits,
&transform_metrics,
)?;
Ok(TimedMedia {
media,
elapsed_ms: metrics::duration_millis(started.elapsed()),
})
},
)
.await?; .await?;
runtime.metrics().observe_image_duration(timed.elapsed_ms); runtime.metrics().observe_image_duration(timed.elapsed_ms);
request_log::record_stage(Stage::Transform, timed.elapsed_ms); request_log::record_stage(Stage::Transform, timed.elapsed_ms);
@@ -67,34 +82,42 @@ pub(in crate::server) async fn run_video_transform(
let deadline = deadline_instant(deadline_ms); let deadline = deadline_instant(deadline_ms);
let media_limits = runtime.limits(); let media_limits = runtime.limits();
let transform_metrics = runtime.metrics(); let transform_metrics = runtime.metrics();
let cost_bytes = data
.len()
.saturating_add(media_process::AV_NATIVE_COST_BYTES);
let timed = runtime let timed = runtime
.tasks() .tasks()
.run_native(deadline, move || { .run_native(
let started = Instant::now(); deadline,
let thumbnail = media_process::extract_video_thumbnail(&data, format, &media_limits)?; move || Ok(cost_bytes),
let media = if width.is_none() && height.is_none() { move || {
thumbnail let started = Instant::now();
} else { let thumbnail =
media_process::transform_image( media_process::extract_video_thumbnail(&data, format, &media_limits)?;
&thumbnail.bytes, let media = if width.is_none() && height.is_none() {
&ImageOptions { thumbnail
width, } else {
height, media_process::transform_image(
format, &thumbnail.bytes,
quality, &ImageOptions {
animation: AnimationMode::Static, width,
deadline_ms, height,
..Default::default() format,
}, quality,
&media_limits, animation: AnimationMode::Static,
&transform_metrics, deadline_ms,
)? ..Default::default()
}; },
Ok(TimedMedia { &media_limits,
media, &transform_metrics,
elapsed_ms: metrics::duration_millis(started.elapsed()), )?
}) };
}) Ok(TimedMedia {
media,
elapsed_ms: metrics::duration_millis(started.elapsed()),
})
},
)
.await?; .await?;
runtime.metrics().observe_video_duration(timed.elapsed_ms); runtime.metrics().observe_video_duration(timed.elapsed_ms);
request_log::record_stage(Stage::Transform, timed.elapsed_ms); request_log::record_stage(Stage::Transform, timed.elapsed_ms);
+49 -9
View File
@@ -59,8 +59,12 @@ use axum::{
use bytes::Bytes; use bytes::Bytes;
use std::{collections::HashMap, sync::Arc}; use std::{collections::HashMap, sync::Arc};
const DECODED_BYTES_PER_PIXEL: usize = 4; const NATIVE_MEMORY_HEADROOM_BYTES: usize = 512 << 20;
const DECODED_PIXEL_BUFFERS_PER_TRANSFORM: usize = 2; const NATIVE_MEMORY_FLOOR_BYTES: usize = 128 << 20;
const CGROUP_MEMORY_LIMIT_PATHS: [&str; 2] = [
"/sys/fs/cgroup/memory.max",
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
];
const CONTENT_TYPE_SNIFF_PREFIX_BYTES: usize = 8192; const CONTENT_TYPE_SNIFF_PREFIX_BYTES: usize = 8192;
pub(in crate::server) struct TransformRuntime { pub(in crate::server) struct TransformRuntime {
@@ -89,6 +93,18 @@ impl TransformRuntime {
metrics: &Arc<metrics::Metrics>, metrics: &Arc<metrics::Metrics>,
) -> anyhow::Result<Self> { ) -> anyhow::Result<Self> {
let limits = MediaLimits::default_from_config(); let limits = MediaLimits::default_from_config();
let memory_limit_bytes = process_memory_limit_bytes();
let memory_budget_bytes = native_memory_budget_bytes(
memory_limit_bytes,
cfg.media.transform_cache_capacity_bytes,
);
tracing::info!(
memory_limit_bytes,
transform_cache_bytes = cfg.media.transform_cache_capacity_bytes,
memory_budget_bytes,
max_native_transforms = cfg.media.max_native_transforms,
"native memory budget"
);
Ok(Self { Ok(Self {
limits, limits,
animation: AnimationLimits::new( animation: AnimationLimits::new(
@@ -99,7 +115,7 @@ impl TransformRuntime {
tasks: NativeTaskExecutor::new(NativeTaskExecutorSettings { tasks: NativeTaskExecutor::new(NativeTaskExecutorSettings {
max_native_transforms: cfg.media.max_native_transforms, max_native_transforms: cfg.media.max_native_transforms,
worker_queue_capacity: cfg.media.worker_queue_capacity, worker_queue_capacity: cfg.media.worker_queue_capacity,
decoded_bytes_per_transform: decoded_bytes_per_transform(&limits), memory_budget_bytes,
native_metrics: metrics.native_transform(), native_metrics: metrics.native_transform(),
transform_metrics: metrics.transform(), transform_metrics: metrics.transform(),
}), }),
@@ -144,12 +160,36 @@ impl TransformRuntime {
} }
} }
fn decoded_bytes_per_transform(limits: &MediaLimits) -> usize { fn native_memory_budget_bytes(memory_limit_bytes: usize, transform_cache_bytes: usize) -> usize {
limits let cache = transform_cache_bytes.min(memory_limit_bytes / 2);
.image_pixels() let headroom = NATIVE_MEMORY_HEADROOM_BYTES.min(memory_limit_bytes / 8);
.max(limits.animated_total_pixels()) memory_limit_bytes
.checked_mul(DECODED_BYTES_PER_PIXEL * DECODED_PIXEL_BUFFERS_PER_TRANSFORM) .saturating_sub(cache)
.expect("the native decoded image budget must not overflow") .saturating_sub(headroom)
.max(NATIVE_MEMORY_FLOOR_BYTES)
}
fn process_memory_limit_bytes() -> usize {
let physical = physical_memory_bytes();
CGROUP_MEMORY_LIMIT_PATHS
.iter()
.find_map(|path| {
std::fs::read_to_string(path)
.ok()?
.trim()
.parse::<usize>()
.ok()
})
.filter(|limit| *limit < physical)
.unwrap_or(physical)
}
fn physical_memory_bytes() -> usize {
let pages = unsafe { libc::sysconf(libc::_SC_PHYS_PAGES) };
let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
usize::try_from(pages)
.unwrap_or(0)
.saturating_mul(usize::try_from(page_size).unwrap_or(0))
} }
pub(in crate::server) async fn serve_bytes_or_transform( pub(in crate::server) async fn serve_bytes_or_transform(
@@ -199,3 +199,22 @@ async fn a_redundant_format_parameter_shares_the_implicit_transform_cache_entry(
); );
} }
} }
#[test]
fn the_budget_leaves_room_for_the_cache_and_headroom() {
assert_eq!(
2560 << 20,
native_memory_budget_bytes(4096 << 20, 1024 << 20)
);
}
#[test]
fn a_cache_larger_than_the_pod_takes_at_most_half_of_it() {
assert_eq!(192 << 20, native_memory_budget_bytes(512 << 20, 1 << 30));
assert_eq!(384 << 20, native_memory_budget_bytes(1 << 30, 1 << 30));
}
#[test]
fn the_budget_never_drops_below_its_floor() {
assert_eq!(128 << 20, native_memory_budget_bytes(64 << 20, 0));
}
@@ -30,6 +30,9 @@ pub fn apng_header(frames: u32) -> Vec<u8> {
data data
} }
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 fn synthetic_png(width: u32, height: u32) -> Vec<u8> { pub fn synthetic_png(width: u32, height: u32) -> Vec<u8> {
ensure_vips_init().unwrap(); ensure_vips_init().unwrap();
let mut pixels = vec![0u8; width as usize * height as usize * 4]; let mut pixels = vec![0u8; width as usize * height as usize * 4];
+4 -3
View File
@@ -10,9 +10,10 @@ pub use adversarial::{
}; };
pub use ffmpeg_cli::{ffmpeg_gen_media, ffmpeg_gen_mp4, ffmpeg_gen_rotated_mp4, ffmpeg_mirror_mp4}; pub use ffmpeg_cli::{ffmpeg_gen_media, ffmpeg_gen_mp4, ffmpeg_gen_rotated_mp4, ffmpeg_mirror_mp4};
pub use images::{ pub use images::{
animated_gif_fixture, animated_gif_frames, apng_header, first_webp_anim_frame_size, JXL_64_LOSSY_ALPHA, JXL_4100_LOSSY_ALPHA, animated_gif_fixture, animated_gif_frames,
gif_frame_delays_cs, gif_loop_count, minimal_gif, png_dimensions, synthetic_bmp, synthetic_png, apng_header, first_webp_anim_frame_size, gif_frame_delays_cs, gif_loop_count, minimal_gif,
webp_animation_loop_count, webp_canvas_size, webp_chunk_payloads, webp_with_metadata_chunk, png_dimensions, synthetic_bmp, synthetic_png, webp_animation_loop_count, webp_canvas_size,
webp_chunk_payloads, webp_with_metadata_chunk,
}; };
pub use media::{ pub use media::{
fixture_audio_mp3_with_png_cover_art, fixture_audio_mp4_with_attached_picture, fixture_audio_mp3_with_png_cover_art, fixture_audio_mp4_with_attached_picture,