mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 18:09:21 +00:00
security(media): bound generator frames, imported audio dimensions, and render images (#3080)
* security(media): bound generator frames, imported audio dimensions, and render images * fix(media): unwrap render result in demo_frames example * fix(render, codecs): guard zero-sized images, check post-scale sequence pixels, and bound combined MXF channels * fix(codecs): annotate channels as u32 in MXF sound track combination * fix(render): preserve exact output_size scaling calculation for renderable_sequence validation * fix(media,render): reject unsupported audio dimensions and invalid render scale Sample rates above MAX_AUDIO_SAMPLE_RATE were clamped without resampling, so a 768 kHz stream was reported as 384 kHz and played at half speed. Reject them instead, and drop the stream at the four codec call sites rather than advertising dimensions the samples do not match. output_size now treats a non-finite or non-positive scale as 1.0 instead of casting it to zero and collapsing the frame to 1x1. * fix(media): keep Matroska audio tracks and their metadata together; guard empty images A track whose advertised sample rate or channel count is refused is dropped before the track list is built, so atracks[k] and audio_streams[k] stay the same track. over_black_with_alpha_rgba8 and Image::transformed return early for an empty image instead of chunking by zero.
This commit is contained in:
@@ -337,10 +337,12 @@ impl AviSource {
|
||||
let mut audio_streams = Vec::new();
|
||||
for &s in &astreams {
|
||||
let Some(st) = file.streams.get(s) else { continue };
|
||||
let w = st.audio.clone().unwrap_or_default();
|
||||
let mut w = st.audio.clone().unwrap_or_default();
|
||||
let Ok((sample_rate, channels)) = filmcraft_media::bounded_audio_dimensions(w.sample_rate, u32::from(w.channels)) else { continue };
|
||||
w.sample_rate = sample_rate;
|
||||
w.channels = u16::try_from(channels).unwrap_or(u16::MAX);
|
||||
let (codec, label) = audio_codec(&w);
|
||||
let mut sample_rate = w.sample_rate.max(1);
|
||||
let mut channels = u32::from(w.channels.max(1));
|
||||
let (mut sample_rate, mut channels) = (sample_rate, channels);
|
||||
let (pieces, first, starts) = match &codec {
|
||||
ACodec::Pcm(cfg) => {
|
||||
let block = usize::from(w.block_align.max(1)).max((usize::from(cfg.bits).div_ceil(8)) * channels as usize);
|
||||
@@ -537,14 +539,18 @@ impl MediaSource for AviSource {
|
||||
return Err(MediaError::Unsupported(format!("{name} audio")));
|
||||
}
|
||||
let ch = ainfo.channels.max(1) as usize;
|
||||
let ratio = f64::from(ainfo.sample_rate) / f64::from(sample_rate.max(1));
|
||||
let source_frames = filmcraft_media::resample_source_frames(frames, ainfo.sample_rate, sample_rate)?;
|
||||
let need = source_frames.checked_add(2).ok_or_else(|| MediaError::Decode("audio source window overflows".into()))?;
|
||||
filmcraft_media::validate_audio_buffer_shape(ch, need)?;
|
||||
filmcraft_media::validate_audio_buffer_shape(ch, frames)?;
|
||||
let ratio = f64::from(ainfo.sample_rate) / f64::from(sample_rate);
|
||||
let s0 = (start as f64 * ratio).floor() as i64;
|
||||
let need = (frames as f64 * ratio).ceil() as i64 + 2;
|
||||
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need.max(0) as usize]; ch];
|
||||
let need_i64 = i64::try_from(need).unwrap_or(i64::MAX);
|
||||
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need]; ch];
|
||||
let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut i = a.starts.partition_point(|&x| x <= s0).saturating_sub(1);
|
||||
while let Some(&pk_start) = a.starts.get(i) {
|
||||
if pk_start >= s0 + need {
|
||||
if pk_start >= s0.saturating_add(need_i64) {
|
||||
break;
|
||||
}
|
||||
let pk = self.audio_packet(a, &mut st, ainfo, i)?;
|
||||
|
||||
+25
-18
@@ -142,6 +142,20 @@ fn codec_delay_ticks(t: &filmcraft_matroska::Track) -> i64 {
|
||||
i64::try_from((t.codec_delay_ns as i128 + scale_ns / 2) / scale_ns).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
/// The stream description of an audio track, `None` when its advertised sample rate or channel
|
||||
/// count is out of range (see [`filmcraft_media::bounded_audio_dimensions`]).
|
||||
fn audio_info(t: &filmcraft_matroska::Track) -> Option<AudioStreamInfo> {
|
||||
let a = t.audio.clone().unwrap_or_default();
|
||||
let mut rate = a.output_sampling_frequency.unwrap_or(a.sampling_frequency).round().max(1.0) as u32;
|
||||
let mut channels = (a.channels as u32).max(1);
|
||||
if let Some(h) = opus_head(&t.codec) {
|
||||
rate = crate::audio::OPUS_RATE;
|
||||
channels = h.channels as u32;
|
||||
}
|
||||
let (rate, channels) = filmcraft_media::bounded_audio_dimensions(rate, channels).ok()?;
|
||||
Some(AudioStreamInfo { sample_rate: rate, channels, codec: codec_label(&t.codec), bits_per_sample: a.bit_depth.map(|b| b as u32) })
|
||||
}
|
||||
|
||||
/// The track timestamp that media time zero stands for (#715): the earliest start among the
|
||||
/// playable tracks. Block timestamps count from the start of the Segment, and a remux that keeps
|
||||
/// its source's timestamps (`ffmpeg -copyts`, a cut from a recording) starts its first picture
|
||||
@@ -339,7 +353,9 @@ impl MkvSource {
|
||||
.tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, t)| t.kind == TrackKind::Audio && !t.samples.is_empty())
|
||||
// A track whose advertised dimensions are refused is no stream at all, so `atracks[k]` and
|
||||
// `info.audio_streams[k]` stay the same track.
|
||||
.filter(|(_, t)| t.kind == TrackKind::Audio && !t.samples.is_empty() && audio_info(t).is_some())
|
||||
.map(|(i, _)| i)
|
||||
.take(filmcraft_media::MAX_AUDIO_STREAMS)
|
||||
.collect();
|
||||
@@ -407,20 +423,7 @@ impl MkvSource {
|
||||
hdr: v.color.as_ref().and_then(hdr_metadata),
|
||||
}
|
||||
});
|
||||
let audio_streams: Vec<AudioStreamInfo> = atracks
|
||||
.iter()
|
||||
.filter_map(|&i| file.tracks.get(i))
|
||||
.map(|t| {
|
||||
let a = t.audio.clone().unwrap_or_default();
|
||||
let mut rate = a.output_sampling_frequency.unwrap_or(a.sampling_frequency).round().max(1.0) as u32;
|
||||
let mut channels = (a.channels as u32).max(1);
|
||||
if let Some(h) = opus_head(&t.codec) {
|
||||
rate = crate::audio::OPUS_RATE;
|
||||
channels = h.channels as u32;
|
||||
}
|
||||
AudioStreamInfo { sample_rate: rate, channels, codec: codec_label(&t.codec), bits_per_sample: a.bit_depth.map(|b| b as u32) }
|
||||
})
|
||||
.collect();
|
||||
let audio_streams: Vec<AudioStreamInfo> = atracks.iter().filter_map(|&i| file.tracks.get(i)).filter_map(audio_info).collect();
|
||||
let info = MediaInfo {
|
||||
name: name.to_string(),
|
||||
kind: if video.is_some() { MediaKind::Movie } else { MediaKind::AudioOnly },
|
||||
@@ -628,15 +631,19 @@ impl MediaSource for MkvSource {
|
||||
let ainfo = self.info.audio_streams.get(stream).ok_or(MediaError::NoStream("audio"))?;
|
||||
let src_rate = ainfo.sample_rate;
|
||||
let ch = ainfo.channels.max(1) as usize;
|
||||
let source_frames = filmcraft_media::resample_source_frames(frames, src_rate, sample_rate)?;
|
||||
let need = source_frames.checked_add(2).ok_or_else(|| MediaError::Decode("audio source window overflows".into()))?;
|
||||
filmcraft_media::validate_audio_buffer_shape(ch, need)?;
|
||||
filmcraft_media::validate_audio_buffer_shape(ch, frames)?;
|
||||
let ratio = src_rate as f64 / sample_rate as f64;
|
||||
let s0 = (start as f64 * ratio).floor() as i64;
|
||||
let need = (frames as f64 * ratio).ceil() as i64 + 2;
|
||||
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need.max(0) as usize]; ch];
|
||||
let need_i64 = i64::try_from(need).unwrap_or(i64::MAX);
|
||||
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need]; ch];
|
||||
let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut i = a.starts.partition_point(|&x| x <= s0).saturating_sub(1);
|
||||
while i < n_packets {
|
||||
let Some(&pk_start) = a.starts.get(i) else { break };
|
||||
if pk_start >= s0 + need {
|
||||
if pk_start >= s0.saturating_add(need_i64) {
|
||||
break;
|
||||
}
|
||||
let pk = self.audio_packet(a, ainfo, &mut st, i)?;
|
||||
|
||||
@@ -484,20 +484,24 @@ impl MediaSource for Mp4Source {
|
||||
let ainfo = self.info.audio_streams.get(stream).ok_or(MediaError::NoStream("audio"))?;
|
||||
let src_rate = ainfo.sample_rate;
|
||||
let ch = ainfo.channels.max(1) as usize;
|
||||
let source_frames = filmcraft_media::resample_source_frames(frames, src_rate, sample_rate)?;
|
||||
let need = source_frames.checked_add(2).ok_or_else(|| MediaError::Decode("audio source window overflows".into()))?;
|
||||
filmcraft_media::validate_audio_buffer_shape(ch, need)?;
|
||||
filmcraft_media::validate_audio_buffer_shape(ch, frames)?;
|
||||
// Map the requested window to source samples (edit list offset applied: presentation = pts + edit_offset).
|
||||
let ratio = src_rate as f64 / sample_rate as f64;
|
||||
let p0 = (start as f64 * ratio).floor() as i64;
|
||||
// (in i128: a hostile edit list can make the offset large enough to overflow i64)
|
||||
let offset = i128::from(a.offset) * i128::from(src_rate) / i128::from(track.timescale.max(1));
|
||||
let s0 = i64::try_from(i128::from(p0) - offset).unwrap_or(if offset > 0 { i64::MIN } else { i64::MAX });
|
||||
let need = (frames as f64 * ratio).ceil() as i64 + 2;
|
||||
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need.max(0) as usize]; ch];
|
||||
let need_i64 = i64::try_from(need).unwrap_or(i64::MAX);
|
||||
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need]; ch];
|
||||
let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let first = a.starts.partition_point(|&x| x <= s0.max(0)).saturating_sub(1);
|
||||
let mut i = first;
|
||||
while let Some(sample) = track.samples.get(i) {
|
||||
let Some(&pk_start) = a.starts.get(i) else { break };
|
||||
if pk_start >= s0.saturating_add(need) {
|
||||
if pk_start >= s0.saturating_add(need_i64) {
|
||||
break;
|
||||
}
|
||||
let pk = self.audio_packet(a, ainfo, &mut st, i)?;
|
||||
@@ -589,7 +593,8 @@ fn audio_stream_info(t: &filmcraft_isobmff::Track, bytes: &crate::Src) -> Option
|
||||
}
|
||||
c => codec_label(c),
|
||||
};
|
||||
Some(AudioStreamInfo { sample_rate: rate.max(1), channels: ch.max(1), codec, bits_per_sample: bits })
|
||||
let (rate, ch) = filmcraft_media::bounded_audio_dimensions(rate, ch).ok()?;
|
||||
Some(AudioStreamInfo { sample_rate: rate, channels: ch, codec, bits_per_sample: bits })
|
||||
}
|
||||
|
||||
fn codec_label(c: &CodecConfig) -> String {
|
||||
|
||||
@@ -330,7 +330,7 @@ impl MxfSource {
|
||||
if let Some(&a0) = atracks.first() {
|
||||
let si0 = file.tracks[a0].sound.clone().unwrap_or_default();
|
||||
let rate = si0.sample_rate;
|
||||
let mut channels = 0;
|
||||
let mut channels: u32 = 0;
|
||||
for &ai in &atracks {
|
||||
let t = &file.tracks[ai];
|
||||
let Some(si) = t.sound.as_ref() else { continue };
|
||||
@@ -342,11 +342,18 @@ impl MxfSource {
|
||||
let stored = t.stored_sample_frames().saturating_sub(first);
|
||||
let presented = t.duration.map_or(stored, |du| ((du as i128 * n as i128 / d.max(1) as i128) as u64).min(stored));
|
||||
aframes = aframes.min(presented);
|
||||
channels += si.channels.max(1);
|
||||
let next =
|
||||
channels.checked_add(si.channels.max(1)).ok_or_else(|| CodecError::Unsupported("MXF: combined audio channel count overflows".into()))?;
|
||||
if next > filmcraft_media::MAX_AUDIO_CHANNELS {
|
||||
return Err(CodecError::Unsupported(format!("MXF: combined audio exceeds {} channels", filmcraft_media::MAX_AUDIO_CHANNELS)));
|
||||
}
|
||||
channels = next;
|
||||
combined.push((ai, first));
|
||||
}
|
||||
let sr = if rate.is_valid() { (rate.num as f64 / rate.den as f64).round() as u32 } else { 48_000 };
|
||||
audio = Some(AudioStreamInfo { sample_rate: sr.max(1), channels, codec: file.tracks[a0].codec.name().into(), bits_per_sample: Some(si0.bits) });
|
||||
if let Ok((sr, channels)) = filmcraft_media::bounded_audio_dimensions(sr, channels) {
|
||||
audio = Some(AudioStreamInfo { sample_rate: sr, channels, codec: file.tracks[a0].codec.name().into(), bits_per_sample: Some(si0.bits) });
|
||||
}
|
||||
}
|
||||
if aframes == u64::MAX {
|
||||
aframes = 0;
|
||||
@@ -461,15 +468,20 @@ impl MediaSource for MxfSource {
|
||||
let ainfo = self.info.audio().ok_or(MediaError::NoStream("audio"))?;
|
||||
let src_rate = ainfo.sample_rate;
|
||||
let ch = ainfo.channels.max(1) as usize;
|
||||
let exact = src_rate == sample_rate;
|
||||
let source_frames = if exact { frames } else { filmcraft_media::resample_source_frames(frames, src_rate, sample_rate)? };
|
||||
let need =
|
||||
if exact { source_frames } else { source_frames.checked_add(2).ok_or_else(|| MediaError::Decode("audio source window overflows".into()))? };
|
||||
filmcraft_media::validate_audio_buffer_shape(ch, need)?;
|
||||
filmcraft_media::validate_audio_buffer_shape(ch, frames)?;
|
||||
let mut out = AudioBuffer::silence(sample_rate, ch, frames);
|
||||
let ratio = src_rate as f64 / sample_rate as f64;
|
||||
let exact = src_rate == sample_rate;
|
||||
let s0 = if exact { start } else { (start as f64 * ratio).floor() as i64 };
|
||||
let need = if exact { frames as i64 } else { (frames as f64 * ratio).ceil() as i64 + 2 };
|
||||
let need_i64 = i64::try_from(need).unwrap_or(i64::MAX);
|
||||
// clip the request to the presented range [0, aframes)
|
||||
let a = s0.max(0);
|
||||
let b = (s0 + need).min(self.aframes as i64);
|
||||
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need.max(0) as usize]; ch];
|
||||
let b = s0.saturating_add(need_i64).min(self.aframes as i64);
|
||||
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need]; ch];
|
||||
if b > a {
|
||||
let mut c0 = 0;
|
||||
for &(ti, first) in &self.atracks {
|
||||
|
||||
@@ -151,7 +151,8 @@ fn gpu_blend_modes_match_cpu() {
|
||||
1.0,
|
||||
24,
|
||||
filmcraft_time::FrameRate::FPS_24,
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
let demo = Arc::new(VideoFrame::rgba8(320, 180, pixels));
|
||||
let base = ramp_layer(200, 180, 3);
|
||||
let top = ramp_layer(160, 120, 0);
|
||||
|
||||
@@ -116,7 +116,8 @@ fn gpu_matches_cpu_plan() {
|
||||
1.0,
|
||||
24,
|
||||
filmcraft_time::FrameRate::FPS_24,
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
let rgba = Arc::new(VideoFrame::rgba8(320, 180, pixels));
|
||||
let (w, h) = (320usize, 180usize);
|
||||
let plan = FramePlan::Layers {
|
||||
@@ -464,7 +465,8 @@ fn gpu_draws_yuv_alpha_plane_like_the_cpu() {
|
||||
1.0,
|
||||
24,
|
||||
filmcraft_time::FrameRate::FPS_24,
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
let background = Arc::new(VideoFrame::rgba8(320, 180, pixels));
|
||||
for bits in [8, 10, 12] {
|
||||
let mut c = GpuCompositor::new(&dev, &q);
|
||||
|
||||
@@ -12,7 +12,7 @@ fn main() {
|
||||
let rows = gens.len().div_ceil(cols);
|
||||
let mut sheet = image::RgbaImage::new(w * cols as u32, h * rows as u32);
|
||||
for (i, g) in gens.iter().enumerate() {
|
||||
let px = render(g, w, h, 3.3, 79, FrameRate::FPS_23_976);
|
||||
let px = render(g, w, h, 3.3, 79, FrameRate::FPS_23_976).unwrap();
|
||||
let img = image::RgbaImage::from_raw(w, h, px).unwrap();
|
||||
image::imageops::overlay(&mut sheet, &img, ((i % cols) as u32 * w) as i64, ((i / cols) as u32 * h) as i64);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::digits::{draw_text, text_width};
|
||||
use crate::{AudioStreamInfo, FrameCache, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result, VideoStreamInfo};
|
||||
|
||||
/// Generator frames are RGBA8 and cached. Keep one project-requested frame within the media
|
||||
/// working-set budget before allocating it.
|
||||
const MAX_GENERATOR_PIXELS: u64 = 1 << 25;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum DemoScene {
|
||||
Aurora,
|
||||
@@ -153,7 +157,7 @@ impl MediaSource for GeneratorSource {
|
||||
let (w, h) = (((v.width as f32 * s).round() as u32).max(2), ((v.height as f32 * s).round() as u32).max(2));
|
||||
let t = rate.tick_of(frame).seconds() as f32;
|
||||
self.cache.get_or_insert_with((frame, bucket), || {
|
||||
let px = render(&self.generator, w, h, t, frame, rate);
|
||||
let px = render(&self.generator, w, h, t, frame, rate)?;
|
||||
Ok(Arc::new(VideoFrame::rgba8(w, h, px).with_pts(rate.tick_of(frame))))
|
||||
})
|
||||
}
|
||||
@@ -291,9 +295,16 @@ fn to8(c: [f32; 3]) -> [u8; 4] {
|
||||
}
|
||||
|
||||
/// Render a generator frame as straight-alpha sRGB RGBA8.
|
||||
pub fn render(g: &Generator, w: u32, h: u32, t: f32, frame: i64, rate: FrameRate) -> Vec<u8> {
|
||||
pub fn render(g: &Generator, w: u32, h: u32, t: f32, frame: i64, rate: FrameRate) -> Result<Vec<u8>> {
|
||||
let pixels = u64::from(w).checked_mul(u64::from(h)).ok_or_else(|| MediaError::Decode("generator frame dimensions overflow".into()))?;
|
||||
if pixels > MAX_GENERATOR_PIXELS {
|
||||
return Err(MediaError::Decode(format!("generator frame has {pixels} pixels; limit is {MAX_GENERATOR_PIXELS}")));
|
||||
}
|
||||
let (wu, hu) = (w as usize, h as usize);
|
||||
let mut px = vec![0u8; wu * hu * 4];
|
||||
let len = wu.checked_mul(hu).and_then(|n| n.checked_mul(4)).ok_or_else(|| MediaError::Decode("generator frame byte count overflows".into()))?;
|
||||
let mut px = Vec::new();
|
||||
px.try_reserve_exact(len).map_err(|_| MediaError::Decode("cannot allocate generator frame".into()))?;
|
||||
px.resize(len, 0);
|
||||
match g {
|
||||
Generator::BarsAndTone => bars(&mut px, wu, hu),
|
||||
Generator::ColorMatte { color } => {
|
||||
@@ -315,7 +326,7 @@ pub fn render(g: &Generator, w: u32, h: u32, t: f32, frame: i64, rate: FrameRate
|
||||
});
|
||||
}
|
||||
}
|
||||
px
|
||||
Ok(px)
|
||||
}
|
||||
|
||||
fn bars(px: &mut [u8], w: usize, h: usize) {
|
||||
@@ -558,13 +569,14 @@ mod tests {
|
||||
let g = GeneratorSource::demo(DemoScene::OceanSunset);
|
||||
let a = g.video_frame(FrameRequest { time: Tick(TICKS_PER_SECOND), scale: 0.25 }).unwrap();
|
||||
assert_eq!((a.width, a.height), (480, 270));
|
||||
let b = render(&Generator::Demo(DemoScene::OceanSunset), 480, 270, FrameRate::FPS_23_976.tick_of(23).seconds() as f32, 23, FrameRate::FPS_23_976);
|
||||
let b =
|
||||
render(&Generator::Demo(DemoScene::OceanSunset), 480, 270, FrameRate::FPS_23_976.tick_of(23).seconds() as f32, 23, FrameRate::FPS_23_976).unwrap();
|
||||
assert_eq!(a.to_rgba8(), b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bars_values() {
|
||||
let px = render(&Generator::BarsAndTone, 800, 600, 0.0, 0, FrameRate::FPS_25);
|
||||
let px = render(&Generator::BarsAndTone, 800, 600, 0.0, 0, FrameRate::FPS_25).unwrap();
|
||||
// first bar after the side panel is 75% white
|
||||
let i = (10 * 800 + 150) * 4;
|
||||
assert_eq!(&px[i..i + 4], &[191, 191, 191, 255]);
|
||||
@@ -685,10 +697,16 @@ mod tests {
|
||||
#[test]
|
||||
fn all_scenes_render() {
|
||||
for s in DemoScene::ALL {
|
||||
let px = render(&Generator::Demo(s), 64, 36, 1.5, 36, FrameRate::FPS_24);
|
||||
let px = render(&Generator::Demo(s), 64, 36, 1.5, 36, FrameRate::FPS_24).unwrap();
|
||||
assert!(px.as_chunks::<4>().0.iter().any(|p| p[0] > 20 || p[1] > 20 || p[2] > 20));
|
||||
}
|
||||
let px = render(&Generator::CountingLeader, 64, 36, 1.5, 36, FrameRate::FPS_24);
|
||||
let px = render(&Generator::CountingLeader, 64, 36, 1.5, 36, FrameRate::FPS_24).unwrap();
|
||||
assert_eq!(px.len(), 64 * 36 * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_generator_frames_are_rejected_before_allocation() {
|
||||
let err = render(&Generator::BlackVideo, 32_768, 32_768, 0.0, 0, FrameRate::FPS_24).unwrap_err();
|
||||
assert!(err.to_string().contains("limit"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,59 @@ pub enum MediaError {
|
||||
|
||||
pub type Result<T> = std::result::Result<T, MediaError>;
|
||||
|
||||
/// Up to 384 kHz audio (surpassing professional 192 kHz formats).
|
||||
pub const MAX_AUDIO_SAMPLE_RATE: u32 = 384_000;
|
||||
/// Up to 64 discrete channels (exceeding standard 32-channel immersive layouts).
|
||||
pub const MAX_AUDIO_CHANNELS: u32 = 64;
|
||||
/// Bounded planar audio buffer: 64 channels * 192 kHz * 10 seconds of f32 is ~492 MiB.
|
||||
pub const MAX_AUDIO_BUFFER_BYTES: usize = 512 * 1024 * 1024;
|
||||
|
||||
/// Bound container-advertised audio stream dimensions before they are trusted by the app or
|
||||
/// allocator.
|
||||
///
|
||||
/// Out-of-range values are rejected rather than clamped: publishing a lower sample rate without
|
||||
/// resampling the samples would make the stream play at the wrong speed, and a clamped channel
|
||||
/// count would misdescribe the interleaving.
|
||||
pub fn bounded_audio_dimensions(sample_rate: u32, channels: u32) -> Result<(u32, u32)> {
|
||||
if sample_rate == 0 || sample_rate > MAX_AUDIO_SAMPLE_RATE {
|
||||
return Err(MediaError::Decode(format!("unsupported audio sample rate {sample_rate}")));
|
||||
}
|
||||
if channels == 0 || channels > MAX_AUDIO_CHANNELS {
|
||||
return Err(MediaError::Decode(format!("unsupported audio channel count {channels}")));
|
||||
}
|
||||
Ok((sample_rate, channels))
|
||||
}
|
||||
|
||||
/// Check an adapter-owned planar `f32` audio allocation before constructing it.
|
||||
pub fn validate_audio_buffer_shape(channels: usize, frames: usize) -> Result<()> {
|
||||
if channels == 0 || channels > MAX_AUDIO_CHANNELS as usize {
|
||||
return Err(MediaError::Decode(format!("unsupported audio channel count {channels}")));
|
||||
}
|
||||
let bytes = channels
|
||||
.checked_mul(frames)
|
||||
.and_then(|samples| samples.checked_mul(std::mem::size_of::<f32>()))
|
||||
.ok_or_else(|| MediaError::Decode("audio buffer dimensions overflow".into()))?;
|
||||
if bytes > MAX_AUDIO_BUFFER_BYTES {
|
||||
return Err(MediaError::Decode(format!("audio buffer of {bytes} bytes exceeds the {} byte limit", MAX_AUDIO_BUFFER_BYTES)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Number of source frames needed to resample `frames` output frames. Arithmetic is checked so
|
||||
/// corrupted rates cannot wrap into a small allocation.
|
||||
pub fn resample_source_frames(frames: usize, source_rate: u32, output_rate: u32) -> Result<usize> {
|
||||
if output_rate == 0 {
|
||||
return Err(MediaError::Decode("zero requested audio sample rate".into()));
|
||||
}
|
||||
let source = usize::try_from(source_rate).map_err(|_| MediaError::Decode("invalid source sample rate".into()))?;
|
||||
let output = usize::try_from(output_rate).map_err(|_| MediaError::Decode("invalid output sample rate".into()))?;
|
||||
frames
|
||||
.checked_mul(source)
|
||||
.and_then(|n| n.checked_add(output.saturating_sub(1)))
|
||||
.map(|n| n / output)
|
||||
.ok_or_else(|| MediaError::Decode("resampled audio window overflows".into()))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum MediaKind {
|
||||
/// Video, possibly with audio.
|
||||
@@ -257,4 +310,15 @@ mod importable_tests {
|
||||
}
|
||||
assert!(!is_importable(Path::new("notes.txt")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_audio_dimensions_are_bounded_before_adapters_allocate() {
|
||||
assert!(bounded_audio_dimensions(0, 0).is_err());
|
||||
assert!(bounded_audio_dimensions(u32::MAX, u32::MAX).is_err());
|
||||
assert!(bounded_audio_dimensions(MAX_AUDIO_SAMPLE_RATE + 1, 2).is_err());
|
||||
assert_eq!(bounded_audio_dimensions(48_000, 2).ok(), Some((48_000, 2)));
|
||||
assert!(validate_audio_buffer_shape(MAX_AUDIO_CHANNELS as usize, MAX_AUDIO_BUFFER_BYTES / 4 / MAX_AUDIO_CHANNELS as usize).is_ok());
|
||||
assert!(validate_audio_buffer_shape(MAX_AUDIO_CHANNELS as usize, MAX_AUDIO_BUFFER_BYTES / 4 / MAX_AUDIO_CHANNELS as usize + 1).is_err());
|
||||
assert!(resample_source_frames(usize::MAX, MAX_AUDIO_SAMPLE_RATE, 1).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+19
-1
@@ -115,7 +115,13 @@ impl WavSource {
|
||||
let (tag, ch, rate, bits) = fmt.ok_or_else(|| MediaError::Decode(format!("{name}: missing fmt chunk")))?;
|
||||
let (off, dlen) = data.ok_or_else(|| MediaError::Decode(format!("{name}: missing data chunk")))?;
|
||||
let float = tag == 3;
|
||||
if !(tag == 1 || float) || ch == 0 || !matches!(bits, 8 | 16 | 24 | 32 | 64) {
|
||||
if !(tag == 1 || float)
|
||||
|| ch == 0
|
||||
|| rate == 0
|
||||
|| u32::from(ch) > crate::MAX_AUDIO_CHANNELS
|
||||
|| rate > crate::MAX_AUDIO_SAMPLE_RATE
|
||||
|| !matches!(bits, 8 | 16 | 24 | 32 | 64)
|
||||
{
|
||||
return Err(MediaError::Unsupported(format!("{name}: WAV format tag {tag}, {bits} bits")));
|
||||
}
|
||||
let frame_bytes = u64::from(ch) * u64::from(bits / 8);
|
||||
@@ -191,6 +197,7 @@ impl MediaSource for WavSource {
|
||||
}
|
||||
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result<AudioBuffer> {
|
||||
let total = self.frames;
|
||||
crate::validate_audio_buffer_shape(self.channels, frames)?;
|
||||
let mut out = AudioBuffer::silence(sample_rate, self.channels, frames);
|
||||
if total == 0 || sample_rate == 0 {
|
||||
return Ok(out);
|
||||
@@ -343,4 +350,15 @@ mod tests {
|
||||
let src = WavSource::parse("short.wav", with_chunk(&wav, b"bext", &[0u8; 100]).into()).unwrap();
|
||||
assert_eq!(src.time_reference(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_header_dimensions_are_rejected_before_publishing_or_reading() {
|
||||
let mut wav = write_wav16(&[0.0; 2], 1, 48_000);
|
||||
// `fmt ` starts at byte 12: channels at +22, rate at +24.
|
||||
wav[22..24].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||
assert!(WavSource::parse("wide.wav", wav.into()).is_err());
|
||||
let mut wav = write_wav16(&[0.0; 2], 1, 48_000);
|
||||
wav[24..28].copy_from_slice(&(crate::MAX_AUDIO_SAMPLE_RATE + 1).to_le_bytes());
|
||||
assert!(WavSource::parse("fast.wav", wav.into()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,14 +11,38 @@ pub struct Image {
|
||||
}
|
||||
|
||||
impl Image {
|
||||
pub const MAX_PIXELS: usize = 268_435_456;
|
||||
|
||||
pub fn new(w: usize, h: usize) -> Self {
|
||||
Self { w, h, px: vec![0.0; w * h * 4] }
|
||||
let len = w.checked_mul(h).filter(|&px| px <= Self::MAX_PIXELS).and_then(|px| px.checked_mul(4)).unwrap_or(0);
|
||||
let (w, h, px) = if len == 0 && (w > 0 || h > 0) {
|
||||
(0, 0, Vec::new())
|
||||
} else {
|
||||
let mut px = Vec::new();
|
||||
if px.try_reserve_exact(len).is_ok() {
|
||||
px.resize(len, 0.0);
|
||||
(w, h, px)
|
||||
} else {
|
||||
(0, 0, Vec::new())
|
||||
}
|
||||
};
|
||||
Self { w, h, px }
|
||||
}
|
||||
pub fn filled(w: usize, h: usize, c: [f32; 4]) -> Self {
|
||||
let mut px = Vec::with_capacity(w * h * 4);
|
||||
for _ in 0..w * h {
|
||||
px.extend_from_slice(&c);
|
||||
}
|
||||
let len = w.checked_mul(h).filter(|&px| px <= Self::MAX_PIXELS).and_then(|px| px.checked_mul(4)).unwrap_or(0);
|
||||
let (w, h, px) = if len == 0 && (w > 0 || h > 0) {
|
||||
(0, 0, Vec::new())
|
||||
} else {
|
||||
let mut px = Vec::new();
|
||||
if px.try_reserve_exact(len).is_ok() {
|
||||
for _ in 0..w * h {
|
||||
px.extend_from_slice(&c);
|
||||
}
|
||||
(w, h, px)
|
||||
} else {
|
||||
(0, 0, Vec::new())
|
||||
}
|
||||
};
|
||||
Self { w, h, px }
|
||||
}
|
||||
#[inline]
|
||||
@@ -83,6 +107,9 @@ impl Image {
|
||||
/// Resample `self` into a `w`×`h` image through `m` (maps source pixels → destination pixels).
|
||||
/// Uses bilinear filtering, with a box pre-filter (mip level) when minifying by more than 2×.
|
||||
pub fn transformed(&self, w: usize, h: usize, m: &Affine) -> Image {
|
||||
if self.w == 0 || self.h == 0 || w == 0 || h == 0 {
|
||||
return Image::new(w, h);
|
||||
}
|
||||
let Some(inv) = m.inverse() else { return Image::new(w, h) };
|
||||
// minification factor
|
||||
let sx = (m.a * m.a + m.b * m.b).sqrt();
|
||||
@@ -93,6 +120,9 @@ impl Image {
|
||||
let m2 = m.then_apply(&Affine::scale(2.0, 2.0));
|
||||
return half.transformed(w, h, &m2);
|
||||
}
|
||||
if w == 0 || h == 0 {
|
||||
return Image::new(0, 0);
|
||||
}
|
||||
let mut out = Image::new(w, h);
|
||||
// Destination bounds of the source rect, to skip empty rows/cols.
|
||||
let b = m.bounds(&filmcraft_geom::Rect::new(0.0, 0.0, self.w as f64, self.h as f64));
|
||||
@@ -119,6 +149,9 @@ impl Image {
|
||||
|
||||
/// Half-size box downsample.
|
||||
pub fn downsample2(&self) -> Image {
|
||||
if self.w == 0 || self.h == 0 {
|
||||
return Image::new(0, 0);
|
||||
}
|
||||
let (w, h) = ((self.w / 2).max(1), (self.h / 2).max(1));
|
||||
let mut out = Image::new(w, h);
|
||||
out.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
|
||||
@@ -161,6 +194,9 @@ impl Image {
|
||||
|
||||
/// Apply a per-pixel function on straight-alpha colour (alpha preserved).
|
||||
pub fn map_rgb(&mut self, f: impl Fn([f32; 3], usize, usize) -> [f32; 3] + Sync) {
|
||||
if self.w == 0 || self.h == 0 {
|
||||
return;
|
||||
}
|
||||
let w = self.w;
|
||||
self.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
|
||||
for x in 0..w {
|
||||
@@ -179,6 +215,9 @@ impl Image {
|
||||
|
||||
/// Convert to straight sRGB RGBA8.
|
||||
pub fn to_rgba8(&self) -> Vec<u8> {
|
||||
if self.w == 0 || self.h == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = vec![0u8; self.w * self.h * 4];
|
||||
out.par_chunks_mut(self.w * 4).zip(self.px.par_chunks(self.w * 4)).for_each(|(o, s)| filmcraft_frame::linear_premul_to_srgb8(s, o));
|
||||
out
|
||||
@@ -186,6 +225,9 @@ impl Image {
|
||||
|
||||
/// Opaque version over black (for monitors).
|
||||
pub fn over_black_rgba8(&self) -> Vec<u8> {
|
||||
if self.w == 0 || self.h == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = vec![0u8; self.w * self.h * 4];
|
||||
out.par_chunks_mut(self.w * 4).zip(self.px.par_chunks(self.w * 4)).for_each(|(o, s)| {
|
||||
for (o, s) in o.as_chunks_mut::<4>().0.iter_mut().zip(s.as_chunks::<4>().0) {
|
||||
@@ -202,6 +244,9 @@ impl Image {
|
||||
/// the same (the sRGB of the premultiplied linear color), so the picture still shows over
|
||||
/// black, and the alpha says where it is transparent.
|
||||
pub fn over_black_with_alpha_rgba8(&self) -> Vec<u8> {
|
||||
if self.w == 0 || self.h == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = vec![0u8; self.w * self.h * 4];
|
||||
out.par_chunks_mut(self.w * 4).zip(self.px.par_chunks(self.w * 4)).for_each(|(o, s)| {
|
||||
for (o, s) in o.as_chunks_mut::<4>().0.iter_mut().zip(s.as_chunks::<4>().0) {
|
||||
|
||||
@@ -84,7 +84,11 @@ impl Default for RenderOptions {
|
||||
}
|
||||
|
||||
/// Output size for a sequence at a scale.
|
||||
///
|
||||
/// A non-finite or non-positive scale is invalid and falls back to 1.0 rather than being cast to a
|
||||
/// zero pixel count (which would silently collapse the frame to 1x1).
|
||||
pub fn output_size(seq: &Sequence, scale: f32) -> (usize, usize) {
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
(((seq.settings.width as f32 * scale).round() as usize).max(1), ((seq.settings.height as f32 * scale).round() as usize).max(1))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user