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

This commit is contained in:
bflatastic
2026-10-30 09:54:50 +07:00
80 changed files with 2467 additions and 213 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};
+5 -2
View File
@@ -254,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;
+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 {
+1
View File
@@ -36,6 +36,7 @@ mod panels;
mod project_items;
mod proxy;
mod quick_offset;
mod render_overwrite;
mod replace_source;
mod rig3d;
pub(crate) mod roto;
+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);
}