mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-01 14:08:38 -06:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
@@ -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};
|
||||
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, _| {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user