Merge pull request #789 from vangel76/gap-project-size-limits

#788: hold opened projects and size commands to the 30,000 px limits

# Conflicts:
#	crates/engine/src/commands/layer.rs
This commit is contained in:
Brandon Thomas 2026-10-12 03:22:54 -04:00
commit 58b09391bd
11 changed files with 388 additions and 26 deletions

View File

@ -1,7 +1,7 @@
//! Composition menu.
use effectcraft_color::Label;
use effectcraft_project::{Comp, ItemId, ItemKind, Marker};
use effectcraft_project::{Comp, ItemId, ItemKind, Marker, limits};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
@ -15,7 +15,7 @@ pub(crate) fn rate_p(p: &Value) -> Option<FrameRate> {
/// A zero, negative or tiny rate would make a zero-length frame (and divide by zero later).
fn check_rate(p: &Value, cmd: &str) -> Result<()> {
match f_p(p, "frameRate").or(f_p(p, "fps")) {
Some(f) if !(0.001..=1000.0).contains(&f) => Err(bad(cmd, "frameRate: fps from 0.001 to 1000")),
Some(f) if !(limits::MIN_FPS..=limits::MAX_FPS).contains(&f) => Err(bad(cmd, "frameRate: fps from 0.001 to 1000")),
_ => Ok(()),
}
}
@ -23,10 +23,10 @@ fn check_rate(p: &Value, cmd: &str) -> Result<()> {
fn apply_settings(c: &mut Comp, p: &Value) {
let (ow, oh) = (c.width, c.height);
if let Some(w) = p.get("width").and_then(Value::as_u64) {
c.width = (w as u32).clamp(4, 30000);
c.width = limits::size(w).max(4);
}
if let Some(h) = p.get("height").and_then(Value::as_u64) {
c.height = (h as u32).clamp(4, 30000);
c.height = limits::size(h).max(4);
}
// Advanced › Anchor: where the old frame sits in the resized one (0 = top left … 4 = center …
// 8 = bottom right). Unparented layers (and all their position keys) move by that offset.
@ -69,7 +69,7 @@ fn apply_settings(c: &mut Comp, p: &Value) {
keep_work_area_ordered(c);
}
if let Some(pa) = f_p(p, "pixelAspect") {
c.pixel_aspect = pa.max(0.1);
c.pixel_aspect = limits::pixel_aspect(pa.max(limits::MIN_PIXEL_ASPECT));
}
if let Some(Value::Array(bg)) = p.get("background") {
let g = |i: usize| bg.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32;
@ -84,10 +84,10 @@ fn apply_settings(c: &mut Comp, p: &Value) {
c.shutter_phase = a.clamp(-360.0, 360.0);
}
if let Some(n) = p.get("motionBlurSamples").and_then(Value::as_u64) {
c.motion_blur_samples = (n as u32).clamp(2, 64);
c.motion_blur_samples = n.clamp(limits::MOTION_BLUR_SAMPLES.0.into(), limits::MOTION_BLUR_SAMPLES.1.into()) as u32;
}
if let Some(n) = p.get("adaptiveSampleLimit").and_then(Value::as_u64) {
c.motion_blur_adaptive_limit = (n as u32).clamp(16, 256);
c.motion_blur_adaptive_limit = n.clamp(limits::ADAPTIVE_SAMPLE_LIMIT.0.into(), limits::ADAPTIVE_SAMPLE_LIMIT.1.into()) as u32;
}
if let Some(b) = p.get("preserveFrameRate").and_then(Value::as_bool) {
c.preserve_frame_rate = b;

View File

@ -17,7 +17,8 @@ fn has_roi(s: &Session) -> std::result::Result<(), String> {
/// (all position keys too) so nothing moves on screen.
fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: &str) -> Result<Value> {
let (x, y) = (r[0].round(), r[1].round());
let (w, h) = (r[2].round().max(1.0) as u32, r[3].round().max(1.0) as u32);
// At most 30,000 px a side, like any composition (#788).
let (w, h) = (effectcraft_project::limits::size_f64(r[2]), effectcraft_project::limits::size_f64(r[3]));
s.edit(label, None, |proj, st| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
c.width = w;

View File

@ -43,10 +43,18 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?;
// XML copies (File ▸ Save a Copy As XML…) open like the JSON project.
let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text };
let mut proj = Project::from_json(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?;
let (mut proj, repaired) =
Project::from_json_repaired(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?;
resolve_relative_footage(&mut proj, path, s.services.as_ref());
s.replace_project(proj, Some(path.to_string()));
s.note_project_path(path);
// Values past the limits (a corrupt or hostile file) were brought within them (#788).
if !repaired.is_empty() {
let more = repaired.len().saturating_sub(3);
let shown = repaired.iter().take(3).cloned().collect::<Vec<_>>().join("; ");
let more = if more > 0 { format!(" and {more} more") } else { String::new() };
s.toast(format!("This project had values outside EffectCraft's limits, corrected when it opened: {shown}{more}."));
}
// Written by a newer EffectCraft: what this version doesn't know would be lost on saving.
let saved_by = effectcraft_project::saved_by(&text);
if let Some(v) = saved_by.as_deref().filter(|v| effectcraft_project::is_newer_version(v, effectcraft_project::APP_VERSION)) {
@ -60,7 +68,7 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
if s.check_footage_on_open {
crate::footage_check::start(s, None, false)?;
}
Ok(json!({"path": path, "savedBy": saved_by}))
Ok(json!({"path": path, "savedBy": saved_by, "repaired": repaired}))
}
/// Footage saved with a relative path (imported by the CLI or an agent as `clip.mp4`, or written

View File

@ -5,7 +5,7 @@
use std::collections::{BTreeMap, BTreeSet};
use effectcraft_color::Label;
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid};
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
@ -84,12 +84,12 @@ fn save_copy(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rate_of(p: &Value) -> FrameRate {
f_p(p, "frameRate").map(FrameRate::from_f64).unwrap_or(FrameRate::FPS_29_97)
f_p(p, "frameRate").map(|f| limits::frame_rate(FrameRate::from_f64(f), FrameRate::FPS_29_97)).unwrap_or(FrameRate::FPS_29_97)
}
fn placeholder_footage(p: &Value) -> Footage {
let w = p.get("width").and_then(Value::as_u64).unwrap_or(1920) as u32;
let h = p.get("height").and_then(Value::as_u64).unwrap_or(1080) as u32;
let w = p.get("width").and_then(Value::as_u64).map_or(1920, limits::size);
let h = p.get("height").and_then(Value::as_u64).map_or(1080, limits::size);
Footage {
path: String::new(),
kind: FootageKind::Video,
@ -133,8 +133,8 @@ fn solid_from(p: &Value, s: &Session) -> Solid {
.unwrap_or([0.5, 0.5, 0.5]);
Solid {
color,
width: p.get("width").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(cw).max(1),
height: p.get("height").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(ch).max(1),
width: p.get("width").and_then(Value::as_u64).map(limits::size).unwrap_or(cw).max(1),
height: p.get("height").and_then(Value::as_u64).map(limits::size).unwrap_or(ch).max(1),
pixel_aspect: 1.0,
}
}

View File

@ -4,7 +4,7 @@ use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Justify, TextDoc, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, TrackMatte,
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, TrackMatte, limits,
};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@ -92,8 +92,9 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let color = color_p(p, "color").unwrap_or(if adjustment { [1.0, 1.0, 1.0] } else { [0.85, 0.2, 0.2] });
let w = p.get("width").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.width);
let h = p.get("height").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.height);
// 1 to 30,000 px a side (#788): the layer is sized from the same numbers as its solid.
let w = p.get("width").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.width);
let h = p.get("height").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.height);
let pixel_aspect = pixel_aspect_p(p)?.unwrap_or(comp.pixel_aspect);
let default_name = if adjustment { "Adjustment Layer 1".to_string() } else { "Solid 1".to_string() };
let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name);
@ -114,7 +115,7 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
fn pixel_aspect_p(p: &Value) -> Result<Option<f64>> {
match p.get("pixelAspect").and_then(Value::as_f64) {
Some(v) if !(v.is_finite() && v > 0.0) => Err(bad("layer.settings", "`pixelAspect` must be a positive number")),
v => Ok(v),
v => Ok(v.map(limits::pixel_aspect)),
}
}
@ -1429,8 +1430,8 @@ fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
_ => {}
}
let color = color_p(p, "color");
let w = p.get("width").and_then(Value::as_u64).map(|v| v.max(1) as u32);
let h = p.get("height").and_then(Value::as_u64).map(|v| v.max(1) as u32);
let w = p.get("width").and_then(Value::as_u64).map(limits::size);
let h = p.get("height").and_then(Value::as_u64).map(limits::size);
let pixel_aspect = pixel_aspect_p(p)?;
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true);

View File

@ -1086,6 +1086,8 @@ mod tests_key_metadata_order;
#[cfg(test)]
mod tests_keylight;
#[cfg(test)]
mod tests_limits;
#[cfg(test)]
mod tests_lottie;
#[cfg(test)]
mod tests_m137;

View File

@ -116,7 +116,9 @@ pub fn import(proj: &mut Project, psd: &Psd, path: &str, name: &str, retain: boo
}
fn new_comp(cx: &Ctx) -> Comp {
Comp::new(cx.psd.width, cx.psd.height, cx.rate, cx.duration)
// A PSB can be 300,000 px a side; a composition is at most 30,000 (#788).
let size = |v: u32| effectcraft_project::limits::size(v.into());
Comp::new(size(cx.psd.width), size(cx.psd.height), cx.rate, cx.duration)
}
fn build_comp(proj: &mut Project, cx: &mut Ctx, name: &str, nodes: &[Node], parent: Option<ItemId>) -> ItemId {

View File

@ -0,0 +1,104 @@
//! Sizes, frame rates and motion-blur samples stay within the project limits when a project is
//! opened and when a command sets them (#788).
use effectcraft_project::{ItemKind, limits};
use effectcraft_render::RenderOpts;
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
use crate::{Event, Session};
fn tmp(name: &str) -> String {
let d = std::env::temp_dir().join(format!("effectcraft-limits-tests-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d.join(name).to_string_lossy().to_string()
}
/// The saved project with `edit` applied to the JSON of every item of kind `kind`.
fn tamper(text: &str, kind: &str, edit: impl Fn(&mut Value)) -> String {
let mut v: Value = serde_json::from_str(text).unwrap();
let items = v["items"].as_object_mut().unwrap();
for it in items.values_mut().filter(|it| it["kind"]["type"] == kind) {
edit(&mut it["kind"]);
}
serde_json::to_string(&v).unwrap()
}
/// A project whose comp is 4 billion × 0 px at 10^18 fps, -5 s long with 4 billion motion-blur
/// samples, holding a solid of 4 billion px.
fn hostile_project() -> String {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 30, "duration": 2})).unwrap();
s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 64, "height": 2})).unwrap();
let text = s.project.to_file_json().unwrap();
let text = tamper(&text, "Comp", |c| {
c["width"] = json!(4_000_000_000u64);
c["height"] = json!(0);
c["frame_rate"] = json!({"num": 1_000_000_000_000_000_000i64, "den": 1});
c["duration"] = json!(-5);
c["motion_blur_samples"] = json!(4_000_000_000u64);
});
tamper(&text, "Solid", |so| so["width"] = json!(4_000_000_000u64))
}
#[test]
fn a_hostile_project_opens_within_the_limits_and_says_what_changed() {
let json_path = tmp("Hostile.ecproj");
let text = hostile_project();
std::fs::write(&json_path, &text).unwrap();
let xml_path = tmp("Hostile.ecprojx");
std::fs::write(&xml_path, crate::xml_project::to_xml(&text).unwrap()).unwrap();
for path in [json_path, xml_path] {
let mut s = Session::default();
let r = s.execute("file.open", json!({"path": path})).unwrap();
let repaired = r["repaired"].as_array().unwrap();
assert!(repaired.iter().any(|n| n.as_str().unwrap().contains("4000000000 × 0 px → 30000 × 1 px")), "{path}: {repaired:?}");
let toast = s.drain_events().into_iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("outside EffectCraft's limits")));
assert!(toast, "{path}");
let (cid, c) = s.project.comps().next().map(|(id, c)| (*id, c.clone())).unwrap();
assert_eq!((c.width, c.height), (limits::MAX_SIZE, 1));
assert_eq!(c.frame_rate.as_f64(), limits::MAX_FPS);
assert_eq!(c.duration, c.frame_rate.frame_duration(), "at least one frame");
assert_eq!(c.work_area, (Tick::ZERO, c.duration));
assert_eq!(c.motion_blur_samples, limits::MOTION_BLUR_SAMPLES.1);
let solid = s.project.items.values().find_map(|i| if let ItemKind::Solid(so) = &i.kind { Some(so.clone()) } else { None }).unwrap();
assert_eq!((solid.width, solid.height), (limits::MAX_SIZE, 2));
// The comp renders at Full resolution, at its (capped) size.
let img = s.render(cid, Tick::ZERO, RenderOpts { scale: 1.0, ..Default::default() });
assert_eq!((img.width, img.height), (limits::MAX_SIZE, 1));
}
}
#[test]
fn a_project_within_the_limits_opens_unchanged() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 24, "duration": 2})).unwrap();
let text = s.project.to_file_json().unwrap();
let (p, notes) = effectcraft_project::Project::from_json_repaired(&text).unwrap();
assert!(notes.is_empty(), "{notes:?}");
assert_eq!(p, *s.project);
}
#[test]
fn commands_cap_sizes_at_30000_px() {
let mut s = Session::default();
// 2^32 + 10 used to wrap to 10 px.
s.execute("comp.new", json!({"name": "Main", "width": 4_294_967_306u64, "height": 32, "pixelAspect": 1e9})).unwrap();
let c = s.active_comp().unwrap().clone();
assert_eq!((c.width, c.height, c.pixel_aspect), (limits::MAX_SIZE, 32, limits::MAX_PIXEL_ASPECT));
s.execute("comp.settings", json!({"width": 64, "height": 4_294_967_306u64})).unwrap();
assert_eq!((s.active_comp().unwrap().width, s.active_comp().unwrap().height), (64, limits::MAX_SIZE));
// New Solid: 0 and 4 billion px are brought to 1 and 30,000; the layer matches its solid.
s.execute("layer.newSolid", json!({"width": 0, "height": 4_000_000_000u64})).unwrap();
let solid = |s: &Session| s.project.items.values().find_map(|i| if let ItemKind::Solid(so) = &i.kind { Some(so.clone()) } else { None }).unwrap();
assert_eq!((solid(&s).width, solid(&s).height), (1, limits::MAX_SIZE));
// Layer Settings on the new (selected) solid layer.
s.execute("layer.settings", json!({"width": 4_000_000_000u64, "height": 8})).unwrap();
assert_eq!((solid(&s).width, solid(&s).height), (limits::MAX_SIZE, 8));
s.execute("file.importSolid", json!({"width": 99_999_999, "height": 4})).unwrap();
assert!(s.project.items.values().any(|i| matches!(&i.kind, ItemKind::Solid(so) if (so.width, so.height) == (limits::MAX_SIZE, 4))));
// A placeholder can't ask for a huge frame or a zero frame rate either.
s.execute("file.importPlaceholder", json!({"width": 4_000_000_000u64, "height": 8, "frameRate": 0})).unwrap();
let f = s.project.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).unwrap();
assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97));
}

View File

@ -8,6 +8,7 @@
pub mod build;
pub mod essential;
pub mod limits;
pub mod props;
pub mod render_queue;
pub mod render_templates;
@ -1246,7 +1247,9 @@ impl Project {
_ => None,
}
}
pub fn add_item(&mut self, name: &str, label: Label, parent: Option<ItemId>, kind: ItemKind) -> ItemId {
/// Adds an item; its size, frame rate and the like are brought within [`limits`] first.
pub fn add_item(&mut self, name: &str, label: Label, parent: Option<ItemId>, mut kind: ItemKind) -> ItemId {
limits::repair_kind(name, &mut kind, &mut vec![]);
let id = ItemId(self.alloc());
self.items.insert(id, Item { id, name: name.into(), label, comment: String::new(), parent, kind, proxy: None });
id
@ -1336,12 +1339,22 @@ impl Project {
serde_json::to_string(self).unwrap_or_default()
}
pub fn from_json(s: &str) -> Result<Project, ProjectError> {
Project::from_json_repaired(s).map(|(p, _)| p)
}
/// [`Project::from_json`], also returning one line per value that was out of [`limits`] and
/// was corrected (a composition of a billion pixels, a zero frame rate…), so a corrupt or
/// hostile file still opens, within bounds, and the user can be told what changed.
pub fn from_json_repaired(s: &str) -> Result<(Project, Vec<String>), ProjectError> {
let mut p: Project = serde_json::from_str(s).map_err(|e| ProjectError::Invalid(e.to_string()))?;
if p.schema > SCHEMA_VERSION {
return Err(ProjectError::Invalid(format!("project schema {} is newer than this version supports ({SCHEMA_VERSION})", p.schema)));
}
p.fix_next_id();
Ok(p)
let mut notes = vec![];
for item in p.items.values_mut() {
limits::repair_item(item, &mut notes);
}
Ok((p, notes))
}
}

View File

@ -0,0 +1,227 @@
//! 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());
}
}

View File

@ -889,7 +889,11 @@ saved warns that what this version doesn't know is lost on saving (`file.open` r
`savedBy`). Saving checks that the file reads back first, so a value JSON can't hold (NaN)
fails the save with a message instead of writing a project that can never be opened (the file
on disk is left as it was). Increment and Save and Save a Copy keep an XML project (`.ecprojx`)
XML; a file that isn't a project says so by name.
XML; a file that isn't a project says so by name. A corrupt or hostile project opens within the
limits the commands use (#788): compositions, solids and footage at most 30,000 × 30,000 px (as in
After Effects), frame rates 0.001–1000 fps, pixel aspect 0.1–10, compositions at least one frame
long and 2–64 motion-blur samples; `file.open` lists what it corrected (`repaired`) and a toast
names it.
### Update: M3.13 menu bar