Files
effectcraft/crates/project/src/limits.rs
T
vangel76 5f73e5b5e9 Time stretch: hold a loaded or imported stretch to ±1 % to ±9900 %
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
2026-11-07 17:03:59 +01:00

339 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The bounds every project holds to (#788). After Effects limits compositions, solids and
//! imported images to 30,000 × 30,000 pixels; the Composition Settings commands already used that
//! limit, a frame-rate range and a motion-blur sample range. A project file is input like any
//! other: [`Project::from_json_repaired`](crate::Project::from_json_repaired) brings what it reads
//! into these bounds (and says what it changed) and [`Project::add_item`](crate::Project::add_item)
//! does the same for every new item, so a corrupt or hostile file can't make the renderer allocate
//! a frame of billions of pixels or step through zero-length frames.
use std::fmt::Display;
use std::sync::Arc;
use effectcraft_time::FrameRate;
use crate::{Comp, Footage, Item, ItemKind, Solid};
/// The largest width or height of a composition, solid or footage frame, in pixels.
pub const MAX_SIZE: u32 = 30_000;
/// The slowest frame rate, in frames per second.
pub const MIN_FPS: f64 = 0.001;
/// The fastest frame rate, in frames per second.
pub const MAX_FPS: f64 = 1000.0;
/// The narrowest pixel aspect ratio.
pub const MIN_PIXEL_ASPECT: f64 = 0.1;
/// The widest pixel aspect ratio.
pub const MAX_PIXEL_ASPECT: f64 = 10.0;
/// Composition Settings ▸ Advanced ▸ Samples Per Frame.
pub const MOTION_BLUR_SAMPLES: (u32, u32) = (2, 64);
/// Composition Settings ▸ Advanced ▸ Adaptive Sample Limit.
pub const ADAPTIVE_SAMPLE_LIMIT: (u32, u32) = (16, 256);
/// The smallest time stretch, in percent, either way (After Effects sets a stretch between 0 and
/// 1 % to 1 %, between -1 % and 0 to -1 %; `layer.timeStretch` refuses less).
pub const MIN_STRETCH: f64 = 1.0;
/// The largest time stretch, in percent, either way (After Effects' `Layer.stretch` range is
/// -9900 to 9900).
pub const MAX_STRETCH: f64 = 9900.0;
/// A layer's time stretch from [`MIN_STRETCH`] to [`MAX_STRETCH`] % either way, its sign (a
/// reversed layer) kept; 100 % for 0 or NaN. Layer and comp time divide by the stretch, and the
/// audio mix reads `100 / stretch` source samples per sample, so a stretch of 1e-300 % or NaN
/// from a damaged file must not reach them (#3247).
pub fn stretch(v: f64) -> f64 {
if v.is_nan() || v == 0.0 {
return 100.0;
}
// Infinity clamps to the largest stretch with its sign.
v.signum() * v.abs().clamp(MIN_STRETCH, MAX_STRETCH)
}
/// A stretch for a note: a plain number in a readable range, scientific notation beyond it (a
/// plain 1e-300 would print 300 digits).
fn percent(v: f64) -> String {
if v == 0.0 || (1e-3..1e7).contains(&v.abs()) { format!("{v} %") } else { format!("{v:e} %") }
}
/// A width or height from 1 to [`MAX_SIZE`].
pub fn size(v: u64) -> u32 {
// In range after the clamp, so the cast can't truncate.
v.clamp(1, u64::from(MAX_SIZE)) as u32
}
/// [`size`] of a measured length (rounded; 1 for NaN).
pub fn size_f64(v: f64) -> u32 {
if v.is_finite() { v.round().clamp(1.0, f64::from(MAX_SIZE)) as u32 } else { 1 }
}
/// A pixel aspect ratio from [`MIN_PIXEL_ASPECT`] to [`MAX_PIXEL_ASPECT`] (square for a value
/// that isn't a positive number).
pub fn pixel_aspect(v: f64) -> f64 {
if v.is_finite() && v > 0.0 { v.clamp(MIN_PIXEL_ASPECT, MAX_PIXEL_ASPECT) } else { 1.0 }
}
/// A frame rate from [`MIN_FPS`] to [`MAX_FPS`]; `fallback` for a zero or negative rate.
pub fn frame_rate(r: FrameRate, fallback: FrameRate) -> FrameRate {
if r.num <= 0 || r.den <= 0 {
return fallback;
}
let fps = r.as_f64();
if (MIN_FPS..=MAX_FPS).contains(&fps) { r } else { FrameRate::from_f64(fps.clamp(MIN_FPS, MAX_FPS)) }
}
/// Records one corrected value.
fn note(notes: &mut Vec<String>, item: &str, what: &str, from: impl Display, to: impl Display) {
notes.push(format!("{item}: {what} {from} → {to}"));
}
fn fps(r: FrameRate) -> String {
format!("{}/{} fps", r.num, r.den)
}
/// Brings a read item (and its proxy) within the limits; one line per corrected value is added
/// to `notes`.
pub(crate) fn repair_item(item: &mut Item, notes: &mut Vec<String>) {
repair_kind(&item.name, &mut item.kind, notes);
if let Some(proxy) = &mut item.proxy {
repair_footage(&format!("{} (proxy)", item.name), &mut proxy.footage, notes);
}
}
/// Brings `kind` within the limits; one line per corrected value is added to `notes`.
pub(crate) fn repair_kind(name: &str, kind: &mut ItemKind, notes: &mut Vec<String>) {
match kind {
ItemKind::Comp(c) => {
if let Some(fixed) = repair_comp(name, c, notes) {
*c = Arc::new(fixed);
}
}
ItemKind::Solid(s) => repair_solid(name, s, notes),
ItemKind::Footage(f) => repair_footage(name, f, notes),
ItemKind::Folder => {}
}
}
/// The comp with its settings within the limits, or `None` when they already are (so a
/// composition shared with undo snapshots is only copied when something changes).
fn repair_comp(name: &str, c: &Comp, notes: &mut Vec<String>) -> Option<Comp> {
let width = c.width.clamp(1, MAX_SIZE);
let height = c.height.clamp(1, MAX_SIZE);
let pa = pixel_aspect(c.pixel_aspect);
let rate = frame_rate(c.frame_rate, FrameRate::FPS_29_97);
// At least one frame long.
let duration = c.duration.max(rate.frame_duration());
let samples = c.motion_blur_samples.clamp(MOTION_BLUR_SAMPLES.0, MOTION_BLUR_SAMPLES.1);
let adaptive = c.motion_blur_adaptive_limit.clamp(ADAPTIVE_SAMPLE_LIMIT.0, ADAPTIVE_SAMPLE_LIMIT.1);
let stretch_ok = |l: &crate::Layer| stretch(l.stretch).to_bits() == l.stretch.to_bits();
let unchanged = (width, height, rate, duration, samples, adaptive)
== (c.width, c.height, c.frame_rate, c.duration, c.motion_blur_samples, c.motion_blur_adaptive_limit)
&& pa.to_bits() == c.pixel_aspect.to_bits()
&& c.layers.iter().all(stretch_ok);
if unchanged {
return None;
}
let mut n = c.clone();
if (width, height) != (c.width, c.height) {
note(notes, name, "size", format_args!("{} × {} px", c.width, c.height), format_args!("{width} × {height} px"));
}
if pa.to_bits() != c.pixel_aspect.to_bits() {
note(notes, name, "pixel aspect ratio", c.pixel_aspect, pa);
}
if rate != c.frame_rate {
note(notes, name, "frame rate", fps(c.frame_rate), fps(rate));
}
if duration != c.duration {
note(notes, name, "duration", format_args!("{:.3} s", c.duration.seconds()), format_args!("{:.3} s", duration.seconds()));
n.work_area = (effectcraft_time::Tick::ZERO, duration);
}
if samples != c.motion_blur_samples {
note(notes, name, "motion blur samples per frame", c.motion_blur_samples, samples);
}
if adaptive != c.motion_blur_adaptive_limit {
note(notes, name, "adaptive sample limit", c.motion_blur_adaptive_limit, adaptive);
}
(n.width, n.height, n.pixel_aspect, n.frame_rate, n.duration) = (width, height, pa, rate, duration);
(n.motion_blur_samples, n.motion_blur_adaptive_limit) = (samples, adaptive);
// Only the layers out of range are copied (the others stay shared).
for l in n.layers.iter_mut_where(|l| !stretch_ok(l)) {
let fixed = stretch(l.stretch);
note(notes, name, &format!("layer '{}' time stretch", l.name), percent(l.stretch), percent(fixed));
l.stretch = fixed;
}
Some(n)
}
fn repair_solid(name: &str, s: &mut Solid, notes: &mut Vec<String>) {
let (w, h) = (s.width.clamp(1, MAX_SIZE), s.height.clamp(1, MAX_SIZE));
if (w, h) != (s.width, s.height) {
note(notes, name, "size", format_args!("{} × {} px", s.width, s.height), format_args!("{w} × {h} px"));
(s.width, s.height) = (w, h);
}
let pa = pixel_aspect(s.pixel_aspect);
if pa.to_bits() != s.pixel_aspect.to_bits() {
note(notes, name, "pixel aspect ratio", s.pixel_aspect, pa);
s.pixel_aspect = pa;
}
}
fn repair_footage(name: &str, f: &mut Footage, notes: &mut Vec<String>) {
// Audio-only footage has no frame (0 × 0).
let (w, h) = (f.width.min(MAX_SIZE), f.height.min(MAX_SIZE));
if (w, h) != (f.width, f.height) {
note(notes, name, "size", format_args!("{} × {} px", f.width, f.height), format_args!("{w} × {h} px"));
(f.width, f.height) = (w, h);
}
let pa = pixel_aspect(f.pixel_aspect);
if pa.to_bits() != f.pixel_aspect.to_bits() {
note(notes, name, "pixel aspect ratio", f.pixel_aspect, pa);
f.pixel_aspect = pa;
}
let rate = frame_rate(f.frame_rate, FrameRate::FPS_30);
if rate != f.frame_rate {
note(notes, name, "frame rate", fps(f.frame_rate), fps(rate));
f.frame_rate = rate;
}
if let Some(native) = f.native_rate {
let rate = frame_rate(native, FrameRate::FPS_30);
if rate != native {
note(notes, name, "native frame rate", fps(native), fps(rate));
f.native_rate = Some(rate);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use effectcraft_time::Tick;
#[test]
fn helpers_clamp_and_refuse_nonsense() {
assert_eq!(size(0), 1);
assert_eq!(size(u64::from(u32::MAX) + 5), MAX_SIZE);
assert_eq!(size_f64(f64::NAN), 1);
assert_eq!(size_f64(1e12), MAX_SIZE);
assert_eq!(pixel_aspect(0.0), 1.0);
assert_eq!(pixel_aspect(-2.0), 1.0);
assert_eq!(pixel_aspect(1e9), MAX_PIXEL_ASPECT);
assert_eq!(frame_rate(FrameRate { num: 0, den: 1 }, FrameRate::FPS_25), FrameRate::FPS_25);
assert_eq!(frame_rate(FrameRate { num: 24, den: -1 }, FrameRate::FPS_25), FrameRate::FPS_25);
assert_eq!(frame_rate(FrameRate::new(1_000_000_000_000_000_000, 1), FrameRate::FPS_25).as_f64(), MAX_FPS);
assert_eq!(frame_rate(FrameRate::FPS_29_97, FrameRate::FPS_25), FrameRate::FPS_29_97);
}
#[test]
fn add_item_and_reading_a_file_hold_footage_to_the_limits() {
let mut p = crate::Project::default();
let wild = Footage { width: u32::MAX, height: 0, pixel_aspect: 0.0, frame_rate: FrameRate { num: 1, den: 0 }, ..Default::default() };
let id = p.add_item("Clip", effectcraft_color::Label::None, None, ItemKind::Footage(wild));
let ItemKind::Footage(f) = &p.items[&id].kind else { panic!("footage") };
assert_eq!((f.width, f.height, f.pixel_aspect, f.frame_rate), (MAX_SIZE, 0, 1.0, FrameRate::FPS_30));
// The same values in a file are corrected, and each correction is reported.
let mut v: serde_json::Value = serde_json::from_str(&p.to_json()).unwrap();
let kind = &mut v["items"][id.0.to_string()]["kind"];
kind["width"] = serde_json::json!(4_000_000_000u64);
kind["native_rate"] = serde_json::json!({"num": -24, "den": 1});
let mut proxy = kind.clone();
proxy["frame_rate"] = serde_json::json!({"num": 0, "den": 0});
v["items"][id.0.to_string()]["proxy"] = serde_json::json!({"footage": proxy});
let (back, notes) = crate::Project::from_json_repaired(&v.to_string()).unwrap();
let ItemKind::Footage(b) = &back.items[&id].kind else { panic!("footage") };
assert_eq!((b.width, b.native_rate), (MAX_SIZE, Some(FrameRate::FPS_30)));
assert_eq!(
notes,
[
"Clip: size 4000000000 × 0 px → 30000 × 0 px",
"Clip: native frame rate -24/1 fps → 30/1 fps",
"Clip (proxy): size 4000000000 × 0 px → 30000 × 0 px",
"Clip (proxy): frame rate 0/0 fps → 30/1 fps",
"Clip (proxy): native frame rate -24/1 fps → 30/1 fps",
]
);
}
#[test]
fn a_stretch_is_held_to_one_to_9900_percent_with_its_sign() {
assert_eq!(stretch(f64::NAN), 100.0);
assert_eq!(stretch(0.0), 100.0);
assert_eq!(stretch(-0.0), 100.0);
assert_eq!(stretch(1e-300), MIN_STRETCH);
assert_eq!(stretch(-1e-300), -MIN_STRETCH);
assert_eq!(stretch(0.5), MIN_STRETCH);
assert_eq!(stretch(1e300), MAX_STRETCH);
assert_eq!(stretch(f64::INFINITY), MAX_STRETCH);
assert_eq!(stretch(f64::NEG_INFINITY), -MAX_STRETCH);
for v in [1.0, -1.0, 33.3, 100.0, -100.0, 200.0, 9900.0, -9900.0] {
assert_eq!(stretch(v).to_bits(), v.to_bits(), "{v} is in range");
}
}
/// A layer whose stretch got past loading (0, NaN, 1e-300 %, set in memory) still maps time
/// through a stretch in range: before, 0 and 1e-300 % saturated every layer time to the end of
/// time and NaN collapsed it to 0 (#3247).
#[test]
fn layer_and_comp_time_use_a_stretch_in_range() {
let mut p = crate::Project::default();
let comp = Comp::new(64, 64, FrameRate::FPS_25, Tick::from_seconds_f64(10.0));
let mut l = crate::build::layer(&mut p, &comp, "L", crate::LayerSource::Camera, (64, 64), None);
l.start_time = Tick::from_seconds_f64(1.0);
let at = |s: f64| Tick::from_seconds_f64(s);
for (bad, effective) in [(0.0, 100.0), (f64::NAN, 100.0), (1e-300, 1.0), (-1e-300, -1.0), (1e300, 9900.0), (f64::NEG_INFINITY, -9900.0)] {
l.stretch = bad;
assert_eq!(l.effective_stretch(), effective, "{bad}");
// One comp second after the start is 100 / stretch layer seconds.
let want = Tick((at(1.0).0 as f64 * 100.0 / effective) as i64);
assert_eq!(l.layer_time(at(2.0)), want, "{bad}");
// And back, within the rounding of the two conversions.
assert!((l.comp_time(l.layer_time(at(2.0))) - at(2.0)).abs() <= Tick(100), "{bad}");
}
}
#[test]
fn a_stretch_out_of_range_in_a_file_is_corrected_and_reported() {
let mut p = crate::Project::default();
let comp = Comp::new(64, 64, FrameRate::FPS_25, Tick::from_seconds_f64(2.0));
let mut c = comp.clone();
for (name, s) in [("tiny", 1e-300), ("reversed tiny", -1e-300), ("zero", 0.0), ("huge", 1e300), ("reversed huge", -5e4), ("fine", -50.0)] {
let mut l = crate::build::layer(&mut p, &comp, name, crate::LayerSource::Camera, (64, 64), None);
l.stretch = s;
c.layers.push(l);
}
// Added through `add_item`, the comp is held to the limits too: write the values back in.
let id = p.add_item("Comp", effectcraft_color::Label::None, None, ItemKind::Comp(Arc::new(c.clone())));
assert!(p.comp(id).unwrap().layers.iter().all(|l| l.stretch.abs() >= MIN_STRETCH && l.stretch.abs() <= MAX_STRETCH));
let mut v: serde_json::Value = serde_json::from_str(&p.to_json()).unwrap();
for (k, l) in c.layers.iter().enumerate() {
v["items"][id.0.to_string()]["kind"]["layers"][k]["stretch"] = serde_json::json!(l.stretch);
}
let (back, notes) = crate::Project::from_json_repaired(&v.to_string()).unwrap();
let got: Vec<f64> = back.comp(id).unwrap().layers.iter().map(|l| l.stretch).collect();
assert_eq!(got, [1.0, -1.0, 100.0, 9900.0, -9900.0, -50.0]);
assert_eq!(
notes,
[
"Comp: layer 'tiny' time stretch 1e-300 % → 1 %",
"Comp: layer 'reversed tiny' time stretch -1e-300 % → -1 %",
"Comp: layer 'zero' time stretch 0 % → 100 %",
"Comp: layer 'huge' time stretch 1e300 % → 9900 %",
"Comp: layer 'reversed huge' time stretch -50000 % → -9900 %",
]
);
// A NaN stretch is written as null (an auto-save): the file opens, at 100 %, and says so.
// Before, the whole project was refused.
v["items"][id.0.to_string()]["kind"]["layers"][0]["stretch"] = serde_json::Value::Null;
let (back, notes) = crate::Project::from_json_repaired(&v.to_string()).unwrap();
assert_eq!(back.comp(id).unwrap().layers.iter().next().unwrap().stretch, 100.0);
assert_eq!(notes.first().map(String::as_str), Some("Comp: layer 'tiny' time stretch NaN % → 100 %"));
let mut nan = p.clone();
nan.comp_mut(id).unwrap().layers.iter_mut().for_each(|l| l.stretch = f64::NAN);
let (back, _) = crate::Project::from_json_repaired(&nan.to_json_compact()).unwrap();
assert!(back.comp(id).unwrap().layers.iter().all(|l| l.stretch == 100.0));
}
#[test]
fn a_comp_in_range_is_not_copied() {
let c = Comp::new(1920, 1080, FrameRate::FPS_24, Tick::from_seconds_f64(2.0));
let mut notes = vec![];
assert!(repair_comp("Comp", &c, &mut notes).is_none());
assert!(notes.is_empty());
}
}