mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 02:27:00 -06:00
Problem: comp.new/comp.settings clamp sizes to 30,000 px, frame rates to 0.001-1000 fps and motion-blur samples to 2-64, but reading a project (.ecproj and .ecprojx, templates, recovery, offload) accepted any value: a 4-billion-px comp, solid or footage frame, a 0 or 10^18 fps rate (zero-length frames), a negative duration or 4 billion blur samples all reached the renderer. New Solid, Layer Settings, Import Solid/Placeholder, Crop Comp and PSD import set sizes without the upper bound, and comp sizes past 2^32 wrapped. Fix: one set of limits in effectcraft_project::limits. Project::from_json brings every item (and proxy) within them; from_json_repaired also says what it changed, which file.open returns as `repaired` and shows in a toast, so the project still opens. Project::add_item applies the same limits to every new item, and the size-setting commands clamp with the shared helpers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
228 lines
10 KiB
Rust
228 lines
10 KiB
Rust
//! 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);
|
||
|
||
/// 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 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();
|
||
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);
|
||
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_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());
|
||
}
|
||
}
|