mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-01 05:58:19 -06:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
@@ -39,3 +39,7 @@ rayon = { workspace = true }
|
||||
image = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
exr = { workspace = true }
|
||||
|
||||
# Native only: the volume's own identity for a file, to tell that a hard link is the footage it leads to.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
same-file = { workspace = true }
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user