#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 <noreply@anthropic.com>
This commit is contained in:
parent
9a7d3e7699
commit
765ba157be
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1404,8 +1405,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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue