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

# Conflicts:
#	apps/effectcraft-cli/tests/it/cli.rs
#	crates/engine/src/output_guard.rs
#	docs/agents.md
This commit is contained in:
bflatastic committed 2026-10-30 16:38:01 +07:00
commit 2a72f03872
15 files changed
+1272 -30

No files matched your search

+32
View File
@@ -448,6 +448,38 @@ fn render_frame_never_writes_over_footage_or_the_project() {
let _ = std::fs::remove_dir_all(&root);
}
/// Two queue items on one file are refused by the render before anything is written, with the
/// file as it was.
#[test]
fn render_refuses_two_items_on_one_file() {
let root = tmp("render-clash");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let out = root.join("clash.mp4");
std::fs::write(&out, b"EARLIER").unwrap();
let project = root.join("p.ecproj");
let p = project.to_str().unwrap();
let add = json!({"output": out.to_str().unwrap()}).to_string();
ok_json(&[
"run",
"comp.new",
r#"{"name":"T","width":64,"height":36,"duration":1}"#,
"renderQueue.add",
&add,
"renderQueue.add",
&add,
"--empty",
"--save-as",
p,
]);
let (code, v) = run_json(&["render", "--queue", "--project", p]);
assert_eq!(code, 1, "{v}");
let error = v["error"].as_str().unwrap();
assert!(error.contains("#1") && error.contains("#2") && error.contains("clash.mp4"), "{v}");
assert_eq!(std::fs::read(&out).unwrap(), b"EARLIER");
let _ = std::fs::remove_dir_all(&root);
}
/// A real stdio client: each edit's reply is read before disconnecting or killing the server.
struct McpChild {
child: std::process::Child,
+15
View File
@@ -25,6 +25,7 @@ fn has_clip(s: &Session) -> std::result::Result<(), String> {
&& s.state.key_clipboard.is_empty()
&& s.state.effect_clipboard.is_empty()
&& s.state.contents_clipboard.is_empty()
&& s.state.mask_clipboard.is_empty()
&& s.state.link_clipboard.is_none()
&& s.state.value_clipboard.is_empty()
{
@@ -285,6 +286,7 @@ fn clear_clipboards(s: &mut Session) {
s.state.key_clipboard.clear();
s.state.effect_clipboard.clear();
s.state.contents_clipboard.clear();
s.state.mask_clipboard.clear();
s.state.link_clipboard = None;
s.state.value_clipboard.clear();
s.state.keyframe_text = None;
@@ -311,6 +313,16 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() {
return s.execute("effect.copy", json!({}));
}
// Masks selected → copy them (not their layer), for Paste onto other layers (#2091), unless
// a layer is named explicitly.
if p.get("layers").is_none() && p.get("layer").is_none_or(Value::is_null) {
let masks = super::prop_groups::copy_masks(s);
if !masks.is_empty() {
clear_clipboards(s);
s.state.mask_clipboard = masks;
return Ok(json!({"masks": s.state.mask_clipboard.len()}));
}
}
// Shape items selected in a shape layer's Contents → copy them (not their layer).
if p.get("layers").is_none() {
let items = super::prop_groups::copy_contents(s);
@@ -597,6 +609,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
if !s.state.contents_clipboard.is_empty() {
return super::prop_groups::paste_contents(s, p);
}
if !s.state.mask_clipboard.is_empty() {
return super::prop_groups::paste_masks(s, p);
}
let cid = super::comp_id(s, p)?;
let clip = s.state.clipboard.clone();
let new = s.edit("Paste", None, |proj, st| {
+1
View File
@@ -851,6 +851,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
s.state.key_clipboard.clear();
s.state.keyframe_text = None;
s.state.contents_clipboard.clear();
s.state.mask_clipboard.clear();
s.state.link_clipboard = None;
s.state.clip_is_keys = false;
let n = groups.len();
+2 -3
View File
@@ -794,6 +794,8 @@ fn select_prop(s: &mut Session, p: &Value) -> Result<Value> {
if !add {
s.state.selected_props.clear();
s.state.selected_vertices.clear();
// Groups (masks, effects) have no keys of their own: don't leave the old selection's behind.
s.state.selected_keys.clear();
}
if !s.state.selected_layers.contains(&lid) {
s.state.selected_layers = vec![lid];
@@ -811,9 +813,6 @@ fn select_prop(s: &mut Session, p: &Value) -> Result<Value> {
&& let Some(pr) = s.active_comp().and_then(|c| c.layer(lid)).and_then(|l| l.props.find(uid))
{
let keys: Vec<KeyRef> = pr.keys.iter().map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect();
if !add {
s.state.selected_keys.clear();
}
s.state.selected_keys.extend(keys);
}
Ok(Value::Null)
+55
View File
@@ -135,6 +135,61 @@ pub(crate) fn copy_contents(s: &Session) -> Vec<PropGroup> {
selected_contents(s).into_iter().filter_map(|(lid, uid)| c.layer(lid)?.props.find_group(uid).cloned()).collect()
}
/// Masks selected on the active comp's layers, top to bottom (#2091).
pub(crate) fn selected_masks(s: &Session) -> Vec<(LayerId, Uid)> {
let Some(c) = s.active_comp().filter(|_| !s.state.selected_props.is_empty()) else { return vec![] };
c.layers
.iter()
.flat_map(|l| {
let masks = l.masks().into_iter().flat_map(|m| m.groups());
masks.filter(|m| s.state.selected_props.contains(&(l.id, m.uid))).map(|m| (l.id, m.uid))
})
.collect()
}
/// Copies of the selected masks (Edit ▸ Copy), top to bottom.
pub(crate) fn copy_masks(s: &Session) -> Vec<PropGroup> {
let Some(c) = s.active_comp() else { return vec![] };
selected_masks(s).into_iter().filter_map(|(lid, uid)| c.layer(lid)?.props.find_group(uid).cloned()).collect()
}
/// Edit ▸ Paste with masks on the clipboard: each is added, with a unique name, below the masks of
/// every selected unlocked layer that can have masks, and the copies are selected (#2091).
pub(crate) fn paste_masks(s: &mut Session, p: &Value) -> Result<Value> {
let c = "edit.paste";
let clip = s.state.mask_clipboard.clone();
let (cid, ids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let targets: Vec<LayerId> = ids.into_iter().filter(|l| comp.layer(*l).is_some_and(|l| l.masks().is_some() && !l.switches.locked)).collect();
if targets.is_empty() {
return Err(bad(c, "select an unlocked layer that can have masks to paste masks into"));
}
let label = match clip.as_slice() {
[g] => format!("Paste {}", g.name),
_ => "Paste".to_string(),
};
let pasted = s.edit(&label, None, |proj, st| {
let mut next = proj.next_id;
let mut out = vec![];
for lid in &targets {
let masks = layer_mut(proj, cid, *lid)?.props.sub_mut("masks").ok_or_else(|| bad(c, "the layer can't have masks"))?;
for g in &clip {
let mut g = g.clone();
g.reassign_uids(&mut next);
g.name = super::effect::unique_name(masks, &g.name);
out.push((*lid, g.uid));
masks.children.push(g.into());
}
}
proj.next_id = next + 1;
st.selected_props = out.clone();
// The source mask's points (selected with it) must not outlive its selection.
st.selected_vertices.clear();
Ok(out)
})?;
Ok(json!({"masks": pasted.iter().map(|(_, u)| *u).collect::<Vec<_>>()}))
}
/// Edit ▸ Cut's removal of the selected shape items (once copied).
pub(crate) fn cut_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
+3
View File
@@ -98,6 +98,9 @@ pub struct EditorState {
/// a shape layer's Contents); Paste adds them to the selected shape layers.
#[serde(skip)]
pub contents_clipboard: Vec<effectcraft_project::PropGroup>,
/// Copied masks (Edit ▸ Copy with masks selected); Paste adds them to the selected layers.
#[serde(skip)]
pub mask_clipboard: Vec<effectcraft_project::PropGroup>,
/// Selected mask vertices (viewer Selection tool / pen).
#[serde(default)]
pub selected_vertices: Vec<VertexRef>,
+537 -26
View File
@@ -10,6 +10,16 @@
//! Collect Files, Export Timeline…) never replace one of those files either, with no flag:
//! they write through [`Session::write_output`], the one place that checks. Save and Save As
//! write the project's own file and do not.
//!
//! Nor does a render write one file twice. When two outputs of one job name the same file (two
//! items with one Output To, a duplicated item, output modules or segments that meet), the one
//! rendered later replaces what the earlier one just wrote, and nothing is on disk to say so when
//! the job starts. [`Session::duplicate_outputs`] lists them and the whole render is refused before
//! anything is written: no answer makes a render replacing its own output right. A numbered sequence is compared by the frames
//! it writes: two items of one pattern clash only where their frames do, and an item that skips
//! existing frames leaves the earlier one's alone. A new item's default output name steps aside
//! the same way it does for footage ([`Session::avoid_sources`]).
//!
use std::collections::{HashMap, HashSet};
#[cfg(not(target_arch = "wasm32"))]
@@ -17,7 +27,7 @@ use std::path::Component;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use effectcraft_project::render_queue::{RenderQueueItem, log_path, numbered_template, sequence_frame, sequence_path};
use effectcraft_project::render_queue::{RenderQueueItem, log_path, numbered_template, sequence_frame, sequence_parts, sequence_path};
use effectcraft_project::{Footage, ItemKind};
use serde_json::{Value, json};
@@ -43,6 +53,10 @@ pub(crate) enum Use {
Export,
}
/// Stands for the frame number in the name a numbered sequence is compared by: a character no
/// file is named with.
const FRAME: char = '\u{e000}';
/// What reads a file the project depends on.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Source {
@@ -181,6 +195,24 @@ fn identity_hash(handle: &same_file::Handle) -> u64 {
hash.finish()
}
/// What an existing regular file is known by on its volume (links and hard links to one file give one),
/// `None` when it does not exist, is not a regular file, or the volume has no such identity.
fn identity(path: &Path) -> Option<String> {
#[cfg(not(target_arch = "wasm32"))]
{
// Opening a FIFO can wait for a writer: only regular files are looked at.
if !std::fs::metadata(path).ok()?.is_file() {
return None;
}
same_file::Handle::from_path(path).ok().map(|handle| identity_hash(&handle).to_string())
}
#[cfg(target_arch = "wasm32")]
{
let _ = path;
None
}
}
/// `path` made absolute, with `.` and `..` resolved by name alone (no file is looked at).
#[cfg(not(target_arch = "wasm32"))]
fn lexical(path: &Path) -> PathBuf {
@@ -209,9 +241,14 @@ fn key(path: &Path) -> String {
fold(&parts.join("/"))
}
/// Browser file-table keys distinguish case and literal dot segments, but not a leading slash.
#[cfg(target_arch = "wasm32")]
fn key(path: &Path) -> String {
browser_key(path)
}
/// Match the browser file table: one optional leading slash, with literal case and dot segments.
#[cfg(any(target_arch = "wasm32", test))]
fn browser_key(path: &Path) -> String {
let path = path.to_string_lossy();
if path.starts_with('/') { path.into_owned() } else { format!("/{path}") }
}
@@ -270,6 +307,142 @@ fn frames_on_disk(path: &str, first: i64, count: u64) -> Result<Vec<PathBuf>, St
.collect())
}
/// What an output writes, as far as a clash with another output goes.
#[derive(Clone, Debug, PartialEq)]
enum Target {
/// One file: known by its name and, when it exists, by what it is on its volume.
File { key: String, identity: Option<String> },
/// The numbered files `first..end` of a sequence: `key` is its pattern, with the width the
/// frame numbers are padded to.
Frames { key: String, width: usize, first: i64, end: i64 },
}
impl Target {
/// The file at `path`. `by_identity` also looks up what it is on its volume (a link to it
/// is it); that asks the file system, so it is left out where only names matter.
fn file(path: &Path, by_identity: bool) -> Target {
let identity = if by_identity { identity(path) } else { None };
Target::File { key: key(path), identity }
}
/// The `count` files of the sequence `path` (its frame-number pattern) from frame `first`.
fn frames(path: &str, first: i64, count: u64) -> Target {
let (before, width, after) = sequence_parts(path);
let key = key(Path::new(&format!("{before}{FRAME}{after}")));
Target::Frames { key, width, first, end: first.saturating_add(i64::try_from(count).unwrap_or(i64::MAX)) }
}
}
/// One output of a job.
#[derive(Clone, Debug)]
struct Write {
/// The queue item (`0`: one that is not in the queue yet).
item: u64,
/// The output path (a sequence's has its frame-number pattern).
path: String,
target: Target,
/// Skip Existing Files: a frame that is there already is left alone.
skips: bool,
}
/// What an output rendered later writes over.
struct Clash<'a> {
/// The earlier output that writes it.
earlier: &'a Write,
/// How many files, and the first of them (its frame number, for a sequence).
files: u64,
first: i64,
}
/// The outputs of a job in the order they render, to find the one that writes what an earlier
/// one does.
#[derive(Default)]
struct Writes {
all: Vec<Write>,
path_bytes: usize,
/// The first output to write a file, by name.
files: HashMap<String, usize>,
/// ... and by what the file is on its volume.
identities: HashMap<String, usize>,
}
/// The common range whose formatted numbers agree. Once the number (including a minus sign)
/// fills the larger padding width, different widths produce the same file name.
fn common_frames(first: i64, end: i64, from: i64, to: i64, width: usize, other_width: usize) -> Option<(u64, i64)> {
let (start, end) = (first.max(from), end.min(to));
let count = |a: i64, b: i64| u64::try_from(b.saturating_sub(a)).unwrap_or(0);
if width == other_width {
return (end > start).then(|| (count(start, end), start));
}
let width = width.max(other_width);
let power = |digits: usize| u32::try_from(digits).ok().and_then(|digits| 10i64.checked_pow(digits));
let negative = power(width.saturating_sub(2)).map(|n| end.min((-n).saturating_add(1))).filter(|to| *to > start);
let positive = power(width.saturating_sub(1)).map(|n| start.max(n)).filter(|from| *from < end);
let files = negative.map_or(0, |to| count(start, to)).saturating_add(positive.map_or(0, |from| count(from, end)));
(files > 0).then(|| (files, if negative.is_some() { start } else { positive.unwrap_or(start) }))
}
impl Writes {
fn is_empty(&self) -> bool {
self.all.is_empty()
}
/// The earliest output `later`, rendered after all of these, writes over.
fn clash(&self, later: &Write) -> Option<Clash<'_>> {
match &later.target {
Target::File { key, identity } => {
if let Some(at) = self.files.get(key).or_else(|| identity.as_ref().and_then(|id| self.identities.get(id))) {
return Some(Clash { earlier: self.all.get(*at)?, files: 1, first: 0 });
}
self.all.iter().find_map(|earlier| {
let Target::Frames { first, end, .. } = earlier.target else { return None };
let frame = sequence_frame(&crate::output_guard::key(Path::new(&earlier.path)), key)?;
(first..end).contains(&frame).then_some(Clash { earlier, files: 1, first: frame })
})
}
// A frame that is there already is left alone, so a later output that skips existing
// frames replaces none of an earlier one's.
Target::Frames { .. } if later.skips => None,
Target::Frames { key, width, first, end } => self.all.iter().find_map(|earlier| {
let (files, start) = match &earlier.target {
Target::Frames { key: other, width: other_width, first: from, end: to } if key == other => {
common_frames(*first, *end, *from, *to, *width, *other_width)?
}
Target::File { key, .. } => {
let frame = sequence_frame(&crate::output_guard::key(Path::new(&later.path)), key)?;
if !(*first..*end).contains(&frame) {
return None;
}
(1, frame)
}
_ => return None,
};
Some(Clash { earlier, files, first: start })
}),
}
}
fn push(&mut self, write: Write) -> Result<(), String> {
let path_bytes = self.path_bytes.saturating_add(write.path.len());
if self.all.len() >= 65_536 || path_bytes > 16 * 1024 * 1024 {
return Err("render output plan exceeds 65536 paths or 16 MiB of path names; split the queue".into());
}
self.path_bytes = path_bytes;
let at = self.all.len();
match &write.target {
Target::File { key, identity } => {
self.files.entry(key.clone()).or_insert(at);
if let Some(identity) = identity {
self.identities.entry(identity.clone()).or_insert(at);
}
}
Target::Frames { .. } => {}
}
self.all.push(write);
Ok(())
}
}
/// A queue item refused before any of its output modules are dispatched.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Refused {
@@ -277,6 +450,50 @@ pub(crate) struct Refused {
pub reason: String,
}
/// An output that would write a file an earlier output of the same job writes.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct DuplicateOutput {
/// The queue item that would write over it (the later one), its place in the queue (1-based)
/// and its composition.
pub item: u64,
pub index: usize,
pub comp: String,
/// The file: a sequence's pattern, or the frame when it is one.
pub path: String,
/// How many files the two outputs have in common.
pub files: u64,
pub sequence: bool,
/// The queue item whose output is written first, and its place and composition.
pub with: u64,
pub with_index: usize,
pub with_comp: String,
}
impl DuplicateOutput {
/// Why the render is refused, naming the two items.
pub(crate) fn reason(&self) -> String {
let label = |index: usize, comp: &str| format!("#{index} “{comp}”");
let what = if self.sequence && self.files != 1 { format!("{} frames of {}", self.files, self.path) } else { self.path.clone() };
let rule = "and a render never writes one file twice; choose another Output To for one of them";
if self.item == self.with {
format!("{} would write {what} twice (two of its output modules), {rule}", label(self.index, &self.comp))
} else {
format!("{} and {} would both write {what}, {rule}", label(self.with_index, &self.with_comp), label(self.index, &self.comp))
}
}
}
/// Why a render is refused when outputs of the job write one file twice: it would replace its own output.
pub(crate) fn duplicate_refusal(duplicates: &[DuplicateOutput]) -> Option<String> {
let first = duplicates.first()?;
let more = match duplicates.len().saturating_sub(1) {
0 => String::new(),
1 => "; 1 more output would write a file twice too".into(),
n => format!("; {n} more outputs would write a file twice too"),
};
Some(format!("{}. Nothing was rendered{more}", first.reason()))
}
impl Session {
/// The files the project reads.
pub(crate) fn protected_files(&self) -> Protected {
@@ -335,8 +552,11 @@ impl Session {
if seen.contains(&item.id) {
continue;
}
let reason = match self.check_output(&protected, item, path) {
Ok(reason) => reason,
let reason = match self.write_paths(item, path) {
Ok(paths) => paths.iter().find_map(|path| match self.check_output(&protected, item, path) {
Ok(reason) => reason,
Err(reason) => Some(reason),
}),
Err(reason) => Some(reason),
};
if let Some(reason) = reason {
@@ -347,6 +567,86 @@ impl Session {
refused
}
/// What the job writes more than once. An item in `refused` writes nothing, so it takes no part.
pub(crate) fn duplicate_outputs(&self, items: &[Vec<(RenderQueueItem, String)>], refused: &[Refused]) -> Result<Vec<DuplicateOutput>, String> {
let skip: HashSet<u64> = refused.iter().map(|r| r.item).collect();
let mut writes = Writes::default();
let mut duplicates = Vec::new();
for (m, path) in items.iter().flatten().filter(|(m, _)| !skip.contains(&m.id)) {
let paths = self.write_paths(m, path)?;
// Alternative destinations of one output cannot collide with themselves.
for path in &paths {
let write = self.write_of(m, path, true);
if let Some(clash) = writes.clash(&write) {
duplicates.push(self.duplicate_of(&write, &clash));
break;
}
}
for path in paths {
writes.push(self.write_of(m, &path, true))?;
}
}
Ok(duplicates)
}
/// Every possible destination, since quota decisions can change during rendering.
fn write_paths<'a>(&self, item: &RenderQueueItem, path: &'a str) -> Result<Vec<std::borrow::Cow<'a, str>>, String> {
let overflow = &self.project.render_prefs.overflow_folders;
const PATH_BYTES: usize = 1024 * 1024;
if path.len() > PATH_BYTES {
return Err("render output destinations exceed 1 MiB of path names".into());
}
let mut paths = vec![std::borrow::Cow::Borrowed(path)];
let mut bytes = path.len();
if item.settings.storage_overflow && self.storage_quota.is_some() {
if overflow.len() > 256 {
return Err("render output planning supports at most 256 storage overflow folders".into());
}
if let Some(name) = Path::new(path).file_name() {
for folder in overflow {
if bytes.saturating_add(folder.len()).saturating_add(name.len()).saturating_add(1) > PATH_BYTES {
return Err("render output destinations exceed 1 MiB of path names".into());
}
let destination = Path::new(folder).join(name).to_string_lossy().into_owned();
bytes = bytes.saturating_add(destination.len());
if bytes > PATH_BYTES {
return Err("render output destinations exceed 1 MiB of path names".into());
}
paths.push(std::borrow::Cow::Owned(destination));
}
}
}
Ok(paths)
}
/// What `path`, an output of `m`, writes. `by_identity`: see [`Target::file`].
fn write_of(&self, m: &RenderQueueItem, path: &str, by_identity: bool) -> Write {
let target = if m.output.format.is_sequence() {
let (first, count) = self.project.comp(m.comp).map_or((0, 0), |c| (m.settings.first_frame(c), m.settings.frame_count(c)));
Target::frames(path, first, count)
} else {
Target::file(Path::new(path), by_identity)
};
let skips = m.settings.skip_existing && self.exporter.as_ref().is_some_and(|exporter| exporter.supports_skip_existing(&m.settings));
Write { item: m.id, path: path.to_string(), target, skips }
}
/// The report of `later` writing over `clash`.
fn duplicate_of(&self, later: &Write, clash: &Clash) -> DuplicateOutput {
// Where a queue item stands, and what it renders.
let place = |id: u64| {
let index = self.project.render_queue.iter().position(|i| i.id == id);
let comp = index.and_then(|k| self.project.render_queue.get(k)).and_then(|i| self.project.item(i.comp)).map(|i| i.name.clone());
(index.map_or(0, |k| k + 1), comp.unwrap_or_default())
};
let sequence = matches!(later.target, Target::Frames { .. });
// One shared frame is named by its file, more by the pattern.
let path = if sequence && clash.files == 1 { sequence_path(&later.path, clash.first) } else { later.path.clone() };
let (index, comp) = place(later.item);
let (with_index, with_comp) = place(clash.earlier.item);
DuplicateOutput { item: later.item, index, comp, path, files: clash.files, sequence, with: clash.earlier.item, with_index, with_comp }
}
fn check_output(&self, protected: &Protected, item: &RenderQueueItem, path: &str) -> Result<Option<String>, String> {
if protected.is_empty() {
return Ok(None);
@@ -371,49 +671,260 @@ impl Session {
.find_map(|path| protected.find(path).map(|source| source.refusal(&path.to_string_lossy(), Use::Render))))
}
/// 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.
/// A new item's default output must not be a file the project reads, nor one another item of
/// the queue writes (any of its output modules, whether or not it is ticked): `[compName]_1`,
/// `[compName]_2`… take its place, 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; the name
/// is stored in the item, so it stays as it is when the item it avoided goes away. One the
/// caller chose is not touched: rendering it is refused instead ([`Session::duplicate_outputs`]).
pub(crate) fn avoid_sources(&self, item: &mut RenderQueueItem) {
if cfg!(target_arch = "wasm32") {
return;
}
let protected = self.protected_files();
if protected.is_empty() {
// Native source protection stays with its filesystem guard; virtual web outputs still
// need distinct queue names because the browser stores completed files by path.
let protected = if cfg!(target_arch = "wasm32") { Protected::default() } else { self.protected_files() };
let Ok(queue) = self.queue_writes() else { return };
if protected.is_empty() && queue.is_empty() {
return;
}
let template = item.output.output.clone();
let mut taken: Option<HashSet<String>> = None;
'candidate: for n in 0..=MAX_NUMBER {
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 };
let mut in_the_way = false;
for (module, path) in &outputs {
match self.check_output(&protected, module, path) {
Ok(Some(_)) => continue 'candidate,
Err(_) => break 'candidate,
Ok(None) => {}
let Ok(paths) = self.write_paths(module, path) else {
item.output.output = template;
return;
};
for destination in &paths {
match self.check_output(&protected, module, destination) {
Ok(Some(_)) => in_the_way = true,
Err(_) => {
item.output.output = template;
return;
}
Ok(None) => {}
}
}
in_the_way |= paths.iter().any(|path| queue.clash(&self.write_of(module, path, false)).is_some());
}
// 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))))) {
if !in_the_way {
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()
/// What the items of the queue write (by name: a new item is not compared with what is on
/// disk).
fn queue_writes(&self) -> Result<Writes, String> {
let mut writes = Writes::default();
for outputs in self.project.render_queue.iter().filter_map(|it| self.outputs_of(it, None).ok()) {
for (m, path) in &outputs {
for path in self.write_paths(m, path)? {
writes.push(self.write_of(m, &path, false))?;
}
}
}
Ok(writes)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// Portable path checks protect virtual outputs too; they do not need native file identity.
#[test]
fn duplicate_queue_paths_are_reported_without_native_files() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 16, "height": 16})).unwrap();
for _ in 0..2 {
s.execute("renderQueue.add", json!({"format": "h264", "output": "/virtual/shared.mp4"})).unwrap();
}
let (outputs, _) = s.queued_outputs(&effectcraft_project::render_queue::OutputFormat::ALL);
let duplicates = s.duplicate_outputs(&outputs, &[]).unwrap();
assert_eq!(duplicates.len(), 1);
assert!(duplicates[0].path.ends_with("shared.mp4"));
let refused = [Refused { item: duplicates[0].item, reason: "already refused".into() }];
assert!(s.duplicate_outputs(&outputs, &refused).unwrap().is_empty());
}
/// An output of queue item `item` that is a file, known by its name alone.
fn movie(item: u64, path: &str) -> Write {
Write { item, path: path.into(), target: Target::file(Path::new(path), false), skips: false }
}
/// An output of queue item `item` that is `count` frames of a sequence from frame `first`.
fn frames(item: u64, pattern: &str, first: i64, count: u64) -> Write {
Write { item, path: pattern.into(), target: Target::frames(pattern, first, count), skips: false }
}
fn job_of(writes: impl IntoIterator<Item = Write>) -> Writes {
let mut job = Writes::default();
for write in writes {
job.push(write).unwrap();
}
job
}
#[test]
fn browser_output_names_match_the_virtual_file_table() {
assert_eq!(browser_key(Path::new("renders/a.png")), browser_key(Path::new("/renders/a.png")));
assert_ne!(browser_key(Path::new("renders/A.png")), browser_key(Path::new("renders/a.png")));
assert_ne!(browser_key(Path::new("renders/../a.png")), browser_key(Path::new("a.png")));
assert_ne!(browser_key(Path::new("//a.png")), browser_key(Path::new("/a.png")));
}
#[test]
fn output_plan_path_budget_returns_an_error() {
let mut job = Writes::default();
let path = "x".repeat(1024 * 1024);
for _ in 0..16 {
job.push(movie(1, &path)).unwrap();
}
assert!(job.push(movie(2, "one-too-many")).is_err());
assert_eq!(job.all.len(), 16);
}
/// The item an output replaces a file of, how many files, and the first.
fn clash(job: &Writes, later: &Write) -> Option<(u64, u64, i64)> {
job.clash(later).map(|c| (c.earlier.item, c.files, c.first))
}
#[test]
fn a_file_is_replaced_by_the_output_that_has_its_name() {
let job = job_of([movie(1, "out/a.mp4"), movie(2, "out/b.mp4"), movie(3, "out/b.mp4")]);
assert_eq!(clash(&job, &movie(9, "out/a.mp4")), Some((1, 1, 0)));
assert_eq!(clash(&job, &movie(9, "out/b.mp4")), Some((2, 1, 0)), "the first to write it is named");
assert_eq!(clash(&job, &movie(9, "out/x/../a.mp4")), Some((1, 1, 0)), "dot segments are resolved");
assert_eq!(clash(&job, &movie(9, "out/c.mp4")), None);
assert_eq!(clash(&job, &movie(9, "other/a.mp4")), None);
assert_eq!(clash(&job, &frames(9, "out/a.mp4", 0, 5)), None, "a sequence is not a movie");
assert!(Writes::default().is_empty() && !job.is_empty());
}
#[test]
fn two_names_of_one_file_on_disk_are_one_file() {
let on_disk = |item: u64, name: &str, id: Option<&str>| Write {
item,
path: name.into(),
target: Target::File { key: name.into(), identity: id.map(String::from) },
skips: false,
};
let job = job_of([on_disk(1, "/a/link.mp4", Some("7:1")), on_disk(2, "/a/nothing-yet.mp4", None)]);
assert_eq!(clash(&job, &on_disk(9, "/b/real.mp4", Some("7:1"))), Some((1, 1, 0)));
assert_eq!(clash(&job, &on_disk(9, "/b/real.mp4", Some("7:2"))), None);
assert_eq!(clash(&job, &on_disk(9, "/b/real.mp4", None)), None, "a file that is not there has no identity");
assert_eq!(clash(&job, &on_disk(9, "/a/nothing-yet.mp4", None)), Some((2, 1, 0)), "its name is all there is");
}
#[test]
fn sequences_clash_where_their_frames_do() {
let job = job_of([frames(1, "a_[#####].png", 0, 5)]); // frames 0-4
assert_eq!(clash(&job, &frames(2, "a_[#####].png", 5, 5)), None, "the five after it");
assert_eq!(clash(&job, &frames(2, "a_[#####].png", -3, 3)), None, "the three before it");
assert_eq!(clash(&job, &frames(2, "a_[#####].png", 4, 5)), Some((1, 1, 4)), "frame 4 only");
assert_eq!(clash(&job, &frames(2, "a_[#####].png", 2, 1)), Some((1, 1, 2)));
assert_eq!(clash(&job, &frames(2, "a_[#####].png", 1, 2)), Some((1, 2, 1)));
assert_eq!(clash(&job, &frames(2, "a_[#####].png", -3, 100)), Some((1, 5, 0)), "all five");
// The first of several that overlap is the one named.
let job = job_of([frames(1, "a_[#####].png", 0, 3), frames(2, "a_[#####].png", 3, 3)]);
assert_eq!(clash(&job, &frames(9, "a_[#####].png", 2, 3)), Some((1, 1, 2)));
assert_eq!(clash(&job, &frames(9, "a_[#####].png", 3, 3)), Some((2, 3, 3)));
}
#[test]
fn sequences_of_other_names_do_not_clash() {
let job = job_of([frames(1, "out/a_[#####].png", 0, 5)]);
for other in ["out/b_[#####].png", "out/a_[####].png", "out/a_[#####].jpg", "elsewhere/a_[#####].png", "out/a[#####].png", "out/a_[#####]/x.png"] {
assert_eq!(clash(&job, &frames(2, other, 0, 5)), None, "{other}");
}
// One name, however it is spelled: the run with or without brackets, none at all, dot segments.
for same in ["out/a_[#####].png", "out/a_#####.png", "out/a.png", "out/b/../a_[#####].png"] {
assert_eq!(clash(&job, &frames(2, same, 0, 5)), Some((1, 5, 0)), "{same}");
}
// A frame number that starts a folder's name is not a file name's.
let job = job_of([frames(1, "out/[##].png", 0, 5)]);
assert_eq!(clash(&job, &frames(2, "out[##].png", 0, 5)), None);
assert_eq!(clash(&job, &frames(2, "out/[##].png", 0, 5)), Some((1, 5, 0)));
}
#[test]
fn padding_widths_can_name_the_same_frame_and_files_can_name_sequence_frames() {
let job = job_of([frames(1, "out/a_[####].png", 9999, 3)]);
assert_eq!(clash(&job, &frames(2, "out/a_[#####].png", 9999, 3)), Some((1, 2, 10000)));
let job = job_of([frames(1, "out/a_[##].png", -12, 15)]);
assert_eq!(clash(&job, &frames(2, "out/a_[###].png", -12, 15)), Some((1, 3, -12)));
let job = job_of([frames(1, "out/a_[#####].png", 0, 5)]);
assert_eq!(clash(&job, &movie(2, "out/a_00002.png")), Some((1, 1, 2)));
let job = job_of([movie(1, "out/a_00002.png")]);
assert_eq!(clash(&job, &frames(2, "out/a_[#####].png", 0, 5)), Some((1, 1, 2)));
let mut skipping = frames(2, "out/a_[#####].png", 0, 5);
skipping.skips = true;
assert_eq!(clash(&job, &skipping), None);
}
/// Skip Existing Files leaves a frame that is there alone: it matters for the output that
/// comes second, and for sequences only.
#[test]
fn an_output_that_skips_existing_frames_replaces_none() {
let skipping = |mut write: Write| {
write.skips = true;
write
};
let job_of_one = job_of([frames(1, "a_[#####].png", 0, 5)]);
assert_eq!(clash(&job_of_one, &skipping(frames(2, "a_[#####].png", 0, 5))), None);
let job_of_one = job_of([skipping(frames(1, "a_[#####].png", 0, 5))]);
assert_eq!(clash(&job_of_one, &frames(2, "a_[#####].png", 3, 5)), Some((1, 2, 3)), "the earlier one's frames are replaced");
let job_of_one = job_of([movie(1, "a.mp4")]);
assert_eq!(clash(&job_of_one, &skipping(movie(2, "a.mp4"))), Some((1, 1, 0)), "a movie is written whole");
}
#[test]
fn frame_numbers_beyond_what_a_project_holds_do_not_panic() {
let top = i64::MAX - 1;
let job = job_of([frames(1, "a_[#####].png", top, u64::MAX), frames(2, "a_[#####].png", i64::MIN, u64::MAX)]);
assert_eq!(clash(&job, &frames(9, "a_[#####].png", top, u64::MAX)), Some((1, 1, top)));
assert_eq!(clash(&job, &frames(9, "a_[#####].png", -5, 3)), Some((2, 3, -5)));
assert_eq!(clash(&job, &frames(9, "a_[#####].png", 0, 5)), None, "between the two");
assert_eq!(clash(&job, &frames(9, "a_[#####].png", i64::MAX, 0)), None, "nothing written");
assert_eq!(clash(&job, &frames(9, "a_[#####].png", i64::MIN, 0)), None);
}
#[test]
fn the_reason_names_both_items_and_what_they_write() {
let both = DuplicateOutput {
item: 7,
index: 3,
comp: "Main".into(),
path: "/o/Main.mp4".into(),
files: 1,
sequence: false,
with: 4,
with_index: 1,
with_comp: "Intro".into(),
};
assert_eq!(
both.reason(),
"#1 “Intro” and #3 “Main” would both write /o/Main.mp4, and a render never writes one file twice; choose another Output To for one of them"
);
let sequence = DuplicateOutput { sequence: true, files: 12, path: "/o/Main_[#####].png".into(), ..both.clone() };
assert!(sequence.reason().contains("would both write 12 frames of /o/Main_[#####].png,"), "{}", sequence.reason());
let one = DuplicateOutput { files: 1, path: "/o/Main_00004.png".into(), ..sequence };
assert!(one.reason().contains("would both write /o/Main_00004.png,"), "{}", one.reason());
let itself = DuplicateOutput { with: 7, with_index: 3, with_comp: "Main".into(), ..both.clone() };
assert!(itself.reason().starts_with("#3 “Main” would write /o/Main.mp4 twice (two of its output modules), and a render never"), "{}", itself.reason());
let mut found = vec![];
assert_eq!(duplicate_refusal(&found), None);
found.push(both.clone());
assert_eq!(duplicate_refusal(&found), Some(format!("{}. Nothing was rendered", both.reason())));
found.push(both.clone());
assert!(duplicate_refusal(&found).is_some_and(|r| r.ends_with("Nothing was rendered; 1 more output would write a file twice too")));
found.push(both);
assert!(duplicate_refusal(&found).is_some_and(|r| r.ends_with("Nothing was rendered; 2 more outputs would write a file twice too")));
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
+11
View File
@@ -57,6 +57,12 @@ pub struct ExportResult {
pub trait Exporter: Send + Sync {
/// Formats this build can write.
fn formats(&self) -> Vec<OutputFormat>;
/// Whether Skip Existing Files preserves sequence frames written by earlier items. Exporters
/// without an existence check (such as memory sinks) must keep the conservative default.
/// The check must cover the final destination for these settings, including any overflow.
fn supports_skip_existing(&self, _settings: &effectcraft_project::render_queue::RenderSettings) -> bool {
false
}
/// Render and write one item (blocking). `progress(done, total)` returns `false` to cancel;
/// a cancelled export removes the files it opened for that output and returns `Err("cancelled")`.
/// Completed earlier queue items and unrelated files must be preserved.
@@ -493,6 +499,11 @@ impl Session {
let formats = exporter.formats();
let (mut items, mut problems) = self.queued_outputs(&formats);
let refused = self.check_outputs(&items);
// Two outputs of this job on one file: the render would replace its own output, so it is
// refused before anything is written (the items refused above take no part).
if let Some(refusal) = crate::output_guard::duplicate_refusal(&self.duplicate_outputs(&items, &refused)?) {
return Err(refusal);
}
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() {
+92
View File
@@ -1943,3 +1943,95 @@ fn old_guides_load_as_pixel_guides_and_percent_round_trips() {
// A zero-sized comp never divides by zero.
assert!(g.px(0, 0).is_finite());
}
#[test]
fn copy_paste_masks_between_layers() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
let b = solid(&mut s, "#00ff00");
let n = s.active_comp().unwrap().layers.len();
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("layer.addMask", json!({})).unwrap();
s.execute("layer.addMask", json!({"shape": "ellipse"})).unwrap();
let names = |s: &Session, id: u64| layer(s, id).masks().unwrap().groups().map(|m| m.name.clone()).collect::<Vec<_>>();
let uids = |s: &Session, id: u64| layer(s, id).masks().unwrap().groups().map(|m| m.uid).collect::<Vec<_>>();
let first = uids(&s, a)[0];
s.execute("prop.select", json!({"layer": a, "prop": first})).unwrap();
// Copy the mask: it is the mask that is copied, not its layer.
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["masks"], json!(1));
assert!(s.state.clipboard.is_empty(), "not the layer");
assert!(s.is_enabled("edit.paste"));
s.execute("layer.select", json!({"layers": [b]})).unwrap();
let r = s.execute("edit.paste", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), n, "no new layer");
assert_eq!(names(&s, b), ["Mask 1"]);
let pasted = r["masks"][0].as_u64().unwrap();
assert_eq!(uids(&s, b), [pasted]);
assert_ne!(pasted, first);
assert_eq!(s.state.selected_props, vec![(effectcraft_project::LayerId(b), pasted)]);
assert_eq!(names(&s, a), ["Mask 1", "Mask 2"], "the source keeps its masks");
// Pasting again onto the same layer keeps names unique.
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(names(&s, b), ["Mask 1", "Mask 2"]);
// One undo step per paste.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(names(&s, b), ["Mask 1"]);
}
#[test]
fn paste_masks_skips_locked_layers() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
let b = solid(&mut s, "#00ff00");
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("layer.addMask", json!({})).unwrap();
let mask = layer(&s, a).masks().unwrap().groups().next().unwrap().uid;
s.execute("prop.select", json!({"layer": a, "prop": mask})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("layer.select", json!({"layers": [b]})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap();
assert!(s.execute("edit.paste", json!({})).is_err());
assert_eq!(layer(&s, b).masks().unwrap().groups().count(), 0);
}
#[test]
fn copy_with_an_explicit_layer_ignores_the_selected_mask() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("layer.addMask", json!({})).unwrap();
let mask = layer(&s, a).masks().unwrap().groups().next().unwrap().uid;
s.execute("prop.select", json!({"layer": a, "prop": mask})).unwrap();
s.execute("edit.copy", json!({"layer": a})).unwrap();
assert_eq!(s.state.clipboard.len(), 1, "the named layer is copied");
assert!(s.state.mask_clipboard.is_empty());
}
#[test]
fn selecting_a_mask_drops_stale_key_selection_so_copy_copies_the_mask() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("layer.addMask", json!({})).unwrap();
let mask = layer(&s, a).masks().unwrap().groups().next().unwrap().uid;
s.state.selected_keys = vec![crate::KeyRef { layer: effectcraft_project::LayerId(a), prop: 1, time: Default::default() }];
s.execute("prop.select", json!({"layer": a, "prop": mask})).unwrap();
assert!(s.state.selected_keys.is_empty());
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["masks"], json!(1));
}
#[test]
fn pasting_a_mask_onto_its_own_layer_drops_the_source_vertex_selection() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("layer.addMask", json!({})).unwrap();
let mask = layer(&s, a).masks().unwrap().groups().next().unwrap().uid;
s.execute("prop.select", json!({"layer": a, "prop": mask})).unwrap();
assert!(!s.state.selected_vertices.is_empty());
s.execute("edit.copy", json!({})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert!(s.state.selected_vertices.is_empty());
s.execute("edit.clear", json!({})).unwrap();
assert_eq!(layer(&s, a).masks().unwrap().groups().count(), 1, "the pasted mask goes, the source stays");
}
+432 -1
View File
@@ -1,5 +1,6 @@
//! Rendering never replaces a file the project reads (#1743). Ordinary-file replacement
//! behavior stays with the exporter and its confirmation API.
//! behavior stays with the exporter and its confirmation API. Two items of one job never write
//! the same file either.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
@@ -27,6 +28,9 @@ impl Exporter for Writer {
fn formats(&self) -> Vec<OutputFormat> {
OutputFormat::ALL.to_vec()
}
fn supports_skip_existing(&self, _: &effectcraft_project::render_queue::RenderSettings) -> bool {
true
}
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")?;
@@ -609,3 +613,430 @@ fn a_watched_folder_still_renders_a_project_again_over_its_outputs() {
assert_eq!(done[0]["state"], "done", "{:?}", done[0]);
assert_eq!(f.read("Main.mp4"), RENDERED);
}
// ------------------------------------------------------------ one file, two items of one job
//
// Nothing is on disk when a job starts, so the check for files that exist said nothing about two
// queue items that resolve to one output: the item rendered last replaced the file of the one
// before it, inside the same job.
impl Fixture {
/// The output path of every queue item, in queue order.
fn outputs(&self) -> Vec<PathBuf> {
(0..self.s.project.render_queue.len()).map(|i| self.output(i)).collect()
}
fn add(&mut self, params: Value) {
self.s.execute("renderQueue.add", params).unwrap();
}
/// What the queue would write twice, in the shape the tests read: `{"duplicates": [...]}`.
fn check(&mut self) -> Value {
let formats = self.s.exporter.as_ref().map(|e| e.formats()).unwrap_or_default();
let (items, _) = self.s.queued_outputs(&formats);
let refused = self.s.check_outputs(&items);
let duplicates: Vec<Value> = self
.s
.duplicate_outputs(&items, &refused)
.unwrap()
.iter()
.map(|d| {
json!({
"item": d.item, "index": d.index, "comp": d.comp, "path": d.path, "files": d.files, "sequence": d.sequence,
"with": {"item": d.with, "index": d.with_index, "comp": d.with_comp}, "reason": d.reason(),
})
})
.collect();
json!({"duplicates": duplicates})
}
/// What `check` lists under `duplicates`: for each, the place in the queue of the item that
/// would write over a file, the place of the item that wrote it first, and how many files.
fn clashes(&mut self) -> Vec<(u64, u64, u64)> {
let found = self.check();
let list = found["duplicates"].as_array().unwrap_or_else(|| panic!("checkOutputs lists no duplicates: {found}"));
list.iter().map(|d| (d["index"].as_u64().unwrap_or(0), d["with"]["index"].as_u64().unwrap_or(0), d["files"].as_u64().unwrap_or(0))).collect()
}
}
/// A PNG sequence of `pattern` for the composition's seconds `start..end`.
fn span(pattern: &str, start: f64, end: f64) -> Value {
json!({"format": "png", "output": pattern, "timeSpan": "custom", "start": start, "end": end})
}
fn with_overflow(f: &mut Fixture) {
struct Room;
impl effectcraft_project::render_queue::StorageQuota for Room {
fn has_room(&self, _: &str, _: u64) -> bool {
true
}
}
f.s.storage_quota = Some(Arc::new(Room));
std::fs::create_dir_all(f.path("overflow")).unwrap();
Arc::make_mut(&mut f.s.project).render_prefs.overflow_folders = vec![f.text("overflow")];
}
#[test]
fn default_names_avoid_possible_storage_overflow_collisions() {
let mut f = Fixture::new("overflow-default");
f.comp("Main");
with_overflow(&mut f);
f.add(json!({"output": f.text("other/Main.mp4"), "storageOverflow": true}));
f.add(json!({"storageOverflow": true}));
assert_eq!(f.output(1), f.path("Main_1.mp4"));
assert!(f.clashes().is_empty());
}
#[test]
fn possible_storage_overflow_destinations_cannot_replace_footage() {
let mut f = Fixture::new("overflow-footage");
f.comp("Main");
with_overflow(&mut f);
f.footage("overflow/Main.mp4");
f.add(json!({"output": f.text("other/Main.mp4"), "storageOverflow": true}));
let error = f.render(json!({})).unwrap_err().to_string();
assert!(error.contains("footage") && error.contains("Main.mp4"), "{error}");
assert!(f.exported().is_empty());
assert_eq!(f.read("overflow/Main.mp4"), SOURCE);
}
#[test]
fn default_names_avoid_protected_storage_overflow_destinations() {
let mut f = Fixture::new("overflow-protected-default");
f.comp("Main");
with_overflow(&mut f);
f.footage("overflow/Main.mp4");
f.add(json!({"storageOverflow": true}));
assert_eq!(f.output(0), f.path("Main_1.mp4"));
f.render(json!({})).unwrap();
assert_eq!(f.exported().len(), 1);
assert_eq!(f.read("overflow/Main.mp4"), SOURCE);
}
/// After Effects numbers the second item of one composition (`Comp 1_1.mov`). A default output
/// name that another item of the queue already has is numbered the same way.
#[test]
fn a_second_default_item_for_one_comp_gets_the_next_free_name() {
let mut f = Fixture::new("second-default");
f.comp("Main");
for _ in 0..3 {
f.add(json!({}));
}
assert_eq!(f.outputs(), [f.path("Main.mp4"), f.path("Main_1.mp4"), f.path("Main_2.mp4")]);
// The name is stored in the item: taking the first one out renames nothing, and the name it
// frees is the next default.
f.s.execute("renderQueue.remove", json!({"index": 1})).unwrap();
assert_eq!(f.outputs(), [f.path("Main_1.mp4"), f.path("Main_2.mp4")]);
f.add(json!({}));
assert_eq!(f.output(2), f.path("Main.mp4"));
assert!(f.clashes().is_empty());
f.render(json!({})).unwrap();
for file in ["Main.mp4", "Main_1.mp4", "Main_2.mp4"] {
assert_eq!(f.read(file), RENDERED, "{file}");
}
assert_eq!(f.exported().len(), 3);
}
#[test]
fn only_outputs_that_name_one_file_are_numbered() {
let mut f = Fixture::new("same-file-only");
f.comp("Main");
f.add(json!({"format": "h264"}));
f.add(json!({"format": "prores"}));
f.add(json!({"format": "hevc"})); // H.264 and HEVC both write .mp4
f.add(json!({"format": "png"}));
f.add(json!({"format": "png"})); // the same five frames of the same sequence
assert_eq!(f.outputs(), [f.path("Main.mp4"), f.path("Main.mov"), f.path("Main_1.mp4"), f.path("Main_[#####].png"), f.path("Main_1_[#####].png")]);
assert!(f.clashes().is_empty());
}
#[test]
fn a_default_name_avoids_every_output_module_of_the_other_items() {
let mut f = Fixture::new("modules");
f.comp("Main");
f.add(json!({}));
// The item's second module is named Main_2.mp4, so that it does not clash with its first.
let item = f.s.execute("render.addOutputModule", json!({})).unwrap();
assert_eq!(Path::new(item["extraOutputs"][0]["outputPath"].as_str().unwrap()), f.path("Main_2.mp4"), "{item}");
f.add(json!({}));
f.add(json!({}));
assert_eq!(f.output(1), f.path("Main_1.mp4"));
assert_eq!(f.output(2), f.path("Main_3.mp4"), "Main_2.mp4 is the first item's second module");
assert!(f.clashes().is_empty());
}
#[test]
fn a_name_the_caller_gave_is_never_numbered() {
let mut f = Fixture::new("named");
f.comp("Main");
let out = f.text("final.mp4");
f.add(json!({"output": out}));
f.add(json!({"output": out}));
f.add(json!({"path": out}));
assert_eq!(f.outputs(), vec![f.path("final.mp4"); 3]);
assert!(f.s.project.render_queue.iter().all(|i| i.output.output == out), "the names stored in the items are the ones given");
// Rendering them is what is refused (below): items 2 and 3 each clash with item 1.
assert_eq!(f.clashes(), [(2, 1, 1), (3, 1, 1)]);
}
#[test]
fn a_pre_render_numbers_its_default_name_too() {
let mut f = Fixture::new("pre-render");
f.comp("Main");
f.s.execute("render.preRender", json!({})).unwrap();
f.s.execute("render.preRender", json!({})).unwrap();
f.s.execute("render.preRender", json!({"output": f.text("mine.mov")})).unwrap();
f.s.execute("render.preRender", json!({"output": f.text("mine.mov")})).unwrap();
assert_eq!(f.outputs(), [f.path("Main_prerender.mov"), f.path("Main_prerender_1.mov"), f.path("mine.mov"), f.path("mine.mov")]);
}
/// File ▸ Create Proxy queues a render with a default name of its own too.
#[test]
fn create_proxy_numbers_its_default_name_too() {
let mut f = Fixture::new("proxy-names");
f.comp("Main");
f.s.execute("file.createProxy", json!({"kind": "movie"})).unwrap();
f.s.execute("file.createProxy", json!({"kind": "movie"})).unwrap();
f.s.execute("file.createProxy", json!({"kind": "movie", "output": f.text("mine.mov")})).unwrap();
f.s.execute("file.createProxy", json!({"kind": "movie", "output": f.text("mine.mov")})).unwrap();
assert_eq!(f.outputs(), [f.path("Main_proxy.mov"), f.path("Main_proxy_1.mov"), f.path("mine.mov"), f.path("mine.mov")]);
}
/// Save Frame As ▸ File… with `queue`: every file of a sequence has its frame number in its name,
/// so two frames of one composition share no file, and the same frame twice does.
#[test]
fn queued_frames_number_their_default_names_only_when_they_are_the_same_frame() {
let mut f = Fixture::new("frames");
f.comp("Main");
for time in [0.0, 0.2, 0.0] {
f.s.execute("comp.saveFrameAs", json!({"queue": true, "time": time})).unwrap();
}
assert_eq!(f.outputs(), [f.path("Main_[#####].png"), f.path("Main_[#####].png"), f.path("Main_1_[#####].png")]);
assert!(f.clashes().is_empty());
f.render(json!({})).unwrap();
for file in ["Main_00000.png", "Main_00002.png", "Main_1_00000.png"] {
assert_eq!(f.read(file), RENDERED, "{file}");
}
}
/// The report: two queue items forced to one path (the same Output To) replaced each other's file
/// inside one job. The render is refused before anything is written, whatever `overwrite` says:
/// that flag is for files that exist, and this is the render replacing its own output.
#[test]
fn two_items_forced_to_one_file_are_reported_and_the_render_is_refused() {
let mut f = Fixture::new("one-file");
f.comp("Main");
let out = f.text("render.mp4");
f.add(json!({"output": out}));
f.add(json!({"output": out}));
let found = f.check();
let list = found["duplicates"].as_array().unwrap_or_else(|| panic!("checkOutputs lists no duplicates: {found}"));
assert_eq!(list.len(), 1, "{found}");
let d = &list[0];
let (first, second) = (f.s.project.render_queue[0].id, f.s.project.render_queue[1].id);
assert_eq!((d["item"].as_u64(), d["index"].as_u64(), d["comp"].as_str()), (Some(second), Some(2), Some("Main")), "{found}");
assert_eq!((d["with"]["item"].as_u64(), d["with"]["index"].as_u64(), d["with"]["comp"].as_str()), (Some(first), Some(1), Some("Main")), "{found}");
assert_eq!((d["files"].as_u64(), d["sequence"].as_bool()), (Some(1), Some(false)), "{found}");
assert_eq!(Path::new(d["path"].as_str().unwrap_or_default()), f.path("render.mp4"), "{found}");
let why = d["reason"].as_str().unwrap_or_default();
assert!(why.contains("#1") && why.contains("#2") && why.contains("render.mp4") && why.contains("Output To"), "{found}");
let queue = f.s.project.render_queue.clone();
let e = f.render(json!({})).unwrap_err().to_string();
assert!(e.contains("#1") && e.contains("#2") && e.contains("render.mp4") && e.contains("Nothing was rendered"), "{e}");
assert!(!f.path("render.mp4").exists());
assert!(f.exported().is_empty(), "the exporter was not started");
assert_eq!(f.s.project.render_queue, queue, "a refused render changes nothing in the queue");
// A file that exists as well does not change that: replacing it would not help.
f.write("render.mp4", EARLIER);
let found = f.check();
assert_eq!(found["duplicates"].as_array().map(Vec::len), Some(1), "{found}");
let e = f.render(json!({})).unwrap_err().to_string();
assert!(e.contains("#2") && e.contains("Nothing was rendered"), "{e}");
assert_eq!(f.read("render.mp4"), EARLIER);
// Another Output To for one of them, and both render.
f.s.execute("renderQueue.setOutput", json!({"index": 2, "path": f.text("second.mp4")})).unwrap();
assert!(f.clashes().is_empty());
f.render(json!({})).unwrap();
assert_eq!((f.read("render.mp4"), f.read("second.mp4")), (RENDERED.to_vec(), RENDERED.to_vec()));
}
/// `renderQueue.duplicate` copies the item as it is, output included: it is reported until one of
/// the two gets another Output To (or is turned off).
#[test]
fn a_duplicated_item_is_a_copy_and_clashes_until_one_of_them_changes() {
let mut f = Fixture::new("duplicate");
f.comp("Main");
f.add(json!({}));
f.s.execute("renderQueue.duplicate", json!({"index": 1})).unwrap();
assert_eq!(f.outputs(), [f.path("Main.mp4"), f.path("Main.mp4")], "a duplicate is a copy, its output included");
assert_eq!(f.clashes(), [(2, 1, 1)]);
let e = f.render(json!({})).unwrap_err().to_string();
assert!(e.contains("#1") && e.contains("#2") && e.contains("Main.mp4"), "{e}");
assert!(f.exported().is_empty());
// Turned off, the copy is not part of the job.
f.s.execute("renderQueue.setRender", json!({"index": 2, "render": false})).unwrap();
assert!(f.clashes().is_empty());
f.render(json!({})).unwrap();
assert_eq!(f.read("Main.mp4"), RENDERED);
assert_eq!(f.exported().len(), 1);
}
/// Only the items of the job are compared: an earlier job's file is a file that exists, and
/// replacing it is up to `overwrite`.
#[test]
fn an_item_that_is_not_in_the_job_does_not_clash() {
let mut f = Fixture::new("not-in-job");
f.comp("Main");
let out = f.text("render.mp4");
f.add(json!({"output": out}));
f.add(json!({"output": out}));
f.s.execute("renderQueue.setRender", json!({"index": 2, "render": false})).unwrap();
f.render(json!({})).unwrap(); // item 1 renders alone
f.s.execute("renderQueue.setRender", json!({"index": 2, "render": true})).unwrap();
assert!(f.clashes().is_empty(), "item 1 is done and is not in this job");
// item 2 now replaces the file item 1 wrote in the earlier job: an ordinary file, left to the exporter
f.render(json!({})).unwrap();
}
#[test]
fn another_spelling_of_one_file_is_one_file_where_the_volume_ignores_case() {
let mut f = Fixture::new("spellings");
f.comp("Main");
f.write("probe.txt", b"x");
if !f.path("PROBE.TXT").exists() {
eprintln!("this volume tells the two names apart: skipped");
return;
}
f.add(json!({"output": f.text("Render.mp4")}));
f.add(json!({"output": f.text("render.MP4")}));
f.add(json!({"output": f.dir.join("sub").join("..").join("RENDER.mp4").to_string_lossy()}));
f.add(json!({"output": f.text("other.mp4")}));
assert_eq!(f.clashes(), [(2, 1, 1), (3, 1, 1)]);
}
/// A sequence writes one file per frame: two items of one pattern clash on the frames they share,
/// and not at all when their frames differ.
#[test]
fn sequences_clash_only_on_the_frames_they_both_write() {
let mut f = Fixture::new("sequence-halves");
f.comp("Main");
let pattern = f.text("seq_[#####].png");
f.add(span(&pattern, 0.0, 0.2)); // frames 0-1
f.add(span(&pattern, 0.2, 0.5)); // frames 2-4
assert!(f.clashes().is_empty());
f.render(json!({})).unwrap();
for n in 0..5 {
assert_eq!(f.read(&format!("seq_{n:05}.png")), RENDERED, "frame {n}");
}
let mut f = Fixture::new("sequence-overlap");
f.comp("Main");
let pattern = f.text("seq_[#####].png");
f.add(span(&pattern, 0.0, 0.2));
f.add(span(&pattern, 0.2, 0.5));
f.add(span(&pattern, 0.3, 0.5)); // frames 3-4 again
assert_eq!(f.clashes(), [(3, 2, 2)]);
let found = f.check();
assert_eq!(found["duplicates"][0]["sequence"], true, "{found}");
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
assert!(e.contains("#2") && e.contains("#3") && e.contains("2 frames") && e.contains("seq_[#####].png"), "{e}");
assert!(!f.path("seq_00000.png").exists(), "the frames before the clash were not rendered either");
assert!(f.exported().is_empty());
}
#[test]
fn differently_padded_sequences_cannot_replace_the_same_frames() {
let mut f = Fixture::new("padding-width-clash");
f.s.execute("comp.new", json!({"name": "Main", "width": 16, "height": 16, "frameRate": 10, "duration": 2})).unwrap();
f.add(span(&f.text("seq_[#].png"), 0.9, 1.2));
f.add(span(&f.text("seq_[##].png"), 0.9, 1.2));
assert_eq!(f.clashes(), [(2, 1, 2)]);
assert!(f.render(json!({})).is_err());
assert!(f.exported().is_empty());
}
/// Skip Existing Files leaves a frame alone when it is there: the item that skips and comes second
/// writes only what the first did not. The item that skips and comes first is written over.
#[test]
fn a_later_item_that_skips_existing_frames_leaves_the_earlier_ones_alone() {
let mut f = Fixture::new("skip-later");
f.comp("Main");
let pattern = f.text("seq_[#####].png");
f.add(json!({"format": "png", "output": pattern}));
f.add(json!({"format": "png", "output": pattern, "skipExisting": true}));
assert!(f.clashes().is_empty());
f.render(json!({})).unwrap();
assert_eq!(f.read("seq_00004.png"), RENDERED);
let mut f = Fixture::new("skip-earlier");
f.comp("Main");
let pattern = f.text("seq_[#####].png");
f.add(json!({"format": "png", "output": pattern, "skipExisting": true}));
f.add(json!({"format": "png", "output": pattern}));
assert_eq!(f.clashes(), [(2, 1, 5)]);
}
#[test]
fn two_output_modules_of_one_item_cannot_share_a_file() {
let mut f = Fixture::new("two-modules");
f.comp("Main");
let out = f.text("render.mp4");
f.add(json!({"output": out}));
f.s.execute("render.addOutputModule", json!({"output": out})).unwrap();
assert_eq!(f.clashes(), [(1, 1, 1)]);
let e = f.render(json!({})).unwrap_err().to_string();
assert!(e.contains("#1") && e.contains("twice"), "{e}");
assert!(f.exported().is_empty());
}
/// Settings ▸ Export ▸ Segment Sequences puts each item's frames in numbered folders: default
/// names keep two items' folders apart, and items given one name clash segment by segment.
#[test]
fn segmented_sequences_clash_segment_by_segment() {
let mut f = Fixture::new("segments");
f.comp("Main");
f.s.prefs.export.segment_sequences = true;
f.s.prefs.export.segment_sequence_files = 2; // five frames: segments of 2, 2 and 1
f.add(json!({"format": "png"}));
f.add(json!({"format": "png"}));
assert_eq!(f.output(1), f.path("Main_1_[#####].png"));
assert!(f.clashes().is_empty());
f.render(json!({})).unwrap();
assert_eq!(f.read("Main_001/Main_00000.png"), RENDERED);
assert_eq!(f.read("Main_1_003/Main_1_00004.png"), RENDERED);
let mut f = Fixture::new("segments-named");
f.comp("Main");
f.s.prefs.export.segment_sequences = true;
f.s.prefs.export.segment_sequence_files = 2;
let pattern = f.text("seg_[#####].png");
f.add(json!({"format": "png", "output": pattern}));
f.add(json!({"format": "png", "output": pattern}));
assert_eq!(f.clashes(), [(2, 1, 2), (2, 1, 2), (2, 1, 1)]);
let e = f.render(json!({})).unwrap_err().to_string();
assert!(e.contains("2 more"), "the first clash is named, the rest counted: {e}");
assert!(f.exported().is_empty());
}
/// An item that is refused for the file it would replace does not write anything, so it cannot
/// clash with another item.
#[test]
fn items_refused_for_footage_are_not_compared_with_each_other() {
let mut f = Fixture::new("refused-pair");
f.footage("clip.mov");
f.comp("Main");
let clip = f.text("clip.mov");
f.add(json!({"format": "prores", "output": clip}));
f.add(json!({"format": "prores", "output": clip}));
f.add(json!({"output": f.text("ok.mp4")}));
let found = f.check();
assert_eq!(found["duplicates"].as_array().map(Vec::len), Some(0), "{found}");
let r = f.render(json!({})).unwrap();
let failed = r["items"].as_array().unwrap().iter().filter(|i| i["status"].get("Failed").is_some()).count();
assert_eq!(failed, 2, "both items on the footage are refused: {r}");
assert_eq!(f.read("clip.mov"), SOURCE);
assert_eq!(f.read("ok.mp4"), RENDERED);
}
+4
View File
@@ -33,6 +33,10 @@ impl Exporter for FileExporter {
fn formats(&self) -> Vec<OutputFormat> {
effectcraft_export::available_formats()
}
fn supports_skip_existing(&self, settings: &effectcraft_project::render_queue::RenderSettings) -> bool {
// The exporter's existence check precedes storage-overflow redirection.
self.sink.is_none() && !cfg!(target_arch = "wasm32") && !settings.storage_overflow
}
fn export(&self, job: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
effectcraft_engine::render::passes::block_on(self.export_async(job, progress))
}
+65
View File
@@ -1,5 +1,70 @@
use serde_json::json;
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn storage_overflow_cannot_bypass_duplicate_output_protection() {
for same_folder in [true, false] {
refuse_overflow_collision(same_folder);
}
}
#[cfg(not(target_arch = "wasm32"))]
fn refuse_overflow_collision(same_folder: bool) {
use std::{path::PathBuf, sync::Arc};
struct OverflowOnly(PathBuf);
impl effectcraft_project::render_queue::StorageQuota for OverflowOnly {
fn has_room(&self, path: &str, _: u64) -> bool {
std::path::Path::new(path).starts_with(&self.0)
}
}
let dir = std::env::temp_dir().join(format!("ec-duplicate-overflow-{}-{same_folder}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let overflow = dir.join("overflow");
std::fs::create_dir_all(&overflow).unwrap();
let mut s = effectcraft_engine::Session {
exporter: Some(Arc::new(super::FileExporter::default())),
storage_quota: Some(Arc::new(OverflowOnly(overflow.clone()))),
..Default::default()
};
Arc::make_mut(&mut s.project).render_prefs.overflow_folders = vec![overflow.to_string_lossy().into_owned(); 2];
s.execute("comp.new", json!({"width": 16, "height": 16, "frameRate": 10, "duration": 0.2})).unwrap();
for index in 0..2 {
let folder = if same_folder { dir.clone() } else { dir.join(index.to_string()) };
s.execute("renderQueue.add", json!({"format": "png", "output": folder.join("frames_[#####].png"), "skipExisting": true, "storageOverflow": true}))
.unwrap();
}
let result = s.execute("renderQueue.render", json!({}));
let written = std::fs::read_dir(&overflow).unwrap().count();
assert!(result.is_err(), "overflow destinations cannot skip previous frames: {result:?}");
assert_eq!(written, 0, "refused before any overflow file is written");
s.execute("renderQueue.remove", json!({"index": 2})).unwrap();
let result = s.execute("renderQueue.render", json!({})).unwrap();
assert_eq!(result["items"][0]["status"], "Done", "one output's alternatives are not separate outputs: {result}");
assert_eq!(std::fs::read_dir(&overflow).unwrap().count(), 2);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn memory_sink_cannot_overwrite_a_previous_output_even_with_skip_existing() {
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
let writes = Arc::new(AtomicUsize::new(0));
let count = writes.clone();
let sink: Arc<effectcraft_export::Sink> = Arc::new(move |_, _| {
count.fetch_add(1, Ordering::Relaxed);
});
let mut s = effectcraft_engine::Session { exporter: Some(Arc::new(super::FileExporter { sink: Some(sink) })), ..Default::default() };
s.execute("comp.new", json!({"name": "Main", "width": 16, "height": 16, "frameRate": 10, "duration": 0.2})).unwrap();
for _ in 0..2 {
s.execute("renderQueue.add", json!({"format": "png", "output": "/virtual/frames_[#####].png", "skipExisting": true})).unwrap();
}
let result = s.execute("renderQueue.render", json!({}));
assert!(result.is_err(), "a sink cannot skip previous paths: {result:?}");
assert_eq!(writes.load(Ordering::Relaxed), 0, "refused before the sink receives bytes");
}
#[test]
fn host_runs_tasks_scheduled_by_panel_handlers() {
let mut s = super::session();
+2
View File
@@ -21,6 +21,8 @@ fn clipboard_text(s: &effectcraft_engine::Session) -> String {
n(st.effect_clipboard.len(), "effect", "effects")
} else if !st.contents_clipboard.is_empty() {
n(st.contents_clipboard.len(), "shape item", "shape items")
} else if !st.mask_clipboard.is_empty() {
n(st.mask_clipboard.len(), "mask", "masks")
} else if st.link_clipboard.is_some() {
"EffectCraft: property links".into()
} else {
+10
View File
@@ -319,6 +319,16 @@ build real projects through these interfaces; they are worked examples of everyt
proresProfile: 4444, output}` then `renderQueue.render {"wait": true}`. Check a render by
importing it (`file.import`, `file.newCompFromSelection`) and `render_frame {transparent: true}`
for its alpha (ProRes 4444 and WebM VP9 alpha both read back as straight alpha).
**A render never writes one file twice either:** when two outputs of the job (two items, a
`renderQueue.duplicate` copy, output modules, segments) name the same file, the whole render is
refused before anything is written, naming both items. Sequences clash only on frames both
write. A later item with Skip Existing Files leaves the earlier one's frames alone only when
the exporter can check the final destination. Browser/memory sinks and storage-overflow renders still report collisions.
Preflight compares every configured overflow destination, since available space can change during a render. Default
names are numbered like footage collisions when another queue item writes them
(`renderQueue.add`, `render.preRender`, `comp.saveFrameAs {queue}`, `file.createProxy`: a second
`Main` renders to `Main_1.mp4`); a name you give is kept, and `renderQueue.duplicate` copies the
output as it is, so change one with `renderQueue.setOutput` or turn it off.
* **Other files written to a path you give** follow the same footage rule, with no flag and no
prompt: `comp.saveFrameAs` (also `…Psd`, `…Exr`), `file.exportLottie`, `file.saveCopy`,
`file.saveCopyAsXml`, `file.collectFiles` (every copy and the collected project; nothing is
+11
View File
@@ -53,6 +53,17 @@ receive `resultType: complete` and list/read cache hints; document reads are nev
{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"effectcraft://document"}}
```
## Two outputs on one file
Two outputs of one job that write the same file (two items with one Output To, a duplicated item,
output modules or segments that meet) refuse the whole `renderQueue.render` before anything is
written: the reply is `isError: true`, naming both items, and the queue is left as it was.
Sequences clash only on the frames both write. Skip Existing Files leaves earlier frames alone
only when the exporter can check the final destination; browser/memory sinks and storage-overflow
renders still report collisions. Preflight checks every configured overflow destination, since free
space can change during a render. A new item's default output name is numbered (`Main.mp4`,
`Main_1.mp4`) when another item of the queue writes it.
## Progress and cancellation
Headless `command_run` / `execute_command` calls to `renderQueue.render` (unless `wait: false`)