Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
bflatastic
2026-10-30 09:55:17 +07:00
82 changed files with 2925 additions and 216 deletions
+2 -2
View File
@@ -164,8 +164,8 @@ pub use enabled::always;
pub(crate) use enabled::{has_comp, has_keys, has_layers, has_project_selection, not_yet, not_yet_run};
pub use params::match_path_of;
pub(crate) use params::{
b_p, bad, comp_id, f_p, frontend, layer_mut, layer_p, layers_p, merge_p, resolve_effect_path, resolve_layer, selectable, selected_leaf_props, str_p,
time_p, unlocked, visible_p,
b_p, bad, comp_id, f_p, frontend, layer_mut, layer_p, layers_p, merge_p, popup_options, resolve_effect_path, resolve_layer, selectable,
selected_leaf_props, str_p, time_p, unlocked, visible_p,
};
pub use schema::{accepted_params, check_params, params_schema};
+174 -4
View File
@@ -1,5 +1,5 @@
//! Composition menu, part two: crop to region of interest / layer bounds, Save Frame As ▸ File,
//! Copy Frame to Clipboard, Responsive Design — Time.
//! Composition menu, part two: crop to region of interest / layer bounds, Scale Composition, Save
//! Frame As ▸ File, Copy Frame to Clipboard, Responsive Design — Time.
use effectcraft_project::Marker;
use effectcraft_time::Tick;
@@ -71,6 +71,164 @@ fn crop_to_layers(s: &mut Session, p: &Value) -> Result<Value> {
crop(s, cid, [x0, y0, b[2].ceil() - x0, b[3].ceil() - y0], "Crop Comp to Selected Layer(s) Bounds")
}
/// Multiply a property's value, every key, its spatial tangents and its speeds by `f`.
fn scale_prop(pr: &mut effectcraft_project::Property, f: f64) {
use effectcraft_keyframe::Value as KV;
let mul = |v: &KV| match v {
KV::Scalar(x) => KV::Scalar(x * f),
KV::Vec2(a) => KV::Vec2(a.map(|x| x * f)),
KV::Vec3(a) => KV::Vec3(a.map(|x| x * f)),
other => other.clone(),
};
pr.value = mul(&pr.value);
for k in &mut pr.keys {
k.value = mul(&k.value);
k.spatial_in = k.spatial_in.map(|x| x * f);
k.spatial_out = k.spatial_out.map(|x| x * f);
for e in k.in_ease.iter_mut().chain(&mut k.out_ease) {
e.speed *= f;
}
}
}
/// Multiply only the Z of a 3D value (and of its keys and spatial tangents) by `f`. Speeds follow:
/// a spatial side's speed by how much the path's direction there stretches, so the ease keeps
/// its shape; a per-dimension one (Scale) by `f` for Z only.
fn scale_prop_z(pr: &mut effectcraft_project::Property, f: f64) {
use effectcraft_keyframe::Value as KV;
let stretch = |v: [f64; 3]| {
let n = v[0].hypot(v[1]).hypot(v[2]);
if n > 1e-12 && n.is_finite() { v[0].hypot(v[1]).hypot(v[2] * f) / n } else { 1.0 }
};
let pos = |k: &effectcraft_project::Keyframe| k.value.as_vec3();
let sub = |a: [f64; 3], b: [f64; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
let is_zero = |v: [f64; 3]| v.iter().all(|x| x.abs() <= 1e-12);
// Read every side's direction before anything changes (auto tangents come from the neighbours).
let sides: Vec<(f64, f64)> = (0..pr.keys.len())
.map(|i| {
let (tin, tout) = effectcraft_keyframe::spatial_tangents(&pr.keys, i);
let here = pr.keys.get(i).map(pos).unwrap_or_default();
let chord = |j: Option<usize>| j.and_then(|j| pr.keys.get(j)).map(|k| sub(pos(k), here)).unwrap_or_default();
let tin = if is_zero(tin) { chord(i.checked_sub(1)) } else { tin };
let tout = if is_zero(tout) { chord(Some(i + 1)) } else { tout };
(stretch(tin), stretch(tout))
})
.collect();
let spatial = pr.spatial;
let mul = |v: &mut KV| {
if let KV::Vec3(a) = v {
a[2] *= f;
}
};
mul(&mut pr.value);
for (k, (s_in, s_out)) in pr.keys.iter_mut().zip(sides) {
mul(&mut k.value);
k.spatial_in[2] *= f;
k.spatial_out[2] *= f;
if spatial {
k.in_ease.iter_mut().for_each(|e| e.speed *= s_in);
k.out_ease.iter_mut().for_each(|e| e.speed *= s_out);
} else {
// A shorter list lends its first ease to Z (`effective_ease`): give Z its own.
for list in [&mut k.in_ease, &mut k.out_ease] {
if let Some(&first) = list.first() {
list.resize(list.len().max(3), first);
}
if let Some(e) = list.get_mut(2) {
e.speed *= f;
}
}
}
}
}
/// Scale Composition (After Effects ships it as File ▸ Scripts ▸ Scale Composition.jsx): the comp
/// and its contents grow or shrink by one factor, keys included, in one undo step. Layers whose
/// parent has a Scale follow that parent; everything else scales its Position (and Point of
/// Interest) and Scale. Anchor Point, masks, shapes and effects live in layer space and follow the
/// layer's Scale. Cameras scale their Zoom so 3D layers keep their place in the frame; a 2D parent
/// carries no depth, so its 3D children scale their Z themselves.
fn scale_comp(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "comp.scale";
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let (ow, oh) = (f64::from(comp.width), f64::from(comp.height));
// The three are alternatives: with two, one would be ignored without a word.
if ["factor", "width", "height"].iter().filter(|k| f_p(p, k).is_some()).count() > 1 {
return Err(bad(C, "pass only one of `factor`, `width` or `height`"));
}
let f = match (f_p(p, "factor"), f_p(p, "width"), f_p(p, "height")) {
(Some(f), ..) => f,
(None, Some(w), _) => w / ow,
(None, None, Some(h)) => h / oh,
_ => return Err(bad(C, "pass `factor`, `width` or `height`")),
};
let (w, h) = ((ow * f).round(), (oh * f).round());
let max = f64::from(effectcraft_project::limits::MAX_SIZE);
if !f.is_finite() || f <= 0.0 || !(4.0..=max).contains(&w) || !(4.0..=max).contains(&h) {
return Err(bad(C, format!("the composition would be {w}×{h}: width and height run from 4 to 30,000 px")));
}
let (w, h) = (w as u32, h as u32);
let active = s.active_comp_id() == Some(cid);
s.edit("Scale Composition", None, |proj, st| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
// An ancestor's Scale carries its descendants (X and Y only when that ancestor is 2D),
// through cameras and lights too: the renderer applies every ancestor's transform.
let chain: std::collections::HashMap<_, _> =
c.layers.iter().map(|l| (l.id, (l.transform().is_some_and(|tr| tr.get("scale").is_some()), l.is_3d(), l.parent))).collect();
// (X and Y carried, Z carried); bounded, as parent chains in a file can loop.
let carried = |mut cur: Option<effectcraft_project::LayerId>| {
let (mut xy, mut z) = (false, false);
for _ in 0..64 {
let Some(&(has_scale, three_d, up)) = cur.and_then(|id| chain.get(&id)) else { break };
xy |= has_scale;
z |= has_scale && three_d;
cur = up;
}
(xy, z)
};
for l in &mut c.layers {
let (xy_carried, z_carried) = carried(l.parent);
let three_d = l.is_3d();
let Some(tr) = l.transform_mut() else { continue };
if !xy_carried {
for name in ["position", "positionX", "positionY", "positionZ", "poi", "scale"] {
if let Some(pr) = tr.get_mut(name) {
scale_prop(pr, f);
}
}
} else if three_d && !z_carried {
if let Some(pr) = tr.get_mut("positionZ") {
scale_prop(pr, f);
}
for name in ["position", "poi", "scale"] {
if let Some(pr) = tr.get_mut(name) {
scale_prop_z(pr, f);
}
}
}
if let Some(cam) = l.props.sub_mut("cameraOptions") {
for name in ["zoom", "focusDistance", "aperture"] {
if let Some(pr) = cam.get_mut(name) {
scale_prop(pr, f);
}
}
}
}
// Percentage guides keep their percent.
for g in c.guides.iter_mut().filter(|g| !g.percent) {
g.position *= f;
}
c.width = w;
c.height = h;
if active && let Some(r) = &mut st.region_of_interest {
*r = r.map(|v| v * f);
}
Ok(())
})?;
Ok(json!({"width": w, "height": h, "factor": f}))
}
/// Encode straight 8-bit RGBA as PNG.
pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> std::result::Result<Vec<u8>, String> {
use image::ImageEncoder;
@@ -96,8 +254,11 @@ fn queue_frame(s: &mut Session, p: &Value) -> Result<Value> {
}
if let Some(path) = str_p(p, "path") {
it.output.output = path.to_string();
} else if !it.output.format.is_sequence() {
it.output.output = "[compName]_frame.[fileExtension]".into();
} else {
if !it.output.format.is_sequence() {
it.output.output = "[compName]_frame.[fileExtension]".into();
}
s.avoid_sources(&mut it);
}
let id = s.edit("Save Frame As", None, |proj, _| {
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
@@ -233,6 +394,15 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", ["Composition"], None, "{comp?}", has_roi, crop_to_roi),
cmd!("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", ["Composition"], None, "{layers?}", has_layers, crop_to_layers),
cmd!(
"comp.scale",
"Scale Composition",
[],
None,
"{comp?, factor? (2 = twice the size) | width? | height? (the other side keeps the aspect ratio)}",
has_comp,
scale_comp
),
cmd!(
"comp.saveFrameAs",
"File...",
+1 -2
View File
@@ -1000,8 +1000,7 @@ fn list(_: &mut Session, p: &Value) -> Result<Value> {
let mut out = json!({"id": p.id, "name": p.name, "type": p.default.kind_name(), "default": p.default.to_json()});
match &p.ui {
effectcraft_project::ParamUi::Popup { options } => {
// Engine/MCP popup values are zero-based; JSX exposes one-based values.
out["options"] = json!(options.iter().enumerate().map(|(value, label)| json!({"value": value, "label": label})).collect::<Vec<_>>());
out["options"] = super::popup_options(options);
}
effectcraft_project::ParamUi::Slider { min, max, slider_min, slider_max, decimals } => {
out["min"] = json!(min);
+6
View File
@@ -58,6 +58,12 @@ pub(crate) fn selected_leaf_props(s: &Session) -> Vec<(LayerId, effectcraft_proj
out
}
/// A popup's `[{value, label}]`, as `effect.list`, `prop.get` and `layer.tree` report it.
/// Engine/MCP popup values are zero-based; JSX exposes one-based values.
pub(crate) fn popup_options(options: &[String]) -> Value {
serde_json::json!(options.iter().enumerate().map(|(value, label)| serde_json::json!({"value": value, "label": label})).collect::<Vec<_>>())
}
// ---------- params ----------
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
+3
View File
@@ -491,6 +491,9 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
"time": t.seconds(), "value": raw.to_json(), "animated": !pr.keys.is_empty(),
"keys": keys, "expression": pr.expr.as_ref().map(|e| e.text.clone()),
});
if let Some(o) = l.popup_options(pr) {
out["options"] = super::popup_options(&o);
}
// With an expression, `value` is the keyframed (pre-expression) value; also report what the
// expression makes of it (what renders), or why it fails. A disabled one renders `value`; one
// disabled by its syntax error (prop.setExpression) reports that error.
+2
View File
@@ -191,6 +191,8 @@ fn create_proxy(s: &mut Session, p: &Value) -> Result<Value> {
it.output.channels = Channels::Rgba;
if let Some(o) = str_p(p, "path").or(str_p(p, "output")) {
it.output.output = o.to_string();
} else {
s.avoid_sources(&mut it);
}
it.post_render = PostRenderAction::SetProxy;
let id = s.edit("Create Proxy", None, |proj, _| {
+3
View File
@@ -127,6 +127,9 @@ fn group_json(g: &PropGroup, path: &str, l: &Layer, t: effectcraft_time::Tick, d
if let Some(e) = &p.expr {
o["expression"] = json!(e.text);
}
if let Some(opts) = l.popup_options(p) {
o["options"] = super::popup_options(&opts);
}
o
}
Node::Group(g) => group_json(g, &cpath, l, t, depth + 1, max_depth),
+20 -1
View File
@@ -550,6 +550,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
object.remove("template");
}
apply_output(&mut it.output, &templates, s.state.region_of_interest, &output_params, "renderQueue.add")?;
if str_p(p, "output").or(str_p(p, "path")).is_none() {
s.avoid_sources(&mut it);
}
if let Some(l) = enum_p(p, "log", "renderQueue.add", RenderLog::parse, "errorsOnly|plusSettings|plusPerFrameInfo")? {
it.log = l;
}
@@ -696,8 +699,18 @@ fn render(s: &mut Session, p: &Value) -> Result<Value> {
{
log::warn!("{e}");
}
let queued: std::collections::HashSet<u64> = s.project.render_queue.iter().filter(|i| i.is_queued()).map(|i| i.id).collect();
let ids = s.start_render(wait).map_err(EngineError::Other)?;
let items: Vec<Value> = s.project.render_queue.iter().enumerate().filter(|(_, i)| ids.contains(&i.id)).map(|(k, i)| item_json(s, i, k)).collect();
// Source refusals fail before the exporter starts. Report them just like encoding
// failures, so CLI/MCP callers do not mistake a partly refused queue for full success.
let items: Vec<Value> = s
.project
.render_queue
.iter()
.enumerate()
.filter(|(_, i)| ids.contains(&i.id) || (queued.contains(&i.id) && matches!(i.status, RenderStatus::Failed(_))))
.map(|(k, i)| item_json(s, i, k))
.collect();
Ok(json!({"rendering": s.is_rendering(), "items": items}))
}
@@ -788,6 +801,9 @@ fn pre_render(s: &mut Session, p: &Value) -> Result<Value> {
it.output = templates.default_output_module(TemplateSlot::PreRender);
it.output.output = "[compName]_prerender.[fileExtension]".into();
apply_output(&mut it.output, &templates, s.state.region_of_interest, p, "render.preRender")?;
if str_p(p, "output").or(str_p(p, "path")).is_none() {
s.avoid_sources(&mut it);
}
it.post_render = PostRenderAction::ImportAndReplace;
let id = s.edit("Pre-render", None, |proj, _| {
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
@@ -813,6 +829,9 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result<Value> {
let mut item = RenderQueueItem::new(0, cid);
item.output = OutputModule::for_format(format);
item.output.output = path.clone();
if let Some(refusal) = s.check_outputs(&[vec![(item.clone(), path.clone())]]).first() {
return Err(EngineError::Other(refusal.reason.clone()));
}
let job = crate::ExportJob {
project: &s.project,
footage: s.footage.as_ref(),
+1
View File
@@ -18,6 +18,7 @@ pub mod history;
mod home;
mod interchange;
pub mod menus;
mod output_guard;
pub mod preview;
pub mod render_queue;
pub mod scriptui;
+435
View File
@@ -0,0 +1,435 @@
//! Protect files the project reads from render outputs (#1743).
//!
//! This guard is independent of replacement consent and export staging: it never authorizes
//! overwriting ordinary outputs or opens a second confirmation flow. Those belong to the
//! exporter. Existing aliases are compared by native file identity, including hard links.
//! Missing sources remain protected by their normalized path. Checks happen before dispatch;
//! filesystem changes between the check and the write require exporter-side revalidation.
use std::collections::{HashMap, HashSet};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use effectcraft_project::render_queue::{RenderQueueItem, log_path, numbered_template, sequence_frame, sequence_path};
use effectcraft_project::{Footage, ItemKind};
use crate::Session;
/// Volumes on Windows and macOS ignore case unless they were set up not to.
const IGNORES_CASE: bool = cfg!(any(windows, target_os = "macos"));
/// How many frames are looked at one by one for a sequence whose folder cannot be read at once
/// (its frame number is in a folder's name).
const PROBE_LIMIT: u64 = 100_000;
/// How far a default output name is numbered (`_1` … `_1000`) before it is left to the check at
/// render time.
const MAX_NUMBER: u32 = 1_000;
/// What reads a file the project depends on.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Source {
/// A footage item, by name.
Footage(Arc<str>),
/// An item's proxy, by the item's name.
Proxy(Arc<str>),
/// The project file.
Project,
}
impl Source {
/// Why `path`, a file this reads, cannot be a render's output.
pub(crate) fn refusal(&self, path: &str) -> String {
match self {
Source::Footage(name) => format!("{path} is the footage “{name}” of this project, and a render never replaces footage; choose another Output To"),
Source::Proxy(name) => format!("{path} is the proxy of “{name}”, and a render never replaces a proxy; choose another Output To"),
Source::Project => format!("{path} is this project's own file, and a render never replaces it; choose another Output To"),
}
}
}
/// The files the project reads: footage (every frame of a sequence), proxies and the project file.
#[derive(Default)]
pub(crate) struct Protected {
files: Vec<(PathBuf, Source)>,
by_path: HashMap<String, usize>,
/// Hashes of native identities, built once. Handles are closed after indexing so long
/// sequences do not exhaust the process's file descriptors. Hash hits are verified with
/// native handle equality; a hash collision cannot make another file a protected source.
#[cfg(not(target_arch = "wasm32"))]
by_identity: std::sync::OnceLock<HashMap<u64, Vec<usize>>>,
#[cfg(all(test, not(target_arch = "wasm32")))]
identity_reads: std::sync::atomic::AtomicUsize,
}
impl Protected {
pub(crate) fn of(s: &Session) -> Protected {
let mut found = Protected::default();
for item in s.project.items.values() {
let name: Arc<str> = item.name.as_str().into();
if let ItemKind::Footage(f) = &item.kind {
found.add_footage(s, f, &Source::Footage(name.clone()));
}
if let Some(proxy) = &item.proxy {
found.add_footage(s, &proxy.footage, &Source::Proxy(name));
}
}
if let Some(path) = s.path.as_deref().filter(|p| !p.is_empty()) {
found.add(PathBuf::from(path), Source::Project);
}
found
}
fn add_footage(&mut self, s: &Session, f: &Footage, source: &Source) {
for stored in std::iter::once(&f.path).chain(&f.sequence).filter(|p| !p.is_empty()) {
// The file the media layer opens for the path the project stores.
let path = s.stored_path(stored).unwrap_or_else(|_| PathBuf::from(stored));
self.add(path, source.clone());
}
}
fn add(&mut self, path: PathBuf, source: Source) {
self.by_path.entry(key(&path)).or_insert(self.files.len());
self.files.push((path, source));
}
pub(crate) fn is_empty(&self) -> bool {
self.files.is_empty()
}
/// The source at this normalized path or native file identity. A new output only needs
/// its path lookup and one failed filesystem probe; existing outputs build the index once.
pub(crate) fn find(&self, path: &Path) -> Option<&Source> {
self.by_path.get(&key(path)).and_then(|i| self.files.get(*i)).map(|(_, source)| source).or_else(|| self.alias_of(path))
}
#[cfg(not(target_arch = "wasm32"))]
fn handle(&self, path: &Path) -> Option<same_file::Handle> {
#[cfg(test)]
self.identity_reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
// Opening a FIFO can wait for a writer. Only regular files can be protected media;
// preserve the old size-indexed lookup's exclusion of special files and directories.
if !std::fs::metadata(path).ok()?.is_file() {
return None;
}
same_file::Handle::from_path(path).ok()
}
/// Hashes narrow the search, while native handle equality also checks any hash collisions.
#[cfg(not(target_arch = "wasm32"))]
fn alias_of(&self, path: &Path) -> Option<&Source> {
let output = self.handle(path)?;
let indexed = self.by_identity.get_or_init(|| {
let mut ids: HashMap<u64, Vec<usize>> = HashMap::new();
for (i, (known, _)) in self.files.iter().enumerate() {
if let Some(handle) = self.handle(known) {
ids.entry(identity_hash(&handle)).or_default().push(i);
}
}
ids
});
indexed.get(&identity_hash(&output))?.iter().find_map(|i| {
let (known, source) = self.files.get(*i)?;
(self.handle(known)? == output).then_some(source)
})
}
#[cfg(target_arch = "wasm32")]
fn alias_of(&self, _: &Path) -> Option<&Source> {
None
}
}
#[cfg(not(target_arch = "wasm32"))]
fn identity_hash(handle: &same_file::Handle) -> u64 {
use std::hash::{Hash, Hasher};
let mut hash = std::collections::hash_map::DefaultHasher::new();
handle.hash(&mut hash);
hash.finish()
}
/// `path` made absolute, with `.` and `..` resolved by name alone (no file is looked at).
fn lexical(path: &Path) -> PathBuf {
let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
let mut out = PathBuf::new();
for part in absolute.components() {
match part {
Component::CurDir => {}
Component::ParentDir => match out.components().next_back() {
Some(Component::Normal(_)) => {
out.pop();
}
Some(Component::RootDir | Component::Prefix(_)) => {}
_ => out.push(".."),
},
other => out.push(other.as_os_str()),
}
}
out
}
/// The name a path is compared by: absolute, resolved by name, without case where volumes ignore it.
fn key(path: &Path) -> String {
let parts: Vec<String> = lexical(path).components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect();
fold(&parts.join("/"))
}
fn fold(name: &str) -> String {
if IGNORES_CASE { name.to_lowercase() } else { name.to_string() }
}
#[cfg(test)]
fn same_file(a: &Path, b: &Path) -> bool {
if key(a) == key(b) {
return true;
}
#[cfg(not(target_arch = "wasm32"))]
return same_file::is_same_file(a, b).unwrap_or(false);
#[cfg(target_arch = "wasm32")]
false
}
/// Whether a file (or a link, even one leading nowhere) is at `path`.
fn exists(path: &str) -> bool {
std::fs::symlink_metadata(path).is_ok_and(|m| !m.is_dir())
}
/// The files of a numbered sequence (`count` frames from `first`) that are on disk: its folder is
/// read once, not asked about frame by frame.
fn frames_on_disk(path: &str, first: i64, count: u64) -> Result<Vec<PathBuf>, String> {
let last = first.saturating_add(i64::try_from(count).unwrap_or(i64::MAX));
let p = Path::new(path);
let dir = p.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
let name = p.file_name().map(|n| n.to_string_lossy().into_owned());
let Some(name) = name.filter(|_| !dir.to_string_lossy().contains('#')) else {
// The frame number is in a folder's name: every frame has a folder of its own.
if count > PROBE_LIMIT {
return Err(format!(
"cannot safely check {count} output frames with a number in a folder name (limit {PROBE_LIMIT}); choose a fixed output folder or a shorter range"
));
}
return Ok((0..count)
.map(|k| sequence_path(path, first.saturating_add(i64::try_from(k).unwrap_or(i64::MAX))))
.filter(|frame| exists(frame))
.map(PathBuf::from)
.collect());
};
let pattern = fold(&name);
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(format!("cannot check render output folder {}: {e}", dir.display())),
};
Ok(entries
.filter_map(Result::ok)
.filter(|e| sequence_frame(&pattern, &fold(&e.file_name().to_string_lossy())).is_some_and(|frame| (first..last).contains(&frame)))
.filter(|e| !e.file_type().is_ok_and(|t| t.is_dir()))
.map(|e| e.path())
.collect())
}
/// A queue item refused before any of its output modules are dispatched.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Refused {
pub item: u64,
pub reason: String,
}
impl Session {
/// The files the project reads.
pub(crate) fn protected_files(&self) -> Protected {
Protected::of(self)
}
/// Reject protected outputs and ranges that cannot be checked completely. Ordinary-file
/// replacement consent is left to the exporter, independently of this source guard.
pub(crate) fn check_outputs(&self, items: &[Vec<(RenderQueueItem, String)>]) -> Vec<Refused> {
if cfg!(target_arch = "wasm32") {
return Vec::new();
}
let protected = self.protected_files();
let mut seen = HashSet::new();
let mut refused = Vec::new();
for (item, path) in items.iter().flatten() {
if seen.contains(&item.id) {
continue;
}
let reason = match self.check_output(&protected, item, path) {
Ok(reason) => reason,
Err(reason) => Some(reason),
};
if let Some(reason) = reason {
seen.insert(item.id);
refused.push(Refused { item: item.id, reason });
}
}
refused
}
fn check_output(&self, protected: &Protected, item: &RenderQueueItem, path: &str) -> Result<Option<String>, String> {
if protected.is_empty() {
return Ok(None);
}
let sequence = item.output.format.is_sequence();
// Skip Existing Files leaves existing frames untouched, but not the render log.
let frames = if sequence && !item.settings.skip_existing {
match self.project.comp(item.comp) {
Some(comp) => frames_on_disk(path, item.settings.first_frame(comp), item.settings.frame_count(comp))?,
None => Vec::new(),
}
} else {
Vec::new()
};
let log = log_path(path);
let movie = (!sequence).then_some(Path::new(path));
Ok(frames
.iter()
.map(PathBuf::as_path)
.chain(movie)
.chain(Some(Path::new(&log)))
.find_map(|path| protected.find(path).map(|source| source.refusal(&path.to_string_lossy()))))
}
/// A new item's default output must not be a file the project reads: `[compName]_1`,
/// `[compName]_2`… take its place (and avoid the queue's other outputs), as After Effects
/// numbers a second item of the same composition. An output nothing is in the way of keeps
/// the composition's own name, and one the caller chose is not touched: rendering it is
/// refused instead.
pub(crate) fn avoid_sources(&self, item: &mut RenderQueueItem) {
if cfg!(target_arch = "wasm32") {
return;
}
let protected = self.protected_files();
if protected.is_empty() {
return;
}
let template = item.output.output.clone();
let mut taken: Option<HashSet<String>> = None;
'candidate: for n in 0..=MAX_NUMBER {
item.output.output = if n == 0 { template.clone() } else { numbered_template(&template, n) };
let Ok(outputs) = self.outputs_of(item, None) else { break };
for (module, path) in &outputs {
match self.check_output(&protected, module, path) {
Ok(Some(_)) => continue 'candidate,
Err(_) => break 'candidate,
Ok(None) => {}
}
}
// Only a name that had to move is kept clear of the other items.
let others = if n == 0 { None } else { Some(taken.get_or_insert_with(|| self.queue_output_keys())) };
if others.is_none_or(|others| !outputs.iter().any(|(_, path)| others.contains(&key(Path::new(path))))) {
return;
}
}
item.output.output = template;
}
/// [`key`] of every output of the queue's items.
fn queue_output_keys(&self) -> HashSet<String> {
self.project.render_queue.iter().filter_map(|it| self.outputs_of(it, None).ok()).flatten().map(|(_, path)| key(Path::new(&path))).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn unrelated_same_size_frames_are_indexed_once() {
let dir = std::env::temp_dir().join(format!("effectcraft-identity-index-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut protected = Protected::default();
for i in 0..128 {
let source = dir.join(format!("source{i}")).join("plate.png");
let output = dir.join(format!("output{i}")).join("plate.png");
for path in [&source, &output] {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, b"same-size frame").unwrap();
}
protected.add(source, Source::Footage(format!("plate{i}").into()));
}
for i in 0..128 {
assert!(protected.find(&dir.join(format!("output{i}")).join("plate.png")).is_none());
}
assert_eq!(protected.identity_reads.load(std::sync::atomic::Ordering::Relaxed), 256, "one source-index pass plus one lookup per output");
let alias = dir.join("renamed.png");
std::fs::hard_link(dir.join("source64").join("plate.png"), &alias).unwrap();
assert_eq!(protected.find(&alias), Some(&Source::Footage("plate64".into())));
assert_eq!(protected.identity_reads.load(std::sync::atomic::Ordering::Relaxed), 258, "native equality verifies a matching identity");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn folder_numbered_sequences_are_never_partially_checked() {
let pattern = std::env::temp_dir().join("frames[######]").join("plate.png").to_string_lossy().into_owned();
let error = frames_on_disk(&pattern, 0, PROBE_LIMIT + 1).unwrap_err();
assert!(error.contains("cannot safely check") && error.contains("100000"), "{error}");
assert!(frames_on_disk(&pattern, 0, u64::MAX).is_err());
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn non_file_outputs_do_not_build_the_identity_index() {
let protected = Protected::default();
assert!(protected.find(&std::env::temp_dir()).is_none());
assert!(protected.by_identity.get().is_none());
}
#[test]
fn paths_compare_by_name_once_resolved() {
let here = std::env::current_dir().unwrap();
assert_eq!(key(Path::new("a/b/../c.mp4")), key(Path::new("a/c.mp4")));
assert_eq!(key(Path::new("./a/c.mp4")), key(&here.join("a/c.mp4")));
assert_ne!(key(Path::new("a/c.mp4")), key(Path::new("b/c.mp4")));
assert_eq!(key(Path::new(&format!("{}x", "../".repeat(64)))), key(Path::new(&format!("{}x", "../".repeat(80)))), "past the root stays at the root");
assert_eq!(IGNORES_CASE, key(Path::new("A/C.MP4")) == key(Path::new("a/c.mp4")));
}
#[test]
fn a_file_is_the_same_file_under_every_spelling_the_volume_allows() {
let dir = std::env::temp_dir().join(format!("effectcraft-output-guard-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("clip.mov"), b"x").unwrap();
std::fs::write(dir.join("other.mov"), b"y").unwrap();
assert!(same_file(&dir.join("clip.mov"), &dir.join("sub").join("..").join("clip.mov")));
assert!(!same_file(&dir.join("clip.mov"), &dir.join("other.mov")));
// Nothing there yet: only the name decides.
assert!(same_file(&dir.join("gone.mov"), &dir.join("sub").join("..").join("gone.mov")));
assert!(!same_file(&dir.join("gone.mov"), &dir.join("clip.mov")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_sequences_folder_is_read_once_for_its_frames() {
let dir = std::env::temp_dir().join(format!("effectcraft-output-frames-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("a_00003.png")).unwrap(); // a folder is not a frame
for name in ["a_00001.png", "a_00002.png", "a_0004.png", "a_00004.jpg", "b_00002.png", "a_00009.png"] {
std::fs::write(dir.join(name), b"x").unwrap();
}
let pattern = dir.join("a_[#####].png").to_string_lossy().into_owned();
let names = |frames: Vec<PathBuf>| {
let mut names: Vec<String> = frames.iter().filter_map(|f| f.file_name()).map(|n| n.to_string_lossy().into_owned()).collect();
names.sort();
names
};
assert_eq!(
names(frames_on_disk(&pattern, 0, 5).unwrap()),
["a_00001.png", "a_00002.png"],
"frames 0-4: not the 4-digit one, the jpg, the folder or frame 9"
);
assert_eq!(names(frames_on_disk(&pattern, 2, 1).unwrap()), ["a_00002.png"]);
assert_eq!(names(frames_on_disk(&pattern, 9, 100).unwrap()), ["a_00009.png"]);
assert_eq!(names(frames_on_disk(&pattern, 0, u64::MAX).unwrap()), ["a_00001.png", "a_00002.png", "a_00009.png"], "a hostile frame count is not walked");
// A frame number in a folder's name: each frame has its own folder.
std::fs::create_dir_all(dir.join("f02")).unwrap();
std::fs::write(dir.join("f02").join("x.png"), b"x").unwrap();
let in_folder = dir.join("f[##]").join("x.png").to_string_lossy().into_owned();
assert_eq!(names(frames_on_disk(&in_folder, 0, 4).unwrap()), ["x.png"]);
// A folder that is not there holds nothing.
assert!(frames_on_disk(&dir.join("none").join("a_[#####].png").to_string_lossy(), 0, 5).unwrap().is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
}
+59 -36
View File
@@ -442,10 +442,46 @@ impl Session {
self.render_job.as_ref().map(|j| j.shared.snapshot())
}
/// Start rendering every queued item. `wait`: block until done (otherwise a background thread
/// renders, or the [`Session::offload`] worker; call [`Session::poll_render`] regularly).
/// Returns the ids being rendered. Without threads or an offload (wasm32) the render always
/// runs to completion before this returns.
/// The (item, resolved output path) of each output of `it`: one per output module and, with
/// Segment Sequences / Segment Movie Files, per segment. `formats`: the ones that can be
/// written (`None`: all). Fails with the status the item gets instead.
pub(crate) fn outputs_of(&self, it: &RenderQueueItem, formats: Option<&[OutputFormat]>) -> Result<Vec<(RenderQueueItem, String)>, RenderStatus> {
let mut modules = vec![];
for om in it.output_modules() {
let mut m = it.clone();
m.output = om.clone();
match self.resolve_output(&m) {
Some(p) if formats.is_none_or(|f| f.contains(&om.format)) => {
// Settings ▸ Export: Append Bit Depth to File Name, Segment Sequences /
// Movie Files.
let bits = output_bits(om.format, self.project.settings.bit_depth);
let p = self.prefs.output_name(&p, bits);
modules.extend(self.segments(&m, &p));
}
Some(_) => return Err(RenderStatus::Failed(format!("{} export is not available", om.format.label()))),
None if self.project.comp(it.comp).is_none() => return Err(RenderStatus::Failed("composition missing".into())),
None => return Err(RenderStatus::NeedsOutput),
}
}
Ok(modules)
}
/// What the next Render would write: for each queued item its [`Session::outputs_of`], and
/// the status of each queued item that has none.
pub(crate) fn queued_outputs(&self, formats: &[OutputFormat]) -> (Vec<Vec<(RenderQueueItem, String)>>, Vec<(u64, RenderStatus)>) {
let (mut items, mut problems) = (Vec::new(), Vec::new());
for it in self.project.render_queue.iter().filter(|i| i.is_queued()) {
match self.outputs_of(it, Some(formats)) {
Ok(modules) => items.push(modules),
Err(status) => problems.push((it.id, status)),
}
}
(items, problems)
}
/// Start rendering every queued item, refusing outputs that replace footage, proxies or
/// the project file. Other existing outputs keep the exporter's usual replacement policy.
/// `wait`: block until done, otherwise render in the background and poll regularly.
pub fn start_render(&mut self, wait: bool) -> Result<Vec<u64>, String> {
let offload = self.offload.clone().filter(|_| !wait);
let wait = wait || (cfg!(target_arch = "wasm32") && offload.is_none());
@@ -455,46 +491,33 @@ impl Session {
self.poll_render();
let exporter = self.exporter.clone().ok_or("export is not available in this build")?;
let formats = exporter.formats();
let mut items = Vec::new();
let mut needs_output = Vec::new();
for it in self.project.render_queue.iter().filter(|i| i.is_queued()) {
let mut modules = vec![];
let mut problem = None;
for om in it.output_modules() {
let mut m = it.clone();
m.output = om.clone();
match self.resolve_output(&m) {
Some(p) if formats.contains(&om.format) => {
// Settings ▸ Export: Append Bit Depth to File Name, Segment Sequences /
// Movie Files.
let bits = output_bits(om.format, self.project.settings.bit_depth);
let p = self.prefs.output_name(&p, bits);
modules.extend(self.segments(&m, &p));
}
Some(_) => problem = Some(RenderStatus::Failed(format!("{} export is not available", om.format.label()))),
None if self.project.comp(it.comp).is_none() => problem = Some(RenderStatus::Failed("composition missing".into())),
None => problem = Some(RenderStatus::NeedsOutput),
}
if problem.is_some() {
break;
}
}
match problem {
Some(st) => needs_output.push((it.id, st)),
None => items.push(modules),
}
}
if !needs_output.is_empty() {
let (mut items, mut problems) = self.queued_outputs(&formats);
let refused = self.check_outputs(&items);
items.retain(|modules| modules.first().is_none_or(|(it, _)| !refused.iter().any(|r| r.item == it.id)));
problems.extend(refused.iter().map(|r| (r.item, RenderStatus::Failed(r.reason.clone()))));
if !problems.is_empty() {
let p = Arc::make_mut(&mut self.project);
for (id, st) in needs_output {
for (id, st) in problems {
if let Some(i) = p.render_queue.iter_mut().find(|i| i.id == id) {
i.status = st;
}
}
self.bump();
}
if let Some(first) = refused.first() {
let message = if refused.len() == 1 {
format!("Not rendered: {}", first.reason)
} else {
format!("{} items were not rendered; first: {}", refused.len(), first.reason)
};
self.events.push(Event::Toast { message, error: true });
}
if items.is_empty() {
return Err("nothing is queued: add a composition (Composition ▸ Add to Render Queue) and tick Render".into());
return Err(match refused.as_slice() {
[] => "nothing is queued: add a composition (Composition ▸ Add to Render Queue) and tick Render".into(),
[only] => only.reason.clone(),
[first, ..] => format!("{} items would replace files the project reads; first: {}", refused.len(), first.reason),
});
}
let ids: Vec<u64> = items.iter().filter_map(|m| m.first().map(|(i, _)| i.id)).collect();
if let Some(off) = offload {
+2
View File
@@ -36,9 +36,11 @@ mod panels;
mod project_items;
mod proxy;
mod quick_offset;
mod render_overwrite;
mod replace_source;
mod rig3d;
pub(crate) mod roto;
mod scale_comp;
mod sequence;
mod session;
mod settings;
+606
View File
@@ -0,0 +1,606 @@
//! Rendering never replaces a file the project reads (#1743). Ordinary-file replacement
//! behavior stays with the exporter and its confirmation API.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use effectcraft_color::Label;
use effectcraft_project::render_queue::{OutputFormat, RenderStatus, sequence_path};
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use crate::{ExportJob, ExportResult, Exporter, Session};
const SOURCE: &[u8] = b"SOURCE FOOTAGE";
const EARLIER: &[u8] = b"AN EARLIER RENDER";
const RENDERED: &[u8] = b"RENDERED";
/// An exporter that writes like the real one: `RENDERED` at the job's path, or at the path of
/// every frame for a sequence (leaving the frames that exist alone when the item skips them).
#[derive(Default)]
struct Writer {
jobs: Mutex<Vec<String>>,
}
impl Exporter for Writer {
fn formats(&self) -> Vec<OutputFormat> {
OutputFormat::ALL.to_vec()
}
fn export(&self, job: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
self.jobs.lock().unwrap().push(job.path.to_string());
let comp = job.project.comp(job.item.comp).ok_or("no comp")?;
let paths: Vec<String> = if job.item.output.format.is_sequence() {
let first = job.item.settings.first_frame(comp);
(0..job.item.settings.frame_count(comp)).map(|k| sequence_path(job.path, first + k as i64)).collect()
} else {
vec![job.path.to_string()]
};
for path in &paths {
if job.item.settings.skip_existing && Path::new(path).exists() {
continue;
}
if let Some(dir) = Path::new(path).parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
std::fs::write(path, RENDERED).map_err(|e| e.to_string())?;
}
Ok(ExportResult { path: paths.first().cloned().unwrap_or_default(), ..Default::default() })
}
}
/// A session whose project is saved in a fresh folder (the footage goes there too), with an
/// exporter that writes real files.
struct Fixture {
s: Session,
writer: Arc<Writer>,
dir: PathBuf,
}
impl Fixture {
fn new(name: &str) -> Fixture {
let dir = std::env::temp_dir().join("effectcraft-render-overwrite").join(format!("{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::default();
let writer = Arc::new(Writer::default());
s.exporter = Some(writer.clone());
s.execute("file.saveAs", json!({"path": dir.join("Project.ecproj").to_string_lossy()})).unwrap();
Fixture { s, writer, dir }
}
fn path(&self, file: &str) -> PathBuf {
self.dir.join(file)
}
fn text(&self, file: &str) -> String {
self.path(file).to_string_lossy().into_owned()
}
fn read(&self, file: &str) -> Vec<u8> {
std::fs::read(self.path(file)).unwrap()
}
fn write(&self, file: &str, bytes: &[u8]) {
std::fs::write(self.path(file), bytes).unwrap();
}
/// A video file in the project's folder, imported.
fn footage(&mut self, file: &str) -> ItemId {
self.write(file, SOURCE);
let f = Footage {
path: self.text(file),
kind: FootageKind::Video,
width: 64,
height: 36,
has_video: true,
duration: Tick::from_seconds_f64(1.0),
..Default::default()
};
Arc::make_mut(&mut self.s.project).add_item(file, Label::None, None, ItemKind::Footage(f))
}
/// Numbered stills in the project's folder, imported as one image sequence.
fn sequence(&mut self, name: &str, frames: std::ops::Range<u32>) -> ItemId {
let files: Vec<String> = frames
.map(|n| {
let file = format!("{name}_{n:05}.png");
self.write(&file, SOURCE);
self.text(&file)
})
.collect();
let f = Footage {
path: files[0].clone(),
kind: FootageKind::Sequence,
sequence: files,
width: 16,
height: 16,
has_video: true,
duration: Tick::from_seconds_f64(0.5),
..Default::default()
};
Arc::make_mut(&mut self.s.project).add_item(&format!("{name}_[00000-00004].png"), Label::None, None, ItemKind::Footage(f))
}
/// A five-frame composition.
fn comp(&mut self, name: &str) {
self.s.execute("comp.new", json!({"name": name, "width": 16, "height": 16, "frameRate": 10, "duration": 0.5})).unwrap();
}
/// The output path of queue item `index` (0-based).
fn output(&self, index: usize) -> PathBuf {
PathBuf::from(self.s.resolve_output(&self.s.project.render_queue[index]).unwrap())
}
/// The paths the exporter was started for.
fn exported(&self) -> Vec<String> {
self.writer.jobs.lock().unwrap().clone()
}
fn render(&mut self, params: Value) -> crate::Result<Value> {
self.s.execute("renderQueue.render", params)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn link(target: &Path, link: &Path) -> std::io::Result<()> {
#[cfg(unix)]
return std::os::unix::fs::symlink(target, link);
#[cfg(windows)]
return std::os::windows::fs::symlink_file(target, link);
#[cfg(not(any(unix, windows)))]
{
let _ = (target, link);
Err(std::io::ErrorKind::Unsupported.into())
}
}
/// A folder that leads to `target`: a symbolic link, or on Windows, where that takes a privilege
/// most accounts lack, a junction.
fn link_dir(target: &Path, link: &Path) -> std::io::Result<()> {
#[cfg(unix)]
return std::os::unix::fs::symlink(target, link);
#[cfg(windows)]
return std::os::windows::fs::symlink_dir(target, link).or_else(|_| {
let made = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]).arg(link).arg(target).output()?;
if made.status.success() { Ok(()) } else { Err(std::io::Error::other(String::from_utf8_lossy(&made.stdout).into_owned())) }
});
#[cfg(not(any(unix, windows)))]
{
let _ = (target, link);
Err(std::io::ErrorKind::Unsupported.into())
}
}
/// The report: save a new project beside `footage.mp4`, import it, make a composition from it
/// (it is named after the file), add that to the Render Queue and click Render. The composition's
/// default output is `<project folder>/footage.mp4`, the footage itself.
#[test]
fn a_comp_made_from_footage_does_not_render_over_it() {
let mut f = Fixture::new("comp-from-footage");
let footage = f.footage("footage.mp4");
f.s.state.project_selection = vec![footage];
f.s.execute("file.newCompFromSelection", json!({"addToRenderQueue": true})).unwrap();
assert_eq!(f.s.project.render_queue.len(), 1);
f.render(json!({})).unwrap();
assert_eq!(f.read("footage.mp4"), SOURCE, "the footage is still the footage");
assert_eq!(f.s.project.render_queue[0].status, RenderStatus::Done);
// The default name stepped aside: the render sits next to it.
assert_eq!(f.output(0), f.path("footage_1.mp4"));
assert_eq!(f.read("footage_1.mp4"), RENDERED);
// Another item for the same composition takes the next free number.
f.s.execute("renderQueue.add", json!({})).unwrap();
assert_eq!(f.output(1), f.path("footage_2.mp4"));
}
#[test]
fn an_output_that_is_the_footage_is_refused_with_or_without_overwrite() {
for (n, params) in [json!({}), json!({"overwrite": true})].into_iter().enumerate() {
let mut f = Fixture::new(&format!("explicit-footage-{n}"));
f.footage("clip.mov");
f.comp("Main");
let clip = f.text("clip.mov");
f.s.execute("renderQueue.add", json!({"format": "prores", "output": clip})).unwrap();
let e = f.render(params).unwrap_err().to_string();
assert!(e.contains("clip.mov") && e.contains("footage"), "{e}");
assert_eq!(f.read("clip.mov"), SOURCE);
assert!(f.exported().is_empty(), "the exporter was not started");
let RenderStatus::Failed(why) = &f.s.project.render_queue[0].status else { panic!("{:?}", f.s.project.render_queue[0].status) };
assert!(why.contains("clip.mov"), "{why}");
}
}
/// An item that would replace the footage fails on its own; the rest of the queue renders.
#[test]
fn the_rest_of_the_queue_renders_when_one_item_would_replace_footage() {
let mut f = Fixture::new("one-refused");
f.footage("clip.mov");
f.comp("Main");
let clip = f.text("clip.mov");
f.s.execute("renderQueue.add", json!({"format": "prores", "output": clip})).unwrap();
let ok = f.text("main.mp4");
f.s.execute("renderQueue.add", json!({"output": ok})).unwrap();
let r = f.render(json!({})).unwrap();
assert_eq!(f.read("clip.mov"), SOURCE);
assert_eq!(f.read("main.mp4"), RENDERED);
assert!(matches!(f.s.project.render_queue[0].status, RenderStatus::Failed(_)));
assert_eq!(f.s.project.render_queue[1].status, RenderStatus::Done);
assert!(r["items"].as_array().unwrap().iter().any(|i| i["status"].get("Failed").is_some()), "the reply includes the refused item: {r}");
assert!(r["items"].as_array().unwrap().iter().any(|i| i["status"] == "Done"), "{r}");
}
#[test]
fn the_project_file_is_never_an_output() {
let mut f = Fixture::new("project-file");
// A project saved under a name a movie could have.
let name = f.text("final.mp4");
f.s.execute("file.saveAs", json!({"path": name})).unwrap();
f.comp("final");
f.s.execute("renderQueue.add", json!({})).unwrap();
assert_eq!(f.output(0), f.path("final_1.mp4"), "the default name moves aside");
f.s.execute("renderQueue.remove", json!({"index": 1})).unwrap();
f.s.execute("renderQueue.add", json!({"output": name})).unwrap();
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
assert!(e.contains("final.mp4") && e.contains("project"), "{e}");
assert!(std::fs::read_to_string(f.path("final.mp4")).unwrap().contains("\"schema\""), "the project file is still a project");
}
#[test]
fn a_proxy_is_not_an_output_either() {
let mut f = Fixture::new("proxy");
f.footage("clip.mov");
f.write("proxy.mov", SOURCE);
f.comp("Main");
let proxy = Footage { path: f.text("proxy.mov"), kind: FootageKind::Video, has_video: true, ..Default::default() };
let comp = f.s.active_comp_id().unwrap();
Arc::make_mut(&mut f.s.project).item_mut(comp).unwrap().proxy = Some(Box::new(effectcraft_project::Proxy { footage: proxy, enabled: true }));
let target = f.text("proxy.mov");
f.s.execute("renderQueue.add", json!({"format": "prores", "output": target})).unwrap();
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
assert!(e.contains("proxy"), "{e}");
assert_eq!(f.read("proxy.mov"), SOURCE);
}
/// Recreating a default proxy writes a new file and attaches it, without replacing the proxy
/// the project is currently reading. Explicitly choosing that protected path is still refused.
#[test]
fn creating_a_proxy_again_chooses_a_new_default_and_attaches_it() {
struct Probe;
impl crate::Importer for Probe {
fn probe(&self, path: &str) -> Result<Footage, String> {
if !Path::new(path).is_file() {
return Err("proxy file missing".into());
}
Ok(Footage {
path: path.into(),
kind: if path.ends_with(".png") { FootageKind::Still } else { FootageKind::Video },
width: 16,
height: 16,
has_video: true,
..Default::default()
})
}
}
for kind in ["movie", "still"] {
let mut f = Fixture::new(&format!("recreate-proxy-{kind}"));
f.s.importer = Some(Arc::new(Probe));
f.comp("Main");
let comp = f.s.active_comp_id().unwrap();
f.s.execute("file.createProxy", json!({"kind": kind})).unwrap();
f.render(json!({})).unwrap();
let first = f.s.project.item(comp).unwrap().proxy.as_ref().unwrap().footage.path.clone();
assert_eq!(std::fs::read(&first).unwrap(), RENDERED);
f.s.execute("file.createProxy", json!({"kind": kind})).unwrap();
f.render(json!({})).unwrap();
let second = f.s.project.item(comp).unwrap().proxy.as_ref().unwrap().footage.path.clone();
assert_ne!(second, first, "the replacement proxy gets its own file");
assert_eq!(std::fs::read(&second).unwrap(), RENDERED);
assert_eq!(std::fs::read(&first).unwrap(), RENDERED);
assert_eq!(f.exported().len(), 2);
// For a still proxy, an explicit output is the sequence pattern, not its first
// rendered frame (a bare PNG name would have another frame number appended).
let protected_output = f.output(1);
f.s.execute("file.createProxy", json!({"kind": kind, "path": protected_output})).unwrap();
let error = f.render(json!({})).unwrap_err().to_string();
assert!(error.contains("never replaces a proxy"), "{error}");
assert_eq!(f.exported().len(), 2);
}
}
/// A pre-render becomes imported footage. Its old output stays protected even from the
/// same queue item; issuing Pre-render again supplies a fresh default output instead.
#[test]
fn refreshing_a_pre_render_uses_a_new_default_without_exempting_imported_footage() {
struct Probe;
impl crate::Importer for Probe {
fn probe(&self, path: &str) -> Result<Footage, String> {
if !Path::new(path).is_file() {
return Err("pre-render missing".into());
}
Ok(Footage { path: path.into(), kind: FootageKind::Video, width: 16, height: 16, has_video: true, ..Default::default() })
}
}
let mut f = Fixture::new("refresh-pre-render");
f.s.importer = Some(Arc::new(Probe));
f.comp("Main");
f.s.execute("render.preRender", json!({"format": "gif"})).unwrap();
let first = f.output(0);
f.render(json!({})).unwrap();
f.s.execute("renderQueue.setRender", json!({"index": 1, "render": true})).unwrap();
let error = f.render(json!({})).unwrap_err().to_string();
assert!(error.contains("footage"), "{error}");
assert_eq!(std::fs::read(&first).unwrap(), RENDERED);
assert_eq!(f.exported().len(), 1);
f.s.execute("renderQueue.setRender", json!({"index": 1, "render": false})).unwrap();
f.s.execute("render.preRender", json!({"format": "gif"})).unwrap();
let second = f.output(1);
assert_ne!(first, second);
f.render(json!({})).unwrap();
assert_eq!(std::fs::read(first).unwrap(), RENDERED);
assert_eq!(std::fs::read(second).unwrap(), RENDERED);
assert_eq!(f.exported().len(), 2);
}
/// Exercise the engine's import and project serialization paths, rather than injecting a
/// Footage item directly. The importer reads dimensions from a real, generated PNG.
#[test]
fn imported_footage_stays_protected_after_the_project_is_reopened() {
struct PngProbe;
impl crate::Importer for PngProbe {
fn probe(&self, path: &str) -> Result<Footage, String> {
let (width, height) = image::image_dimensions(path).map_err(|e| e.to_string())?;
Ok(Footage { path: path.into(), kind: FootageKind::Still, width, height, has_video: true, ..Default::default() })
}
}
let mut f = Fixture::new("import-reopen");
f.s.importer = Some(Arc::new(PngProbe));
let path = f.path("plate_00002.png");
image::RgbaImage::from_pixel(4, 4, image::Rgba([40, 80, 120, 255])).save(&path).unwrap();
let original = std::fs::read(&path).unwrap();
let result = f.s.execute("file.import", json!({"paths": [path]})).unwrap();
assert_eq!(result["items"].as_array().unwrap().len(), 1);
f.comp("Main");
f.s.execute("renderQueue.add", json!({"format": "png", "output": f.text("plate_[#####].png")})).unwrap();
f.s.execute("file.save", json!({})).unwrap();
f.s.execute("file.open", json!({"path": f.text("Project.ecproj")})).unwrap();
let error = f.render(json!({})).unwrap_err().to_string();
assert!(error.contains("plate_00002.png") && error.contains("footage"), "{error}");
assert_eq!(std::fs::read(&path).unwrap(), original);
assert!(f.exported().is_empty());
}
#[test]
fn a_protected_frame_past_the_probe_limit_is_not_rendered() {
struct LastFrame(PathBuf);
impl Exporter for LastFrame {
fn formats(&self) -> Vec<OutputFormat> {
vec![OutputFormat::PngSequence]
}
fn export(&self, _: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
// A synthetic exporter writes only the vulnerable late frame, so a broken guard
// can be reproduced without creating 100,001 output directories.
std::fs::write(&self.0, RENDERED).map_err(|e| e.to_string())?;
Ok(ExportResult { path: self.0.to_string_lossy().into_owned(), ..Default::default() })
}
}
let mut f = Fixture::new("late-frame");
std::fs::create_dir_all(f.path("f100000")).unwrap();
f.footage("f100000/plate.png");
f.comp("Main");
f.s.execute("comp.settings", json!({"duration": 10000.1})).unwrap();
f.s.exporter = Some(Arc::new(LastFrame(f.path("f100000/plate.png"))));
f.s.execute("renderQueue.add", json!({"format": "png", "output": f.text("f[######]/plate.png")})).unwrap();
let error = f.render(json!({})).unwrap_err().to_string();
assert!(error.contains("cannot safely check"), "{error}");
assert_eq!(f.read("f100000/plate.png"), SOURCE);
}
/// A numbered sequence is footage frame by frame: one colliding frame is enough, and nothing of
/// the item is rendered.
#[test]
fn a_sequence_never_replaces_one_of_its_footage_frames() {
let mut f = Fixture::new("sequence");
f.sequence("shot", 0..5);
f.comp("shot");
// The default name steps aside as a whole: shot_1_00000.png …
f.s.execute("renderQueue.add", json!({"format": "png"})).unwrap();
assert_eq!(f.output(0), f.path("shot_1_[#####].png"));
f.render(json!({})).unwrap();
for n in 0..5 {
assert_eq!(f.read(&format!("shot_{n:05}.png")), SOURCE, "frame {n}");
assert_eq!(f.read(&format!("shot_1_{n:05}.png")), RENDERED, "frame {n}");
}
// Named by hand, one frame of five is a footage file.
let mut f = Fixture::new("sequence-one-frame");
f.write("shot_00002.png", SOURCE);
let still = Footage { path: f.text("shot_00002.png"), kind: FootageKind::Still, width: 16, height: 16, has_video: true, ..Default::default() };
Arc::make_mut(&mut f.s.project).add_item("shot_00002.png", Label::None, None, ItemKind::Footage(still));
f.comp("shot");
let pattern = f.text("shot_[#####].png");
f.s.execute("renderQueue.add", json!({"format": "png", "output": pattern})).unwrap();
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
assert!(e.contains("shot_00002.png"), "{e}");
assert_eq!(f.read("shot_00002.png"), SOURCE);
assert!(!f.path("shot_00000.png").exists(), "the frames before it were not rendered either");
assert!(f.exported().is_empty());
}
#[test]
fn ordinary_existing_outputs_keep_the_exporters_replacement_policy() {
let mut f = Fixture::new("earlier-render");
f.comp("Main");
f.s.execute("renderQueue.add", json!({"output": f.text("render.mp4")})).unwrap();
f.write("render.mp4", EARLIER);
f.render(json!({})).unwrap();
assert_eq!(f.read("render.mp4"), RENDERED);
assert_eq!(f.s.project.render_queue[0].status, RenderStatus::Done);
}
#[test]
fn a_render_with_nothing_in_the_way_needs_no_flag() {
let mut f = Fixture::new("free");
f.comp("Main");
f.s.execute("renderQueue.add", json!({})).unwrap();
assert_eq!(f.output(0), f.path("Main.mp4"), "no footage in the way: the comp's own name");
f.render(json!({})).unwrap();
assert_eq!(f.read("Main.mp4"), RENDERED);
}
/// Skip Existing Files leaves the frames that exist alone, so they need no answer.
#[test]
fn a_sequence_that_skips_existing_frames_needs_no_flag() {
let mut f = Fixture::new("skip-existing");
f.comp("Main");
let pattern = f.text("render_[#####].png");
f.s.execute("renderQueue.add", json!({"format": "png", "output": pattern, "skipExisting": true})).unwrap();
for n in 0..3 {
f.write(&format!("render_{n:05}.png"), EARLIER);
}
f.render(json!({})).unwrap();
assert_eq!(f.read("render_00002.png"), EARLIER);
assert_eq!(f.read("render_00003.png"), RENDERED);
// Ordinary files still follow the exporter's replacement policy when skipping is off.
f.s.execute("renderQueue.setRenderSettings", json!({"index": 1, "skipExisting": false})).unwrap();
f.render(json!({})).unwrap();
assert_eq!(f.read("render_00002.png"), RENDERED);
}
/// A link is another name for the file it points to.
#[test]
fn a_link_to_the_footage_is_the_footage() {
let mut f = Fixture::new("link");
f.footage("clip.mov");
f.comp("Main");
let alias = f.path("render.mov");
if link(&f.path("clip.mov"), &alias).is_err() {
eprintln!("this account cannot make symbolic links: skipped");
return;
}
f.s.execute("renderQueue.add", json!({"format": "prores", "output": f.text("render.mov")})).unwrap();
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
assert!(e.contains("render.mov") && e.contains("footage"), "{e}");
assert_eq!(f.read("clip.mov"), SOURCE);
assert!(std::fs::symlink_metadata(&alias).unwrap().file_type().is_symlink(), "the link was not replaced either");
}
/// A hard link to the footage under another name is the same file on disk: replacing it would truncate the footage.
#[test]
fn a_hard_link_to_the_footage_is_the_footage() {
let mut f = Fixture::new("hardlink");
f.footage("clip.mov");
f.comp("Main");
if std::fs::hard_link(f.path("clip.mov"), f.path("render.mov")).is_err() {
eprintln!("this volume cannot make hard links: skipped");
return;
}
f.s.execute("renderQueue.add", json!({"format": "prores", "output": f.text("render.mov")})).unwrap();
// not even with overwrite
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
assert!(e.contains("render.mov") && e.contains("footage"), "{e}");
assert_eq!(f.read("clip.mov"), SOURCE);
assert_eq!(f.read("render.mov"), SOURCE, "the other name still leads to the footage");
assert!(f.exported().is_empty(), "nothing was rendered");
}
/// A copy of the footage has its size and bytes, but it is another file: an earlier render may replace it.
#[test]
fn a_copy_of_the_footage_is_not_the_footage() {
let mut f = Fixture::new("copy");
f.footage("clip.mov");
f.comp("Main");
std::fs::copy(f.path("clip.mov"), f.path("copy.mov")).unwrap();
f.s.execute("renderQueue.add", json!({"format": "prores", "output": f.text("copy.mov")})).unwrap();
f.render(json!({})).unwrap();
assert_eq!(f.read("copy.mov"), RENDERED);
assert_eq!(f.read("clip.mov"), SOURCE, "the footage is as it was");
}
/// Footage imported through a linked folder is the same file as the one in the real folder.
#[test]
fn footage_reached_through_a_linked_folder_is_still_footage() {
let mut f = Fixture::new("linked-folder");
std::fs::create_dir_all(f.path("real")).unwrap();
std::fs::write(f.path("real").join("clip.mov"), SOURCE).unwrap();
if link_dir(&f.path("real"), &f.path("linked")).is_err() {
eprintln!("this account cannot link folders: skipped");
return;
}
let via_link =
Footage { path: f.path("linked").join("clip.mov").to_string_lossy().into_owned(), kind: FootageKind::Video, has_video: true, ..Default::default() };
Arc::make_mut(&mut f.s.project).add_item("clip.mov", Label::None, None, ItemKind::Footage(via_link));
f.comp("Main");
f.s.execute("renderQueue.add", json!({"format": "prores", "output": f.path("real").join("clip.mov").to_string_lossy()})).unwrap();
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
assert!(e.contains("footage"), "{e}");
assert_eq!(std::fs::read(f.path("real").join("clip.mov")).unwrap(), SOURCE);
}
/// Where the volume ignores case, `CLIP.MOV` is `clip.mov`.
#[test]
fn another_spelling_of_the_footage_name_is_the_footage_where_the_volume_ignores_case() {
let mut f = Fixture::new("case");
f.footage("clip.mov");
f.comp("Main");
if !f.path("CLIP.MOV").exists() {
eprintln!("this volume tells the two names apart: skipped");
return;
}
let shouting = f.text("CLIP.MOV");
f.s.execute("renderQueue.add", json!({"format": "prores", "output": shouting})).unwrap();
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
assert!(e.contains("footage"), "{e}");
assert_eq!(f.read("clip.mov"), SOURCE);
}
#[test]
fn save_current_preview_never_replaces_footage() {
let mut f = Fixture::new("preview");
f.footage("clip.mov");
f.comp("Main");
let clip = f.text("clip.mov");
let e = f.s.execute("render.saveCurrentPreview", json!({"path": clip})).unwrap_err().to_string();
assert!(e.contains("clip.mov") && e.contains("footage"), "{e}");
assert_eq!(f.read("clip.mov"), SOURCE);
assert!(f.exported().is_empty());
// Anywhere else it renders.
let elsewhere = f.text("preview.mp4");
f.s.execute("render.saveCurrentPreview", json!({"path": elsewhere})).unwrap();
assert_eq!(f.read("preview.mp4"), RENDERED);
}
#[test]
fn save_current_preview_checks_sequence_frames_as_well_as_the_pattern() {
let mut f = Fixture::new("preview-sequence");
f.sequence("plate", 0..5);
f.comp("Main");
let error = f.s.execute("render.saveCurrentPreview", json!({"path": f.text("plate_[#####].png")})).unwrap_err().to_string();
assert!(error.contains("plate_000") && error.contains("footage"), "{error}");
for frame in 0..5 {
assert_eq!(f.read(&format!("plate_{frame:05}.png")), SOURCE);
}
assert!(f.exported().is_empty());
}
/// A watched folder renders unattended; delete the status file to render a project again.
#[test]
fn a_watched_folder_still_renders_a_project_again_over_its_outputs() {
let mut f = Fixture::new("watch");
f.comp("Main");
let out = f.text("Main.mp4");
f.s.execute("renderQueue.add", json!({"output": out})).unwrap();
f.s.execute("file.save", json!({})).unwrap();
f.write("Main.mp4", EARLIER);
let folder = f.dir.to_string_lossy().into_owned();
let done = crate::commands::watch_folder::poll(&mut f.s, &folder).unwrap();
assert_eq!(done.len(), 1, "{done:?}");
assert_eq!(done[0]["state"], "done", "{:?}", done[0]);
assert_eq!(f.read("Main.mp4"), RENDERED);
}
+287
View File
@@ -0,0 +1,287 @@
//! Scale Composition (`comp.scale`, #2208): the comp and everything in it grow or shrink by one
//! factor, so the frame looks the same at the new size.
use effectcraft_project::{Layer, LayerId};
use effectcraft_time::Tick;
use serde_json::json;
use crate::{EngineError, Session};
fn comp(w: u32, h: u32) -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": w, "height": h, "frameRate": 10, "duration": 2})).unwrap();
s
}
fn solid(s: &mut Session, color: &str, w: u32, h: u32) -> u64 {
s.execute("layer.newSolid", json!({"color": color, "width": w, "height": h})).unwrap()["layer"].as_u64().unwrap()
}
fn layer(s: &Session, id: u64) -> Layer {
s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
}
fn v3(s: &Session, id: u64, path: &str, t: f64) -> [f64; 3] {
layer(s, id).props.prop(path).unwrap().value_at(Tick::from_seconds_f64(t)).as_vec3()
}
/// Opaque pixels of `color_channel` (0 = red, 1 = green) at `t`: (x0, y0, x1, y1, count).
fn bounds(s: &Session, t: f64, channel: usize) -> (i64, i64, i64, i64, u32) {
let img = s.render(s.active_comp_id().unwrap(), Tick::from_seconds_f64(t), Default::default());
let (mut x0, mut y0, mut x1, mut y1, mut n) = (i64::MAX, i64::MAX, i64::MIN, i64::MIN, 0);
for y in 0..img.height as i64 {
for x in 0..img.width as i64 {
let p = img.get(x, y);
if p[3] > 0.5 && p[channel] > 0.5 {
n += 1;
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
}
}
}
(x0, y0, x1, y1, n)
}
/// Twice as large on every side, from a pixel-exact frame.
fn doubled(b: (i64, i64, i64, i64, u32)) -> (i64, i64, i64, i64, u32) {
(b.0 * 2, b.1 * 2, b.2 * 2 + 1, b.3 * 2 + 1, b.4 * 4)
}
#[test]
fn scale_comp_doubles_the_frame_with_keys_and_parented_layers() {
let mut s = comp(200, 100);
// A red solid moving right (two Position keys), and a green solid parented to a null.
let red = solid(&mut s, "#ff0000", 20, 10);
s.execute("prop.addKey", json!({"layer": red, "path": "transform/position", "time": 0, "value": [30, 20]})).unwrap();
s.execute("prop.addKey", json!({"layer": red, "path": "transform/position", "time": 1, "value": [70, 20]})).unwrap();
let green = solid(&mut s, "#00ff00", 10, 10);
let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": rig, "path": "transform/position", "value": [150, 60]})).unwrap();
s.execute("layer.setParent", json!({"layers": [green], "parent": rig})).unwrap();
s.execute("prop.set", json!({"layer": green, "path": "transform/position", "value": [10, 15]})).unwrap();
let before = s.project.clone();
let (red0, red1, green0) = (bounds(&s, 0.0, 0), bounds(&s, 1.0, 0), bounds(&s, 0.0, 1));
assert_eq!(red0, (20, 15, 39, 24, 200));
let r = s.execute("comp.scale", json!({"factor": 2})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(400), Some(200)));
let c = s.active_comp().unwrap();
assert_eq!((c.width, c.height), (400, 200));
// Every key moves, not only the value at the current time.
assert_eq!(bounds(&s, 0.0, 0), doubled(red0));
assert_eq!(bounds(&s, 1.0, 0), doubled(red1));
assert_eq!(v3(&s, red, "transform/scale", 1.0), [200.0; 3]);
// The child keeps its own transform: its scaled parent carries it.
assert_eq!(bounds(&s, 0.0, 1), doubled(green0));
assert_eq!(v3(&s, green, "transform/position", 0.0)[..2], [10.0, 15.0]);
assert_eq!(v3(&s, green, "transform/scale", 0.0), [100.0; 3]);
let after = s.project.clone();
// One undo step, and one redo step.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project == before, "undo restores the comp and every layer");
s.execute("edit.redo", json!({})).unwrap();
assert!(s.project == after, "redo scales the comp and every layer again");
}
#[test]
fn scale_comp_to_a_width_keeps_the_aspect_and_scales_cameras_3d_and_guides() {
let mut s = comp(640, 360);
let card = solid(&mut s, "#ff0000", 100, 100);
s.execute("layer.setSwitch", json!({"layers": [card], "switch": "threeD", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": card, "path": "transform/position", "value": [100, 50, 200]})).unwrap();
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("view.addGuide", json!({"orientation": "vertical", "position": 160})).unwrap();
s.execute("view.addGuide", json!({"orientation": "horizontal", "position": 25, "percent": true})).unwrap();
let zoom = |s: &Session| layer(s, cam).props.prop("cameraOptions/zoom").unwrap().value.as_f64();
let (zoom0, cam0, poi0) = (zoom(&s), v3(&s, cam, "transform/position", 0.0), v3(&s, cam, "transform/poi", 0.0));
let seen0 = bounds(&s, 0.0, 0);
s.execute("view.setRegionOfInterest", json!({"rect": [10, 20, 70, 70]})).unwrap();
let r = s.execute("comp.scale", json!({"width": 960})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64(), r["factor"].as_f64()), (Some(960), Some(540), Some(1.5)));
assert_eq!(v3(&s, card, "transform/position", 0.0), [150.0, 75.0, 300.0]);
assert_eq!(v3(&s, card, "transform/scale", 0.0), [150.0; 3]);
assert!((zoom(&s) - zoom0 * 1.5).abs() < 1e-9, "the camera sees the same frame");
assert_eq!(v3(&s, cam, "transform/position", 0.0), cam0.map(|v| v * 1.5));
assert_eq!(v3(&s, cam, "transform/poi", 0.0), poi0.map(|v| v * 1.5));
// Through the camera the card covers the same part of the frame.
let seen = bounds(&s, 0.0, 0);
let near = |a: i64, b: i64| (a as f64 - b as f64 * 1.5).abs() <= 2.0;
assert!(near(seen.0, seen0.0) && near(seen.1, seen0.1) && near(seen.2, seen0.2) && near(seen.3, seen0.3), "{seen0:?} → {seen:?}");
let c = s.active_comp().unwrap();
assert_eq!(c.guides[0].px(c.width, c.height), 240.0);
assert_eq!(c.guides[1].position, 25.0, "a percentage guide keeps its percent");
assert_eq!(s.state.region_of_interest, Some([15.0, 30.0, 105.0, 105.0]), "the region of interest covers the same part");
// A height alone works the same way.
let r = s.execute("comp.scale", json!({"height": 270})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(480), Some(270)));
}
#[test]
fn scale_comp_scales_depth_under_a_2d_parent_and_children_of_a_camera() {
let mut s = comp(200, 100);
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
// A 3D card under a 2D null: the null's Scale carries X and Y but not Z.
let card = solid(&mut s, "#ff0000", 20, 10);
let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": rig, "path": "transform/position", "value": [100, 50]})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [card], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setParent", json!({"layers": [card], "parent": rig})).unwrap();
s.execute("prop.set", json!({"layer": card, "path": "transform/position", "value": [-40, 0, 500]})).unwrap();
// A 3D card parented to the camera, in front of it.
let held = solid(&mut s, "#00ff00", 10, 10);
s.execute("layer.setSwitch", json!({"layers": [held], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setParent", json!({"layers": [held], "parent": cam})).unwrap();
s.execute("prop.set", json!({"layer": held, "path": "transform/position", "value": [30, 0, 500]})).unwrap();
let (card0, held0) = (bounds(&s, 0.0, 0), bounds(&s, 0.0, 1));
let (held_pos0, held_scale0) = (v3(&s, held, "transform/position", 0.0), v3(&s, held, "transform/scale", 0.0));
assert!(card0.4 > 0 && held0.4 > 0, "both cards are in view: {card0:?} {held0:?}");
s.execute("comp.scale", json!({"factor": 2})).unwrap();
let near = |a: (i64, i64, i64, i64, u32), b: (i64, i64, i64, i64, u32)| {
[(a.0, b.0 * 2), (a.1, b.1 * 2), (a.2, b.2 * 2 + 1), (a.3, b.3 * 2 + 1)].iter().all(|&(x, y)| (x - y).abs() <= 2)
};
let (card1, held1) = (bounds(&s, 0.0, 0), bounds(&s, 0.0, 1));
assert!(near(card1, card0), "the card under the 2D null doubles: {card0:?} → {card1:?}");
assert_eq!(v3(&s, card, "transform/position", 0.0), [-40.0, 0.0, 1000.0]);
assert!(near(held1, held0), "the card held by the camera doubles: {held0:?} → {held1:?}");
assert_eq!(v3(&s, held, "transform/position", 0.0), held_pos0.map(|v| v * 2.0));
assert_eq!(v3(&s, held, "transform/scale", 0.0), held_scale0.map(|v| v * 2.0));
}
#[test]
fn scale_comp_keeps_the_ease_of_depth_moves_under_a_2d_parent() {
let mut s = comp(200, 100);
let card = solid(&mut s, "#ff0000", 20, 10);
let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [card], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setParent", json!({"layers": [card], "parent": rig})).unwrap();
s.execute("prop.addKey", json!({"layer": card, "path": "transform/position", "time": 0, "value": [0, 0, 0]})).unwrap();
s.execute("prop.addKey", json!({"layer": card, "path": "transform/position", "time": 1, "value": [0, 0, 100]})).unwrap();
s.execute("keys.setEase", json!({"layer": card, "path": "transform/position", "time": 0, "side": "out", "speed": 100, "influence": 50})).unwrap();
s.execute("prop.addKey", json!({"layer": card, "path": "transform/scale", "time": 0, "value": [100, 100, 100]})).unwrap();
s.execute("prop.addKey", json!({"layer": card, "path": "transform/scale", "time": 1, "value": [100, 100, 300]})).unwrap();
s.execute("keys.setEase", json!({"layer": card, "path": "transform/scale", "time": 0, "side": "out", "speed": 300, "influence": 50})).unwrap();
let (pos0, scale0) = (v3(&s, card, "transform/position", 0.25), v3(&s, card, "transform/scale", 0.25));
s.execute("comp.scale", json!({"factor": 2})).unwrap();
// Twice the distance in the same time: the same ease, every frame twice as deep.
let (pos, scale) = (v3(&s, card, "transform/position", 0.25), v3(&s, card, "transform/scale", 0.25));
assert!((pos[2] - pos0[2] * 2.0).abs() < 1e-6, "{pos0:?} → {pos:?}");
assert!((scale[2] - scale0[2] * 2.0).abs() < 1e-6, "{scale0:?} → {scale:?}");
assert_eq!(scale[..2], scale0[..2]);
}
#[test]
fn scale_comp_gives_z_its_own_speed_when_a_scale_key_has_one_ease() {
let mut s = comp(200, 100);
let card = solid(&mut s, "#ff0000", 20, 10);
let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [card], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setParent", json!({"layers": [card], "parent": rig})).unwrap();
s.execute("prop.addKey", json!({"layer": card, "path": "transform/scale", "time": 0, "value": [100, 100, 100]})).unwrap();
s.execute("prop.addKey", json!({"layer": card, "path": "transform/scale", "time": 1, "value": [100, 100, 300]})).unwrap();
s.execute("keys.setEase", json!({"layer": card, "path": "transform/scale", "time": 0, "side": "out", "speed": 300, "influence": 50})).unwrap();
// One ease for every dimension, as a project file may store it.
let cid = s.active_comp_id().unwrap();
let key =
&mut std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(card)).unwrap().props.prop_mut("transform/scale").unwrap().keys
[0];
key.out_ease.truncate(1);
let scale0 = v3(&s, card, "transform/scale", 0.25);
s.execute("comp.scale", json!({"factor": 2})).unwrap();
let scale = v3(&s, card, "transform/scale", 0.25);
assert!((scale[2] - scale0[2] * 2.0).abs() < 1e-6, "{scale0:?} → {scale:?}");
assert_eq!(scale[..2], scale0[..2], "X and Y keep the shared ease");
}
#[test]
fn scale_comp_scales_depth_once_through_a_3d_grandparent() {
let mut s = comp(200, 100);
s.execute("layer.newCamera", json!({})).unwrap();
// card (3D) → A (2D null) → B (3D null): B's Scale already carries the depth.
let card = solid(&mut s, "#ff0000", 20, 10);
let a = s.execute("layer.newNull", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newNull", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [card, b], "switch": "threeD", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [100, 50, 0]})).unwrap();
s.execute("layer.setParent", json!({"layers": [a], "parent": b})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [0, 0]})).unwrap();
s.execute("layer.setParent", json!({"layers": [card], "parent": a})).unwrap();
s.execute("prop.set", json!({"layer": card, "path": "transform/position", "value": [-40, 0, 500]})).unwrap();
let card0 = bounds(&s, 0.0, 0);
assert!(card0.4 > 0, "the card is in view");
s.execute("comp.scale", json!({"factor": 2})).unwrap();
assert_eq!(v3(&s, card, "transform/position", 0.0), [-40.0, 0.0, 500.0]);
let card1 = bounds(&s, 0.0, 0);
let near =
[(card1.0, card0.0 * 2), (card1.1, card0.1 * 2), (card1.2, card0.2 * 2 + 1), (card1.3, card0.3 * 2 + 1)].iter().all(|&(x, y)| (x - y).abs() <= 2);
assert!(near, "{card0:?} → {card1:?}");
}
#[test]
fn scale_comp_scales_once_through_a_camera_under_a_scaled_null() {
let mut s = comp(200, 100);
// solid → camera → B (3D null): B's Scale reaches the solid through the camera.
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newNull", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setParent", json!({"layers": [cam], "parent": b})).unwrap();
let held = solid(&mut s, "#00ff00", 10, 10);
s.execute("layer.setSwitch", json!({"layers": [held], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setParent", json!({"layers": [held], "parent": cam})).unwrap();
s.execute("prop.set", json!({"layer": held, "path": "transform/position", "value": [30, 0, 500]})).unwrap();
let held0 = bounds(&s, 0.0, 1);
assert!(held0.4 > 0, "the solid is in view: {held0:?}");
s.execute("comp.scale", json!({"factor": 2})).unwrap();
assert_eq!(v3(&s, held, "transform/position", 0.0), [30.0, 0.0, 500.0]);
assert_eq!(v3(&s, held, "transform/scale", 0.0), [100.0; 3]);
let held1 = bounds(&s, 0.0, 1);
let near =
[(held1.0, held0.0 * 2), (held1.1, held0.1 * 2), (held1.2, held0.2 * 2 + 1), (held1.3, held0.3 * 2 + 1)].iter().all(|&(x, y)| (x - y).abs() <= 2);
assert!(near, "{held0:?} → {held1:?}");
}
#[test]
fn scale_comp_scales_the_point_of_interest_depth_of_a_camera_under_a_2d_null() {
let mut s = comp(200, 100);
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setParent", json!({"layers": [cam], "parent": rig})).unwrap();
s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [10, 20, -1000]})).unwrap();
s.execute("prop.set", json!({"layer": cam, "path": "transform/poi", "value": [10, 20, 200]})).unwrap();
s.execute("comp.scale", json!({"factor": 2})).unwrap();
// The 2D null carries X and Y; the camera's own Z doubles at both ends, so it looks the same way.
assert_eq!(v3(&s, cam, "transform/position", 0.0), [10.0, 20.0, -2000.0]);
assert_eq!(v3(&s, cam, "transform/poi", 0.0), [10.0, 20.0, 400.0]);
}
#[test]
fn scale_comp_refuses_a_bad_factor_and_changes_nothing() {
let mut s = comp(640, 360);
solid(&mut s, "#ff0000", 100, 100);
let before = s.project.clone();
let cases = [
(json!({}), "pass `factor`"),
(json!({"factor": 0}), "4 to 30,000 px"),
(json!({"factor": -2}), "4 to 30,000 px"),
(json!({"width": 0}), "4 to 30,000 px"),
(json!({"factor": 100}), "4 to 30,000 px"),
(json!({"factor": 0.001}), "4 to 30,000 px"),
(json!({"factor": 2, "width": 960}), "only one of"),
(json!({"width": 960, "height": 360}), "only one of"),
];
for (p, why) in cases {
match s.execute("comp.scale", p.clone()) {
Err(EngineError::BadParams { cmd, msg }) => assert!(cmd == "comp.scale" && msg.contains(why), "{p}: {msg}"),
other => panic!("{p} is refused as bad parameters, got {other:?}"),
}
assert!(s.project == before, "{p} leaves the project alone");
}
s.execute("comp.scale", json!({"factor": 1})).unwrap();
}