mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 22:42:24 -06:00
Render Queue: two outputs of one job never write the same file, and default names avoid the queue's other outputs
Two queue items that resolve to one output (the same comp added twice, a duplicated item) replaced
each other's file inside one job. Nothing is on disk when the job starts, so no check said anything.
- A default output name (renderQueue.add, render.preRender, comp.saveFrameAs {queue},
file.createProxy) that another item of the queue writes, in any output module or segment, is
numbered like a footage collision: Main.mp4, Main_1.mp4, Main_2.mp4. Sequences are compared by
the frames they write, so two stills of one comp at different times keep their names. Names a
caller gave and names already stored in items are never changed.
- Outputs of one job that still name the same file (explicit names, renderQueue.duplicate, output
modules, segments) refuse the whole render before anything is written, naming both items.
Sequences clash only on frames both write; a later item with Skip Existing Files leaves the
earlier item's frames alone; items refused for footage take no part.
Refusing the whole render, not just the later item: which of two items should keep the file is the
user's to say, and failing only the later one would leave a half-rendered queue found after they
left. renderQueue.duplicate stays a verbatim copy so the clash is reported, not hidden.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
1f15f8a53b
commit
615c4cdffb
@@ -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};
|
||||
@@ -604,3 +605,369 @@ 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)
|
||||
.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})
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user