mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-13 20:05:48 +00:00
A project file, an .aep, a timeline document or a Lottie file could give a layer a time stretch of 1e-300 %, 0 or 1e300 %. Layer and comp time divide by it and the audio mix read about usize::MAX source samples for one block, which overflowed or tried a huge allocation and aborted the app. - limits::stretch brings a stretch within After Effects' range (±1 % to ±9900 %, Layer.stretch in the scripting guide), sign kept; 0 and NaN are 100 %. Opening a project file corrects each layer and says so; a stretch written as null (NaN in an auto-save) now opens instead of failing. - .aep import and Lottie import clamp and report the stretch; timeline import clamps the clip speed and warns. - layer.timeStretch refuses a non-number and more than 9900 %; the Time Stretch dialog's ranges match. - Layer::layer_time / comp_time and the audio mix use the clamped stretch, so a value set in memory can't divide by zero or read without bound. Fixes #3247
97 lines
4.7 KiB
Rust
97 lines
4.7 KiB
Rust
//! A project file whose layers carry a time stretch outside ±1 % to ±9900 % (1e-300 %, 0,
|
|
//! 1e300 %…) opens with the stretch brought into that range, and a frame of it draws and its audio
|
|
//! mixes with bounded source reads. Before, a stretch of 1e-300 % asked the footage for about
|
|
//! `usize::MAX` samples and the mix overflowed or tried a huge allocation, aborting the app (#3247).
|
|
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
use effectcraft_color::Label;
|
|
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, build, limits};
|
|
use effectcraft_time::Tick;
|
|
|
|
use super::{setup, solid};
|
|
use crate::{FootageSource, Image, audio::mix_comp, render_frame};
|
|
|
|
/// Silent audio footage that records the longest read asked of it and refuses a read past a
|
|
/// thousand seconds (so a runaway read can't take the machine's memory: the mix then fills the
|
|
/// read with silence itself).
|
|
#[derive(Default)]
|
|
struct Probe {
|
|
longest: AtomicUsize,
|
|
}
|
|
|
|
impl FootageSource for Probe {
|
|
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
|
None
|
|
}
|
|
fn audio(&self, _: ItemId, _: &Footage, _: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
|
|
self.longest.fetch_max(frames, Ordering::Relaxed);
|
|
(frames <= rate as usize * 1000).then(|| vec![0.0; frames * 2])
|
|
}
|
|
}
|
|
|
|
/// The project file of a comp holding a red solid and a sound, both with their stretch set to
|
|
/// `stretch` in the file text.
|
|
fn file_with_stretch(stretch: f64) -> (String, ItemId) {
|
|
let (mut p, cid, comp) = setup();
|
|
let red = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
|
|
let f = Footage {
|
|
path: "tone.wav".into(),
|
|
kind: FootageKind::Audio,
|
|
has_audio: true,
|
|
frame_rate: comp.frame_rate,
|
|
duration: Tick::from_seconds_f64(10.0),
|
|
..Default::default()
|
|
};
|
|
let fid = p.add_item("tone.wav", Label::SeaFoam, None, ItemKind::Footage(f));
|
|
let sound = build::layer(&mut p, &comp, "sound", LayerSource::Footage { item: fid }, (0, 0), None);
|
|
let c = p.comp_mut(cid).unwrap();
|
|
c.layers.push(red);
|
|
c.layers.push(sound);
|
|
// Both layers are at 100 %; `{:e}` writes a JSON number for any finite value, and NaN is
|
|
// written as null, as serializing the project writes it.
|
|
let text = p.to_json();
|
|
assert_eq!(text.matches("\"stretch\": 100.0").count(), 2);
|
|
let value = if stretch.is_nan() { "null".to_string() } else { format!("{stretch:e}") };
|
|
(text.replace("\"stretch\": 100.0", &format!("\"stretch\": {value}")), cid)
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_with_an_extreme_stretch_opens_in_range_draws_and_mixes_with_bounded_reads() {
|
|
for (stored, opened) in
|
|
[(1e-300, 1.0), (-1e-300, -1.0), (0.0, 100.0), (1e300, limits::MAX_STRETCH), (-1e300, -limits::MAX_STRETCH), (0.5, 1.0), (f64::NAN, 100.0)]
|
|
{
|
|
let (text, cid) = file_with_stretch(stored);
|
|
let (p, notes) = Project::from_json_repaired(&text).unwrap();
|
|
assert!(notes.iter().filter(|n| n.contains("time stretch")).count() == 2, "{stored}: {notes:?}");
|
|
let c = p.comp(cid).unwrap();
|
|
assert!(c.layers.iter().all(|l| l.stretch == opened), "{stored}: {:?}", c.layers.iter().map(|l| l.stretch).collect::<Vec<_>>());
|
|
// A frame draws: the solid has no keys and keeps its in and out points, so it is red.
|
|
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
|
|
assert_eq!(img.get(10, 10), [1.0, 0.0, 0.0, 1.0], "{stored}");
|
|
// The audio mixes, reading at most 100 source samples per comp sample (a stretch of 1 %).
|
|
let (rate, n) = (8000, 4000);
|
|
let probe = Probe::default();
|
|
let mixed = mix_comp(&p, &probe, None, cid, Tick::from_seconds_f64(0.5), n, rate);
|
|
assert_eq!(mixed.len(), n * 2, "{stored}");
|
|
assert!(probe.longest.load(Ordering::Relaxed) <= 100 * n + 1, "{stored}: a read of {} samples", probe.longest.load(Ordering::Relaxed));
|
|
}
|
|
}
|
|
|
|
/// A stretch set in memory (not through a file, so not repaired) mixes as the clamped stretch the
|
|
/// layer's time uses: before, 1e-300 % read about `usize::MAX` source samples and overflowed.
|
|
#[test]
|
|
fn an_in_memory_extreme_stretch_mixes_with_bounded_reads() {
|
|
for stretch in [1e-300, 0.0, f64::NAN, 1e300] {
|
|
let (text, cid) = file_with_stretch(100.0);
|
|
let (mut p, _) = Project::from_json_repaired(&text).unwrap();
|
|
p.comp_mut(cid).unwrap().layers.iter_mut().for_each(|l| l.stretch = stretch);
|
|
let (rate, n) = (8000, 4000);
|
|
let probe = Probe::default();
|
|
let mixed = mix_comp(&p, &probe, None, cid, Tick::from_seconds_f64(0.5), n, rate);
|
|
assert_eq!(mixed.len(), n * 2, "{stretch}");
|
|
assert!(probe.longest.load(Ordering::Relaxed) <= 100 * n + 1, "{stretch}: a read of {} samples", probe.longest.load(Ordering::Relaxed));
|
|
}
|
|
}
|