From 765ba157bec4b9610c7c7b994fb3cc20b35fa326 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 13:22:59 +0200 Subject: [PATCH] #788: hold opened projects and size commands to the 30,000 px limits 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 --- crates/engine/src/commands/comp.rs | 14 +- crates/engine/src/commands/comp_more.rs | 3 +- crates/engine/src/commands/file.rs | 12 +- crates/engine/src/commands/file_more.rs | 12 +- crates/engine/src/commands/layer.rs | 13 +- crates/engine/src/lib.rs | 2 + crates/engine/src/psd_import.rs | 4 +- crates/engine/src/tests_limits.rs | 104 +++++++++++ crates/project/src/lib.rs | 17 +- crates/project/src/limits.rs | 227 ++++++++++++++++++++++++ docs/target-app-parity.md | 6 +- 11 files changed, 388 insertions(+), 26 deletions(-) create mode 100644 crates/engine/src/tests_limits.rs create mode 100644 crates/project/src/limits.rs diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index bf1fd530..3fcf9c6d 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -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 { /// 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; diff --git a/crates/engine/src/commands/comp_more.rs b/crates/engine/src/commands/comp_more.rs index 2c4ca409..d7acfbac 100644 --- a/crates/engine/src/commands/comp_more.rs +++ b/crates/engine/src/commands/comp_more.rs @@ -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 { 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; diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 6f3d2e70..301e59bf 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -43,10 +43,18 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { 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::>().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 { 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 diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 9aecd8d6..4f43a53a 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -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 { } 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, } } diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 09bad38f..a504ec19 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -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, Project, PropGroup, Quality, Solid, TrackMatte, + Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, 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 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 fn pixel_aspect_p(p: &Value) -> Result> { 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)), } } @@ -1404,8 +1405,8 @@ fn layer_settings(s: &mut Session, p: &Value) -> Result { _ => {} } 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); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a9e9b606..ce576ef5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1072,6 +1072,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; diff --git a/crates/engine/src/psd_import.rs b/crates/engine/src/psd_import.rs index efd63a9d..b025a7a5 100644 --- a/crates/engine/src/psd_import.rs +++ b/crates/engine/src/psd_import.rs @@ -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 { diff --git a/crates/engine/src/tests_limits.rs b/crates/engine/src/tests_limits.rs new file mode 100644 index 00000000..68f2c04c --- /dev/null +++ b/crates/engine/src/tests_limits.rs @@ -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)); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index c1f110e0..8a255e09 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -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, 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, 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::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), 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)) } } diff --git a/crates/project/src/limits.rs b/crates/project/src/limits.rs new file mode 100644 index 00000000..998f4022 --- /dev/null +++ b/crates/project/src/limits.rs @@ -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, 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) { + 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) { + 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) -> Option { + 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) { + 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) { + // 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()); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 12d37245..edeb4d45 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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