diff --git a/crates/video/src/h264/decode/README.md b/crates/video/src/h264/decode/README.md index a73b8f2f4..4c352fd00 100644 --- a/crates/video/src/h264/decode/README.md +++ b/crates/video/src/h264/decode/README.md @@ -99,7 +99,8 @@ A picture is refused when the pictures the decoder would hold do not fit 1 GiB on 32-bit targets): the DPB (the larger of its slots at the new size and what it holds now), the picture being decoded, and what is in flight or waiting in the output queue. Pictures already held count at their own size, and a buffer shared by the DPB and the queue counts once. -The per-picture size cap applies as well. +The default per-picture luma cap is 2^25 samples; applications with a different bounded frame +policy can raise it with `Decoder::set_max_picture_luma_samples`. `configure_avcc` checks every entry of the record before it handles any: the first list holds SPS NAL units and the second PPS NAL units. A record that carries a picture slice is an error and diff --git a/crates/video/src/h264/decode/decoder.rs b/crates/video/src/h264/decode/decoder.rs index 6d5fe3e5d..01a130b35 100644 --- a/crates/video/src/h264/decode/decoder.rs +++ b/crates/video/src/h264/decode/decoder.rs @@ -115,6 +115,8 @@ pub struct Decoder { /// Bytes the picture store may take ([`crate::budget`]). memory_budget: u64, threads: usize, + /// Maximum reconstructed luma samples in one picture. + max_luma_samples: u64, /// Draft mode: skip deblocking of non-reference pictures ([`Decoder::set_draft`]). draft: bool, } @@ -179,6 +181,7 @@ impl Decoder { max_in_flight: threads.max(1) + 2, memory_budget: crate::budget::DECODE_MEMORY_BUDGET, threads: workers, + max_luma_samples: super::params::MAX_LUMA_SAMPLES, draft: false, } } @@ -391,7 +394,7 @@ impl Decoder { // Before the header fields that depend on the SPS: they are validated for the streams // we decode (a 10-bit slice may have a QP below 0), so a stream we cannot decode must // fail as unsupported here, not as invalid there (#626). - sps.check_supported()?; + sps.check_supported_with_limit(self.max_luma_samples)?; found = Some((pps.clone(), sps.clone())); Ok((&**pps, &**sps)) })?; @@ -468,6 +471,14 @@ impl Decoder { self.memory_budget = bytes; } + /// Set the maximum luma sample count allowed in a single decoded picture. + /// + /// The default is 2^25 samples. This is a per-picture geometry guard; the separate + /// memory budget controls aggregate decoded picture storage. + pub fn set_max_picture_luma_samples(&mut self, samples: u64) { + self.max_luma_samples = samples; + } + /// Refuse an SPS whose pictures would not fit the memory budget, before anything is allocated. fn check_budget(&self, sps: &Sps) -> Result<()> { let chroma = sps.chroma_array_type(); diff --git a/crates/video/src/h264/decode/params.rs b/crates/video/src/h264/decode/params.rs index 62ca789eb..b919efb7d 100644 --- a/crates/video/src/h264/decode/params.rs +++ b/crates/video/src/h264/decode/params.rs @@ -6,7 +6,7 @@ use craft_media::bitstream::BitReader; /// Maximum supported reconstructed luma area. 8K UHD fits comfortably while /// hostile SPS dimensions cannot allocate full pictures and decoder work state. -const MAX_LUMA_SAMPLES: u64 = 1 << 25; +pub(super) const MAX_LUMA_SAMPLES: u64 = 1 << 25; /// Scaling matrices after applying fall-back rules, stored in raster order (weightScale). #[derive(Clone, Debug, PartialEq, Eq)] @@ -527,7 +527,11 @@ impl Sps { } pub fn check_supported(&self) -> Result<()> { - if u64::from(self.width()).saturating_mul(u64::from(self.height())) > MAX_LUMA_SAMPLES { + self.check_supported_with_limit(MAX_LUMA_SAMPLES) + } + + pub(super) fn check_supported_with_limit(&self, max_luma_samples: u64) -> Result<()> { + if u64::from(self.width()).saturating_mul(u64::from(self.height())) > max_luma_samples { return unsupported("picture dimensions exceed the decoder memory limit"); } if !self.frame_mbs_only { @@ -744,6 +748,31 @@ mod tests { assert_eq!(sps.pic_order_cnt_type, 2); assert_eq!(sps.crop_rect(), (0, 0, 176, 144)); } + + #[test] + fn caller_can_raise_single_picture_sample_limit() { + let mut w = BitWriter::new(); + w.write_bits(66, 8); + w.write_bits(0xC0, 8); + w.write_bits(30, 8); + w.write_ue(0); + w.write_ue(0); + w.write_ue(2); + w.write_ue(1); + w.write_bit(false); + w.write_ue(511); // 8192 luma samples wide + w.write_ue(511); // 8192 luma samples high + w.write_bit(true); + w.write_bit(true); + w.write_bit(false); + w.write_bit(false); + w.rbsp_trailing(); + + let sps = Sps::parse(&w.finish()).unwrap(); + assert_eq!((sps.width(), sps.height()), (8192, 8192)); + assert!(sps.check_supported().is_err()); + assert!(sps.check_supported_with_limit(1 << 26).is_ok()); + } /// Baseline SPS with the given `ue(v)` overrides for the fields hostile streams can max out. fn hostile_sps(profile: u32, bit_depth: u32, log2_frame_num: u32, w: u32, h: u32, crop: u32) -> Vec { let mut bw = BitWriter::new(); diff --git a/crates/video/src/prores/README.md b/crates/video/src/prores/README.md index e3a9ea989..9831e3282 100644 --- a/crates/video/src/prores/README.md +++ b/crates/video/src/prores/README.md @@ -16,6 +16,8 @@ use craft_video::prores::*; let frame: Frame = decode_frame(&sample)?; // 10-bit 4:2:2 / 12-bit 4:4:4 let f10 = decode_frame_with(&sample, &DecodeOptions { bit_depth: Some(10), threads: true })?; decode_frame_into(&sample, &DecodeOptions::default(), &mut frame_buf)?; // reuses planes +let limits = DecodeLimits { max_pixels: 8192 * 8192, max_frame_bytes: 512 * 1024 * 1024 }; +let bounded = decode_frame_with_limits(&sample, &DecodeOptions::default(), &limits)?; let hdr: FrameHeader = probe(&sample)?; // header only // Encode (progressive). @@ -28,6 +30,11 @@ alpha: Option>, interlace: Interlace, color: ColorInfo { primaries, tra aspect_ratio, frame_rate_code }` holds planar samples with no row padding (`chroma_width()` is `ceil(width/2)` for 4:2:2). The colour codes are H.273 code points straight from the frame header. +`DecodeLimits` bounds both frame area and aggregate decoded plane storage before allocation. +`decode_frame_with` uses an aggregate limit of 300 MiB and does not impose an additional area +limit; use `decode_frame_with_limits` when the application needs a stricter pixel policy or a +different finite memory ceiling. + `Profile` = `Proxy` (`apco`), `Lt` (`apcs`), `Standard` (`apcn`), `Hq` (`apch`), `P4444` (`ap4h`), `P4444Xq` (`ap4x`). The frame itself doesn't say which profile it is (only the container fourcc does), so the decoder doesn't need to know. diff --git a/crates/video/src/prores/decode.rs b/crates/video/src/prores/decode.rs index f4670af60..7125d4641 100644 --- a/crates/video/src/prores/decode.rs +++ b/crates/video/src/prores/decode.rs @@ -11,6 +11,21 @@ use crate::prores::{AlphaType, ChromaFormat, Error, Frame, Interlace, Result}; /// Maximum aggregate decoded plane storage for one ProRes frame (300 MiB; 8K ProRes 4444 with alpha is ~265 MiB). const MAX_FRAME_BYTES: usize = 300 * 1024 * 1024; +/// Resource limits for decoding one ProRes frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DecodeLimits { + /// Maximum frame width × height. Checked before any plane allocation. + pub max_pixels: u64, + /// Maximum aggregate output plane storage in bytes. + pub max_frame_bytes: usize, +} + +impl Default for DecodeLimits { + fn default() -> Self { + Self { max_pixels: u64::MAX, max_frame_bytes: MAX_FRAME_BYTES } + } +} + /// Decoder options. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct DecodeOptions { @@ -105,15 +120,28 @@ impl Scratch { /// Decode one ProRes frame. pub fn decode_frame_with(data: &[u8], opts: &DecodeOptions) -> Result { + decode_frame_with_limits(data, opts, &DecodeLimits::default()) +} + +/// Decode one ProRes frame with explicit geometry and aggregate-memory limits. +pub fn decode_frame_with_limits(data: &[u8], opts: &DecodeOptions, limits: &DecodeLimits) -> Result { let mut f = Frame::new(0, 0, ChromaFormat::Yuv422, 10, false)?; - decode_frame_into(data, opts, &mut f)?; + decode_frame_into_with_limits(data, opts, limits, &mut f)?; Ok(f) } /// Decode one ProRes frame into `out`, reusing its plane allocations (the fastest way to decode /// a sequence). On error the contents of `out` are unspecified. pub fn decode_frame_into(data: &[u8], opts: &DecodeOptions, out: &mut Frame) -> Result<()> { + decode_frame_into_with_limits(data, opts, &DecodeLimits::default(), out) +} + +/// Decode one ProRes frame into `out` under explicit resource limits. +pub fn decode_frame_into_with_limits(data: &[u8], opts: &DecodeOptions, limits: &DecodeLimits, out: &mut Frame) -> Result<()> { let (hdr, mut off, frame_size) = FrameHeader::parse(data)?; + if u64::from(hdr.width) * u64::from(hdr.height) > limits.max_pixels { + return Err(Error::Unsupported("frame size")); + } let data = &data[..frame_size]; let depth = opts.bit_depth.unwrap_or(match hdr.chroma { ChromaFormat::Yuv422 => 10, @@ -131,7 +159,7 @@ pub fn decode_frame_into(data: &[u8], opts: &DecodeOptions, out: &mut Frame) -> .checked_add(chroma.checked_mul(2).ok_or(Error::Invalid("chroma plane count overflows"))?) .and_then(|n| if hdr.alpha == AlphaType::None { Some(n) } else { n.checked_add(luma) }) .ok_or(Error::Invalid("frame plane count overflows"))?; - if samples.checked_mul(std::mem::size_of::()).is_none_or(|n| n > MAX_FRAME_BYTES) { + if samples.checked_mul(std::mem::size_of::()).is_none_or(|n| n > limits.max_frame_bytes) { return Err(Error::Invalid("frame dimensions exceed the decoder memory limit")); } let interlaced = hdr.interlace != Interlace::Progressive; @@ -564,6 +592,44 @@ mod alpha_decode_tests { assert_eq!(slice_quantiser(224).unwrap(), qscale(224)); } + #[test] + fn custom_limits_preserve_large_4444_geometry_before_allocating() { + let hdr = FrameHeader { + header_size: 20, + bitstream_version: 1, + encoder_id: *b"test", + width: 8192, + height: 8192, + chroma: ChromaFormat::Yuv444, + interlace: Interlace::Progressive, + aspect_ratio: 0, + frame_rate_code: 0, + color: crate::prores::ColorInfo::default(), + alpha: AlphaType::Bits16, + qmat_luma: [4; 64], + qmat_chroma: [4; 64], + luma_matrix_present: false, + chroma_matrix_present: false, + }; + let header = hdr.write(); + let frame_size = 8 + header.len(); + let mut data = Vec::with_capacity(frame_size); + data.extend_from_slice(&(frame_size as u32).to_be_bytes()); + data.extend_from_slice(&crate::prores::header::FRAME_ID); + data.extend_from_slice(&header); + + let defaults = DecodeLimits { max_pixels: 8192 * 8192, ..DecodeLimits::default() }; + assert!(matches!( + decode_frame_with_limits(&data, &DecodeOptions::default(), &defaults), + Err(Error::Invalid("frame dimensions exceed the decoder memory limit")) + )); + + let soundcraft = DecodeLimits { max_pixels: 8192 * 8192, max_frame_bytes: 512 * 1024 * 1024 }; + assert!(matches!(decode_frame_with_limits(&data, &DecodeOptions::default(), &soundcraft), Err(Error::Truncated))); + let too_few_pixels = DecodeLimits { max_pixels: 8192 * 8192 - 1, ..soundcraft }; + assert!(matches!(decode_frame_with_limits(&data, &DecodeOptions::default(), &too_few_pixels), Err(Error::Unsupported("frame size")))); + } + #[test] fn alpha_decoder_requires_complete_bounded_runs() { let mut out = [0u16; 1]; diff --git a/crates/video/src/prores/mod.rs b/crates/video/src/prores/mod.rs index 4af49f913..5979c19af 100644 --- a/crates/video/src/prores/mod.rs +++ b/crates/video/src/prores/mod.rs @@ -18,7 +18,9 @@ mod encode; pub mod header; mod tables; -pub use decode::{DecodeOptions, decode_frame, decode_frame_into, decode_frame_with, probe}; +pub use decode::{ + DecodeLimits, DecodeOptions, decode_frame, decode_frame_into, decode_frame_into_with_limits, decode_frame_with, decode_frame_with_limits, probe, +}; pub use encode::{Encoder, EncoderConfig}; pub use header::{FrameHeader, PictureHeader};