mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 06:27:11 -06:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
@@ -0,0 +1,606 @@
|
||||
//! Rendering never replaces a file the project reads (#1743). Ordinary-file replacement
|
||||
//! behavior stays with the exporter and its confirmation API.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::render_queue::{OutputFormat, RenderStatus, sequence_path};
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{ExportJob, ExportResult, Exporter, Session};
|
||||
|
||||
const SOURCE: &[u8] = b"SOURCE FOOTAGE";
|
||||
const EARLIER: &[u8] = b"AN EARLIER RENDER";
|
||||
const RENDERED: &[u8] = b"RENDERED";
|
||||
|
||||
/// An exporter that writes like the real one: `RENDERED` at the job's path, or at the path of
|
||||
/// every frame for a sequence (leaving the frames that exist alone when the item skips them).
|
||||
#[derive(Default)]
|
||||
struct Writer {
|
||||
jobs: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Exporter for Writer {
|
||||
fn formats(&self) -> Vec<OutputFormat> {
|
||||
OutputFormat::ALL.to_vec()
|
||||
}
|
||||
fn export(&self, job: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
|
||||
self.jobs.lock().unwrap().push(job.path.to_string());
|
||||
let comp = job.project.comp(job.item.comp).ok_or("no comp")?;
|
||||
let paths: Vec<String> = if job.item.output.format.is_sequence() {
|
||||
let first = job.item.settings.first_frame(comp);
|
||||
(0..job.item.settings.frame_count(comp)).map(|k| sequence_path(job.path, first + k as i64)).collect()
|
||||
} else {
|
||||
vec![job.path.to_string()]
|
||||
};
|
||||
for path in &paths {
|
||||
if job.item.settings.skip_existing && Path::new(path).exists() {
|
||||
continue;
|
||||
}
|
||||
if let Some(dir) = Path::new(path).parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(path, RENDERED).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(ExportResult { path: paths.first().cloned().unwrap_or_default(), ..Default::default() })
|
||||
}
|
||||
}
|
||||
|
||||
/// A session whose project is saved in a fresh folder (the footage goes there too), with an
|
||||
/// exporter that writes real files.
|
||||
struct Fixture {
|
||||
s: Session,
|
||||
writer: Arc<Writer>,
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new(name: &str) -> Fixture {
|
||||
let dir = std::env::temp_dir().join("effectcraft-render-overwrite").join(format!("{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut s = Session::default();
|
||||
let writer = Arc::new(Writer::default());
|
||||
s.exporter = Some(writer.clone());
|
||||
s.execute("file.saveAs", json!({"path": dir.join("Project.ecproj").to_string_lossy()})).unwrap();
|
||||
Fixture { s, writer, dir }
|
||||
}
|
||||
|
||||
fn path(&self, file: &str) -> PathBuf {
|
||||
self.dir.join(file)
|
||||
}
|
||||
|
||||
fn text(&self, file: &str) -> String {
|
||||
self.path(file).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn read(&self, file: &str) -> Vec<u8> {
|
||||
std::fs::read(self.path(file)).unwrap()
|
||||
}
|
||||
|
||||
fn write(&self, file: &str, bytes: &[u8]) {
|
||||
std::fs::write(self.path(file), bytes).unwrap();
|
||||
}
|
||||
|
||||
/// A video file in the project's folder, imported.
|
||||
fn footage(&mut self, file: &str) -> ItemId {
|
||||
self.write(file, SOURCE);
|
||||
let f = Footage {
|
||||
path: self.text(file),
|
||||
kind: FootageKind::Video,
|
||||
width: 64,
|
||||
height: 36,
|
||||
has_video: true,
|
||||
duration: Tick::from_seconds_f64(1.0),
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut self.s.project).add_item(file, Label::None, None, ItemKind::Footage(f))
|
||||
}
|
||||
|
||||
/// Numbered stills in the project's folder, imported as one image sequence.
|
||||
fn sequence(&mut self, name: &str, frames: std::ops::Range<u32>) -> ItemId {
|
||||
let files: Vec<String> = frames
|
||||
.map(|n| {
|
||||
let file = format!("{name}_{n:05}.png");
|
||||
self.write(&file, SOURCE);
|
||||
self.text(&file)
|
||||
})
|
||||
.collect();
|
||||
let f = Footage {
|
||||
path: files[0].clone(),
|
||||
kind: FootageKind::Sequence,
|
||||
sequence: files,
|
||||
width: 16,
|
||||
height: 16,
|
||||
has_video: true,
|
||||
duration: Tick::from_seconds_f64(0.5),
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut self.s.project).add_item(&format!("{name}_[00000-00004].png"), Label::None, None, ItemKind::Footage(f))
|
||||
}
|
||||
|
||||
/// A five-frame composition.
|
||||
fn comp(&mut self, name: &str) {
|
||||
self.s.execute("comp.new", json!({"name": name, "width": 16, "height": 16, "frameRate": 10, "duration": 0.5})).unwrap();
|
||||
}
|
||||
|
||||
/// The output path of queue item `index` (0-based).
|
||||
fn output(&self, index: usize) -> PathBuf {
|
||||
PathBuf::from(self.s.resolve_output(&self.s.project.render_queue[index]).unwrap())
|
||||
}
|
||||
|
||||
/// The paths the exporter was started for.
|
||||
fn exported(&self) -> Vec<String> {
|
||||
self.writer.jobs.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn render(&mut self, params: Value) -> crate::Result<Value> {
|
||||
self.s.execute("renderQueue.render", params)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn link(target: &Path, link: &Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
return std::os::unix::fs::symlink(target, link);
|
||||
#[cfg(windows)]
|
||||
return std::os::windows::fs::symlink_file(target, link);
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = (target, link);
|
||||
Err(std::io::ErrorKind::Unsupported.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder that leads to `target`: a symbolic link, or on Windows, where that takes a privilege
|
||||
/// most accounts lack, a junction.
|
||||
fn link_dir(target: &Path, link: &Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
return std::os::unix::fs::symlink(target, link);
|
||||
#[cfg(windows)]
|
||||
return std::os::windows::fs::symlink_dir(target, link).or_else(|_| {
|
||||
let made = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]).arg(link).arg(target).output()?;
|
||||
if made.status.success() { Ok(()) } else { Err(std::io::Error::other(String::from_utf8_lossy(&made.stdout).into_owned())) }
|
||||
});
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = (target, link);
|
||||
Err(std::io::ErrorKind::Unsupported.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// The report: save a new project beside `footage.mp4`, import it, make a composition from it
|
||||
/// (it is named after the file), add that to the Render Queue and click Render. The composition's
|
||||
/// default output is `<project folder>/footage.mp4`, the footage itself.
|
||||
#[test]
|
||||
fn a_comp_made_from_footage_does_not_render_over_it() {
|
||||
let mut f = Fixture::new("comp-from-footage");
|
||||
let footage = f.footage("footage.mp4");
|
||||
f.s.state.project_selection = vec![footage];
|
||||
f.s.execute("file.newCompFromSelection", json!({"addToRenderQueue": true})).unwrap();
|
||||
assert_eq!(f.s.project.render_queue.len(), 1);
|
||||
f.render(json!({})).unwrap();
|
||||
assert_eq!(f.read("footage.mp4"), SOURCE, "the footage is still the footage");
|
||||
assert_eq!(f.s.project.render_queue[0].status, RenderStatus::Done);
|
||||
// The default name stepped aside: the render sits next to it.
|
||||
assert_eq!(f.output(0), f.path("footage_1.mp4"));
|
||||
assert_eq!(f.read("footage_1.mp4"), RENDERED);
|
||||
// Another item for the same composition takes the next free number.
|
||||
f.s.execute("renderQueue.add", json!({})).unwrap();
|
||||
assert_eq!(f.output(1), f.path("footage_2.mp4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_output_that_is_the_footage_is_refused_with_or_without_overwrite() {
|
||||
for (n, params) in [json!({}), json!({"overwrite": true})].into_iter().enumerate() {
|
||||
let mut f = Fixture::new(&format!("explicit-footage-{n}"));
|
||||
f.footage("clip.mov");
|
||||
f.comp("Main");
|
||||
let clip = f.text("clip.mov");
|
||||
f.s.execute("renderQueue.add", json!({"format": "prores", "output": clip})).unwrap();
|
||||
let e = f.render(params).unwrap_err().to_string();
|
||||
assert!(e.contains("clip.mov") && e.contains("footage"), "{e}");
|
||||
assert_eq!(f.read("clip.mov"), SOURCE);
|
||||
assert!(f.exported().is_empty(), "the exporter was not started");
|
||||
let RenderStatus::Failed(why) = &f.s.project.render_queue[0].status else { panic!("{:?}", f.s.project.render_queue[0].status) };
|
||||
assert!(why.contains("clip.mov"), "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An item that would replace the footage fails on its own; the rest of the queue renders.
|
||||
#[test]
|
||||
fn the_rest_of_the_queue_renders_when_one_item_would_replace_footage() {
|
||||
let mut f = Fixture::new("one-refused");
|
||||
f.footage("clip.mov");
|
||||
f.comp("Main");
|
||||
let clip = f.text("clip.mov");
|
||||
f.s.execute("renderQueue.add", json!({"format": "prores", "output": clip})).unwrap();
|
||||
let ok = f.text("main.mp4");
|
||||
f.s.execute("renderQueue.add", json!({"output": ok})).unwrap();
|
||||
let r = f.render(json!({})).unwrap();
|
||||
assert_eq!(f.read("clip.mov"), SOURCE);
|
||||
assert_eq!(f.read("main.mp4"), RENDERED);
|
||||
assert!(matches!(f.s.project.render_queue[0].status, RenderStatus::Failed(_)));
|
||||
assert_eq!(f.s.project.render_queue[1].status, RenderStatus::Done);
|
||||
assert!(r["items"].as_array().unwrap().iter().any(|i| i["status"].get("Failed").is_some()), "the reply includes the refused item: {r}");
|
||||
assert!(r["items"].as_array().unwrap().iter().any(|i| i["status"] == "Done"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_project_file_is_never_an_output() {
|
||||
let mut f = Fixture::new("project-file");
|
||||
// A project saved under a name a movie could have.
|
||||
let name = f.text("final.mp4");
|
||||
f.s.execute("file.saveAs", json!({"path": name})).unwrap();
|
||||
f.comp("final");
|
||||
f.s.execute("renderQueue.add", json!({})).unwrap();
|
||||
assert_eq!(f.output(0), f.path("final_1.mp4"), "the default name moves aside");
|
||||
f.s.execute("renderQueue.remove", json!({"index": 1})).unwrap();
|
||||
f.s.execute("renderQueue.add", json!({"output": name})).unwrap();
|
||||
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("final.mp4") && e.contains("project"), "{e}");
|
||||
assert!(std::fs::read_to_string(f.path("final.mp4")).unwrap().contains("\"schema\""), "the project file is still a project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proxy_is_not_an_output_either() {
|
||||
let mut f = Fixture::new("proxy");
|
||||
f.footage("clip.mov");
|
||||
f.write("proxy.mov", SOURCE);
|
||||
f.comp("Main");
|
||||
let proxy = Footage { path: f.text("proxy.mov"), kind: FootageKind::Video, has_video: true, ..Default::default() };
|
||||
let comp = f.s.active_comp_id().unwrap();
|
||||
Arc::make_mut(&mut f.s.project).item_mut(comp).unwrap().proxy = Some(Box::new(effectcraft_project::Proxy { footage: proxy, enabled: true }));
|
||||
let target = f.text("proxy.mov");
|
||||
f.s.execute("renderQueue.add", json!({"format": "prores", "output": target})).unwrap();
|
||||
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("proxy"), "{e}");
|
||||
assert_eq!(f.read("proxy.mov"), SOURCE);
|
||||
}
|
||||
|
||||
/// Recreating a default proxy writes a new file and attaches it, without replacing the proxy
|
||||
/// the project is currently reading. Explicitly choosing that protected path is still refused.
|
||||
#[test]
|
||||
fn creating_a_proxy_again_chooses_a_new_default_and_attaches_it() {
|
||||
struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
if !Path::new(path).is_file() {
|
||||
return Err("proxy file missing".into());
|
||||
}
|
||||
Ok(Footage {
|
||||
path: path.into(),
|
||||
kind: if path.ends_with(".png") { FootageKind::Still } else { FootageKind::Video },
|
||||
width: 16,
|
||||
height: 16,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
for kind in ["movie", "still"] {
|
||||
let mut f = Fixture::new(&format!("recreate-proxy-{kind}"));
|
||||
f.s.importer = Some(Arc::new(Probe));
|
||||
f.comp("Main");
|
||||
let comp = f.s.active_comp_id().unwrap();
|
||||
f.s.execute("file.createProxy", json!({"kind": kind})).unwrap();
|
||||
f.render(json!({})).unwrap();
|
||||
let first = f.s.project.item(comp).unwrap().proxy.as_ref().unwrap().footage.path.clone();
|
||||
assert_eq!(std::fs::read(&first).unwrap(), RENDERED);
|
||||
|
||||
f.s.execute("file.createProxy", json!({"kind": kind})).unwrap();
|
||||
f.render(json!({})).unwrap();
|
||||
let second = f.s.project.item(comp).unwrap().proxy.as_ref().unwrap().footage.path.clone();
|
||||
assert_ne!(second, first, "the replacement proxy gets its own file");
|
||||
assert_eq!(std::fs::read(&second).unwrap(), RENDERED);
|
||||
assert_eq!(std::fs::read(&first).unwrap(), RENDERED);
|
||||
assert_eq!(f.exported().len(), 2);
|
||||
|
||||
// For a still proxy, an explicit output is the sequence pattern, not its first
|
||||
// rendered frame (a bare PNG name would have another frame number appended).
|
||||
let protected_output = f.output(1);
|
||||
f.s.execute("file.createProxy", json!({"kind": kind, "path": protected_output})).unwrap();
|
||||
let error = f.render(json!({})).unwrap_err().to_string();
|
||||
assert!(error.contains("never replaces a proxy"), "{error}");
|
||||
assert_eq!(f.exported().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
/// A pre-render becomes imported footage. Its old output stays protected even from the
|
||||
/// same queue item; issuing Pre-render again supplies a fresh default output instead.
|
||||
#[test]
|
||||
fn refreshing_a_pre_render_uses_a_new_default_without_exempting_imported_footage() {
|
||||
struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
if !Path::new(path).is_file() {
|
||||
return Err("pre-render missing".into());
|
||||
}
|
||||
Ok(Footage { path: path.into(), kind: FootageKind::Video, width: 16, height: 16, has_video: true, ..Default::default() })
|
||||
}
|
||||
}
|
||||
let mut f = Fixture::new("refresh-pre-render");
|
||||
f.s.importer = Some(Arc::new(Probe));
|
||||
f.comp("Main");
|
||||
f.s.execute("render.preRender", json!({"format": "gif"})).unwrap();
|
||||
let first = f.output(0);
|
||||
f.render(json!({})).unwrap();
|
||||
f.s.execute("renderQueue.setRender", json!({"index": 1, "render": true})).unwrap();
|
||||
let error = f.render(json!({})).unwrap_err().to_string();
|
||||
assert!(error.contains("footage"), "{error}");
|
||||
assert_eq!(std::fs::read(&first).unwrap(), RENDERED);
|
||||
assert_eq!(f.exported().len(), 1);
|
||||
f.s.execute("renderQueue.setRender", json!({"index": 1, "render": false})).unwrap();
|
||||
f.s.execute("render.preRender", json!({"format": "gif"})).unwrap();
|
||||
let second = f.output(1);
|
||||
assert_ne!(first, second);
|
||||
f.render(json!({})).unwrap();
|
||||
assert_eq!(std::fs::read(first).unwrap(), RENDERED);
|
||||
assert_eq!(std::fs::read(second).unwrap(), RENDERED);
|
||||
assert_eq!(f.exported().len(), 2);
|
||||
}
|
||||
|
||||
/// Exercise the engine's import and project serialization paths, rather than injecting a
|
||||
/// Footage item directly. The importer reads dimensions from a real, generated PNG.
|
||||
#[test]
|
||||
fn imported_footage_stays_protected_after_the_project_is_reopened() {
|
||||
struct PngProbe;
|
||||
impl crate::Importer for PngProbe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
let (width, height) = image::image_dimensions(path).map_err(|e| e.to_string())?;
|
||||
Ok(Footage { path: path.into(), kind: FootageKind::Still, width, height, has_video: true, ..Default::default() })
|
||||
}
|
||||
}
|
||||
let mut f = Fixture::new("import-reopen");
|
||||
f.s.importer = Some(Arc::new(PngProbe));
|
||||
let path = f.path("plate_00002.png");
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([40, 80, 120, 255])).save(&path).unwrap();
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
let result = f.s.execute("file.import", json!({"paths": [path]})).unwrap();
|
||||
assert_eq!(result["items"].as_array().unwrap().len(), 1);
|
||||
f.comp("Main");
|
||||
f.s.execute("renderQueue.add", json!({"format": "png", "output": f.text("plate_[#####].png")})).unwrap();
|
||||
f.s.execute("file.save", json!({})).unwrap();
|
||||
f.s.execute("file.open", json!({"path": f.text("Project.ecproj")})).unwrap();
|
||||
let error = f.render(json!({})).unwrap_err().to_string();
|
||||
assert!(error.contains("plate_00002.png") && error.contains("footage"), "{error}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
assert!(f.exported().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_protected_frame_past_the_probe_limit_is_not_rendered() {
|
||||
struct LastFrame(PathBuf);
|
||||
impl Exporter for LastFrame {
|
||||
fn formats(&self) -> Vec<OutputFormat> {
|
||||
vec![OutputFormat::PngSequence]
|
||||
}
|
||||
fn export(&self, _: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
|
||||
// A synthetic exporter writes only the vulnerable late frame, so a broken guard
|
||||
// can be reproduced without creating 100,001 output directories.
|
||||
std::fs::write(&self.0, RENDERED).map_err(|e| e.to_string())?;
|
||||
Ok(ExportResult { path: self.0.to_string_lossy().into_owned(), ..Default::default() })
|
||||
}
|
||||
}
|
||||
let mut f = Fixture::new("late-frame");
|
||||
std::fs::create_dir_all(f.path("f100000")).unwrap();
|
||||
f.footage("f100000/plate.png");
|
||||
f.comp("Main");
|
||||
f.s.execute("comp.settings", json!({"duration": 10000.1})).unwrap();
|
||||
f.s.exporter = Some(Arc::new(LastFrame(f.path("f100000/plate.png"))));
|
||||
f.s.execute("renderQueue.add", json!({"format": "png", "output": f.text("f[######]/plate.png")})).unwrap();
|
||||
let error = f.render(json!({})).unwrap_err().to_string();
|
||||
assert!(error.contains("cannot safely check"), "{error}");
|
||||
assert_eq!(f.read("f100000/plate.png"), SOURCE);
|
||||
}
|
||||
|
||||
/// A numbered sequence is footage frame by frame: one colliding frame is enough, and nothing of
|
||||
/// the item is rendered.
|
||||
#[test]
|
||||
fn a_sequence_never_replaces_one_of_its_footage_frames() {
|
||||
let mut f = Fixture::new("sequence");
|
||||
f.sequence("shot", 0..5);
|
||||
f.comp("shot");
|
||||
// The default name steps aside as a whole: shot_1_00000.png …
|
||||
f.s.execute("renderQueue.add", json!({"format": "png"})).unwrap();
|
||||
assert_eq!(f.output(0), f.path("shot_1_[#####].png"));
|
||||
f.render(json!({})).unwrap();
|
||||
for n in 0..5 {
|
||||
assert_eq!(f.read(&format!("shot_{n:05}.png")), SOURCE, "frame {n}");
|
||||
assert_eq!(f.read(&format!("shot_1_{n:05}.png")), RENDERED, "frame {n}");
|
||||
}
|
||||
|
||||
// Named by hand, one frame of five is a footage file.
|
||||
let mut f = Fixture::new("sequence-one-frame");
|
||||
f.write("shot_00002.png", SOURCE);
|
||||
let still = Footage { path: f.text("shot_00002.png"), kind: FootageKind::Still, width: 16, height: 16, has_video: true, ..Default::default() };
|
||||
Arc::make_mut(&mut f.s.project).add_item("shot_00002.png", Label::None, None, ItemKind::Footage(still));
|
||||
f.comp("shot");
|
||||
let pattern = f.text("shot_[#####].png");
|
||||
f.s.execute("renderQueue.add", json!({"format": "png", "output": pattern})).unwrap();
|
||||
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("shot_00002.png"), "{e}");
|
||||
assert_eq!(f.read("shot_00002.png"), SOURCE);
|
||||
assert!(!f.path("shot_00000.png").exists(), "the frames before it were not rendered either");
|
||||
assert!(f.exported().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_existing_outputs_keep_the_exporters_replacement_policy() {
|
||||
let mut f = Fixture::new("earlier-render");
|
||||
f.comp("Main");
|
||||
f.s.execute("renderQueue.add", json!({"output": f.text("render.mp4")})).unwrap();
|
||||
f.write("render.mp4", EARLIER);
|
||||
f.render(json!({})).unwrap();
|
||||
assert_eq!(f.read("render.mp4"), RENDERED);
|
||||
assert_eq!(f.s.project.render_queue[0].status, RenderStatus::Done);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_render_with_nothing_in_the_way_needs_no_flag() {
|
||||
let mut f = Fixture::new("free");
|
||||
f.comp("Main");
|
||||
f.s.execute("renderQueue.add", json!({})).unwrap();
|
||||
assert_eq!(f.output(0), f.path("Main.mp4"), "no footage in the way: the comp's own name");
|
||||
f.render(json!({})).unwrap();
|
||||
assert_eq!(f.read("Main.mp4"), RENDERED);
|
||||
}
|
||||
|
||||
/// Skip Existing Files leaves the frames that exist alone, so they need no answer.
|
||||
#[test]
|
||||
fn a_sequence_that_skips_existing_frames_needs_no_flag() {
|
||||
let mut f = Fixture::new("skip-existing");
|
||||
f.comp("Main");
|
||||
let pattern = f.text("render_[#####].png");
|
||||
f.s.execute("renderQueue.add", json!({"format": "png", "output": pattern, "skipExisting": true})).unwrap();
|
||||
for n in 0..3 {
|
||||
f.write(&format!("render_{n:05}.png"), EARLIER);
|
||||
}
|
||||
f.render(json!({})).unwrap();
|
||||
assert_eq!(f.read("render_00002.png"), EARLIER);
|
||||
assert_eq!(f.read("render_00003.png"), RENDERED);
|
||||
// Ordinary files still follow the exporter's replacement policy when skipping is off.
|
||||
f.s.execute("renderQueue.setRenderSettings", json!({"index": 1, "skipExisting": false})).unwrap();
|
||||
f.render(json!({})).unwrap();
|
||||
assert_eq!(f.read("render_00002.png"), RENDERED);
|
||||
}
|
||||
|
||||
/// A link is another name for the file it points to.
|
||||
#[test]
|
||||
fn a_link_to_the_footage_is_the_footage() {
|
||||
let mut f = Fixture::new("link");
|
||||
f.footage("clip.mov");
|
||||
f.comp("Main");
|
||||
let alias = f.path("render.mov");
|
||||
if link(&f.path("clip.mov"), &alias).is_err() {
|
||||
eprintln!("this account cannot make symbolic links: skipped");
|
||||
return;
|
||||
}
|
||||
f.s.execute("renderQueue.add", json!({"format": "prores", "output": f.text("render.mov")})).unwrap();
|
||||
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("render.mov") && e.contains("footage"), "{e}");
|
||||
assert_eq!(f.read("clip.mov"), SOURCE);
|
||||
assert!(std::fs::symlink_metadata(&alias).unwrap().file_type().is_symlink(), "the link was not replaced either");
|
||||
}
|
||||
|
||||
/// A hard link to the footage under another name is the same file on disk: replacing it would truncate the footage.
|
||||
#[test]
|
||||
fn a_hard_link_to_the_footage_is_the_footage() {
|
||||
let mut f = Fixture::new("hardlink");
|
||||
f.footage("clip.mov");
|
||||
f.comp("Main");
|
||||
if std::fs::hard_link(f.path("clip.mov"), f.path("render.mov")).is_err() {
|
||||
eprintln!("this volume cannot make hard links: skipped");
|
||||
return;
|
||||
}
|
||||
f.s.execute("renderQueue.add", json!({"format": "prores", "output": f.text("render.mov")})).unwrap();
|
||||
// not even with overwrite
|
||||
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("render.mov") && e.contains("footage"), "{e}");
|
||||
assert_eq!(f.read("clip.mov"), SOURCE);
|
||||
assert_eq!(f.read("render.mov"), SOURCE, "the other name still leads to the footage");
|
||||
assert!(f.exported().is_empty(), "nothing was rendered");
|
||||
}
|
||||
|
||||
/// A copy of the footage has its size and bytes, but it is another file: an earlier render may replace it.
|
||||
#[test]
|
||||
fn a_copy_of_the_footage_is_not_the_footage() {
|
||||
let mut f = Fixture::new("copy");
|
||||
f.footage("clip.mov");
|
||||
f.comp("Main");
|
||||
std::fs::copy(f.path("clip.mov"), f.path("copy.mov")).unwrap();
|
||||
f.s.execute("renderQueue.add", json!({"format": "prores", "output": f.text("copy.mov")})).unwrap();
|
||||
f.render(json!({})).unwrap();
|
||||
assert_eq!(f.read("copy.mov"), RENDERED);
|
||||
assert_eq!(f.read("clip.mov"), SOURCE, "the footage is as it was");
|
||||
}
|
||||
|
||||
/// Footage imported through a linked folder is the same file as the one in the real folder.
|
||||
#[test]
|
||||
fn footage_reached_through_a_linked_folder_is_still_footage() {
|
||||
let mut f = Fixture::new("linked-folder");
|
||||
std::fs::create_dir_all(f.path("real")).unwrap();
|
||||
std::fs::write(f.path("real").join("clip.mov"), SOURCE).unwrap();
|
||||
if link_dir(&f.path("real"), &f.path("linked")).is_err() {
|
||||
eprintln!("this account cannot link folders: skipped");
|
||||
return;
|
||||
}
|
||||
let via_link =
|
||||
Footage { path: f.path("linked").join("clip.mov").to_string_lossy().into_owned(), kind: FootageKind::Video, has_video: true, ..Default::default() };
|
||||
Arc::make_mut(&mut f.s.project).add_item("clip.mov", Label::None, None, ItemKind::Footage(via_link));
|
||||
f.comp("Main");
|
||||
f.s.execute("renderQueue.add", json!({"format": "prores", "output": f.path("real").join("clip.mov").to_string_lossy()})).unwrap();
|
||||
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("footage"), "{e}");
|
||||
assert_eq!(std::fs::read(f.path("real").join("clip.mov")).unwrap(), SOURCE);
|
||||
}
|
||||
|
||||
/// Where the volume ignores case, `CLIP.MOV` is `clip.mov`.
|
||||
#[test]
|
||||
fn another_spelling_of_the_footage_name_is_the_footage_where_the_volume_ignores_case() {
|
||||
let mut f = Fixture::new("case");
|
||||
f.footage("clip.mov");
|
||||
f.comp("Main");
|
||||
if !f.path("CLIP.MOV").exists() {
|
||||
eprintln!("this volume tells the two names apart: skipped");
|
||||
return;
|
||||
}
|
||||
let shouting = f.text("CLIP.MOV");
|
||||
f.s.execute("renderQueue.add", json!({"format": "prores", "output": shouting})).unwrap();
|
||||
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("footage"), "{e}");
|
||||
assert_eq!(f.read("clip.mov"), SOURCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_current_preview_never_replaces_footage() {
|
||||
let mut f = Fixture::new("preview");
|
||||
f.footage("clip.mov");
|
||||
f.comp("Main");
|
||||
let clip = f.text("clip.mov");
|
||||
let e = f.s.execute("render.saveCurrentPreview", json!({"path": clip})).unwrap_err().to_string();
|
||||
assert!(e.contains("clip.mov") && e.contains("footage"), "{e}");
|
||||
assert_eq!(f.read("clip.mov"), SOURCE);
|
||||
assert!(f.exported().is_empty());
|
||||
// Anywhere else it renders.
|
||||
let elsewhere = f.text("preview.mp4");
|
||||
f.s.execute("render.saveCurrentPreview", json!({"path": elsewhere})).unwrap();
|
||||
assert_eq!(f.read("preview.mp4"), RENDERED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_current_preview_checks_sequence_frames_as_well_as_the_pattern() {
|
||||
let mut f = Fixture::new("preview-sequence");
|
||||
f.sequence("plate", 0..5);
|
||||
f.comp("Main");
|
||||
let error = f.s.execute("render.saveCurrentPreview", json!({"path": f.text("plate_[#####].png")})).unwrap_err().to_string();
|
||||
assert!(error.contains("plate_000") && error.contains("footage"), "{error}");
|
||||
for frame in 0..5 {
|
||||
assert_eq!(f.read(&format!("plate_{frame:05}.png")), SOURCE);
|
||||
}
|
||||
assert!(f.exported().is_empty());
|
||||
}
|
||||
|
||||
/// A watched folder renders unattended; delete the status file to render a project again.
|
||||
#[test]
|
||||
fn a_watched_folder_still_renders_a_project_again_over_its_outputs() {
|
||||
let mut f = Fixture::new("watch");
|
||||
f.comp("Main");
|
||||
let out = f.text("Main.mp4");
|
||||
f.s.execute("renderQueue.add", json!({"output": out})).unwrap();
|
||||
f.s.execute("file.save", json!({})).unwrap();
|
||||
f.write("Main.mp4", EARLIER);
|
||||
let folder = f.dir.to_string_lossy().into_owned();
|
||||
let done = crate::commands::watch_folder::poll(&mut f.s, &folder).unwrap();
|
||||
assert_eq!(done.len(), 1, "{done:?}");
|
||||
assert_eq!(done[0]["state"], "done", "{:?}", done[0]);
|
||||
assert_eq!(f.read("Main.mp4"), RENDERED);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! Scale Composition (`comp.scale`, #2208): the comp and everything in it grow or shrink by one
|
||||
//! factor, so the frame looks the same at the new size.
|
||||
|
||||
use effectcraft_project::{Layer, LayerId};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn comp(w: u32, h: u32) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": w, "height": h, "frameRate": 10, "duration": 2})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn solid(s: &mut Session, color: &str, w: u32, h: u32) -> u64 {
|
||||
s.execute("layer.newSolid", json!({"color": color, "width": w, "height": h})).unwrap()["layer"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn layer(s: &Session, id: u64) -> Layer {
|
||||
s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
|
||||
}
|
||||
|
||||
fn v3(s: &Session, id: u64, path: &str, t: f64) -> [f64; 3] {
|
||||
layer(s, id).props.prop(path).unwrap().value_at(Tick::from_seconds_f64(t)).as_vec3()
|
||||
}
|
||||
|
||||
/// Opaque pixels of `color_channel` (0 = red, 1 = green) at `t`: (x0, y0, x1, y1, count).
|
||||
fn bounds(s: &Session, t: f64, channel: usize) -> (i64, i64, i64, i64, u32) {
|
||||
let img = s.render(s.active_comp_id().unwrap(), Tick::from_seconds_f64(t), Default::default());
|
||||
let (mut x0, mut y0, mut x1, mut y1, mut n) = (i64::MAX, i64::MAX, i64::MIN, i64::MIN, 0);
|
||||
for y in 0..img.height as i64 {
|
||||
for x in 0..img.width as i64 {
|
||||
let p = img.get(x, y);
|
||||
if p[3] > 0.5 && p[channel] > 0.5 {
|
||||
n += 1;
|
||||
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
|
||||
}
|
||||
}
|
||||
}
|
||||
(x0, y0, x1, y1, n)
|
||||
}
|
||||
|
||||
/// Twice as large on every side, from a pixel-exact frame.
|
||||
fn doubled(b: (i64, i64, i64, i64, u32)) -> (i64, i64, i64, i64, u32) {
|
||||
(b.0 * 2, b.1 * 2, b.2 * 2 + 1, b.3 * 2 + 1, b.4 * 4)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_comp_doubles_the_frame_with_keys_and_parented_layers() {
|
||||
let mut s = comp(200, 100);
|
||||
// A red solid moving right (two Position keys), and a green solid parented to a null.
|
||||
let red = solid(&mut s, "#ff0000", 20, 10);
|
||||
s.execute("prop.addKey", json!({"layer": red, "path": "transform/position", "time": 0, "value": [30, 20]})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": red, "path": "transform/position", "time": 1, "value": [70, 20]})).unwrap();
|
||||
let green = solid(&mut s, "#00ff00", 10, 10);
|
||||
let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": rig, "path": "transform/position", "value": [150, 60]})).unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [green], "parent": rig})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": green, "path": "transform/position", "value": [10, 15]})).unwrap();
|
||||
let before = s.project.clone();
|
||||
let (red0, red1, green0) = (bounds(&s, 0.0, 0), bounds(&s, 1.0, 0), bounds(&s, 0.0, 1));
|
||||
assert_eq!(red0, (20, 15, 39, 24, 200));
|
||||
|
||||
let r = s.execute("comp.scale", json!({"factor": 2})).unwrap();
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(400), Some(200)));
|
||||
let c = s.active_comp().unwrap();
|
||||
assert_eq!((c.width, c.height), (400, 200));
|
||||
// Every key moves, not only the value at the current time.
|
||||
assert_eq!(bounds(&s, 0.0, 0), doubled(red0));
|
||||
assert_eq!(bounds(&s, 1.0, 0), doubled(red1));
|
||||
assert_eq!(v3(&s, red, "transform/scale", 1.0), [200.0; 3]);
|
||||
// The child keeps its own transform: its scaled parent carries it.
|
||||
assert_eq!(bounds(&s, 0.0, 1), doubled(green0));
|
||||
assert_eq!(v3(&s, green, "transform/position", 0.0)[..2], [10.0, 15.0]);
|
||||
assert_eq!(v3(&s, green, "transform/scale", 0.0), [100.0; 3]);
|
||||
let after = s.project.clone();
|
||||
|
||||
// One undo step, and one redo step.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.project == before, "undo restores the comp and every layer");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert!(s.project == after, "redo scales the comp and every layer again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_comp_to_a_width_keeps_the_aspect_and_scales_cameras_3d_and_guides() {
|
||||
let mut s = comp(640, 360);
|
||||
let card = solid(&mut s, "#ff0000", 100, 100);
|
||||
s.execute("layer.setSwitch", json!({"layers": [card], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": card, "path": "transform/position", "value": [100, 50, 200]})).unwrap();
|
||||
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("view.addGuide", json!({"orientation": "vertical", "position": 160})).unwrap();
|
||||
s.execute("view.addGuide", json!({"orientation": "horizontal", "position": 25, "percent": true})).unwrap();
|
||||
let zoom = |s: &Session| layer(s, cam).props.prop("cameraOptions/zoom").unwrap().value.as_f64();
|
||||
let (zoom0, cam0, poi0) = (zoom(&s), v3(&s, cam, "transform/position", 0.0), v3(&s, cam, "transform/poi", 0.0));
|
||||
let seen0 = bounds(&s, 0.0, 0);
|
||||
s.execute("view.setRegionOfInterest", json!({"rect": [10, 20, 70, 70]})).unwrap();
|
||||
|
||||
let r = s.execute("comp.scale", json!({"width": 960})).unwrap();
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64(), r["factor"].as_f64()), (Some(960), Some(540), Some(1.5)));
|
||||
assert_eq!(v3(&s, card, "transform/position", 0.0), [150.0, 75.0, 300.0]);
|
||||
assert_eq!(v3(&s, card, "transform/scale", 0.0), [150.0; 3]);
|
||||
assert!((zoom(&s) - zoom0 * 1.5).abs() < 1e-9, "the camera sees the same frame");
|
||||
assert_eq!(v3(&s, cam, "transform/position", 0.0), cam0.map(|v| v * 1.5));
|
||||
assert_eq!(v3(&s, cam, "transform/poi", 0.0), poi0.map(|v| v * 1.5));
|
||||
// Through the camera the card covers the same part of the frame.
|
||||
let seen = bounds(&s, 0.0, 0);
|
||||
let near = |a: i64, b: i64| (a as f64 - b as f64 * 1.5).abs() <= 2.0;
|
||||
assert!(near(seen.0, seen0.0) && near(seen.1, seen0.1) && near(seen.2, seen0.2) && near(seen.3, seen0.3), "{seen0:?} → {seen:?}");
|
||||
let c = s.active_comp().unwrap();
|
||||
assert_eq!(c.guides[0].px(c.width, c.height), 240.0);
|
||||
assert_eq!(c.guides[1].position, 25.0, "a percentage guide keeps its percent");
|
||||
assert_eq!(s.state.region_of_interest, Some([15.0, 30.0, 105.0, 105.0]), "the region of interest covers the same part");
|
||||
|
||||
// A height alone works the same way.
|
||||
let r = s.execute("comp.scale", json!({"height": 270})).unwrap();
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(480), Some(270)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_comp_scales_depth_under_a_2d_parent_and_children_of_a_camera() {
|
||||
let mut s = comp(200, 100);
|
||||
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
// A 3D card under a 2D null: the null's Scale carries X and Y but not Z.
|
||||
let card = solid(&mut s, "#ff0000", 20, 10);
|
||||
let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": rig, "path": "transform/position", "value": [100, 50]})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [card], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [card], "parent": rig})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": card, "path": "transform/position", "value": [-40, 0, 500]})).unwrap();
|
||||
// A 3D card parented to the camera, in front of it.
|
||||
let held = solid(&mut s, "#00ff00", 10, 10);
|
||||
s.execute("layer.setSwitch", json!({"layers": [held], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [held], "parent": cam})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": held, "path": "transform/position", "value": [30, 0, 500]})).unwrap();
|
||||
let (card0, held0) = (bounds(&s, 0.0, 0), bounds(&s, 0.0, 1));
|
||||
let (held_pos0, held_scale0) = (v3(&s, held, "transform/position", 0.0), v3(&s, held, "transform/scale", 0.0));
|
||||
assert!(card0.4 > 0 && held0.4 > 0, "both cards are in view: {card0:?} {held0:?}");
|
||||
|
||||
s.execute("comp.scale", json!({"factor": 2})).unwrap();
|
||||
let near = |a: (i64, i64, i64, i64, u32), b: (i64, i64, i64, i64, u32)| {
|
||||
[(a.0, b.0 * 2), (a.1, b.1 * 2), (a.2, b.2 * 2 + 1), (a.3, b.3 * 2 + 1)].iter().all(|&(x, y)| (x - y).abs() <= 2)
|
||||
};
|
||||
let (card1, held1) = (bounds(&s, 0.0, 0), bounds(&s, 0.0, 1));
|
||||
assert!(near(card1, card0), "the card under the 2D null doubles: {card0:?} → {card1:?}");
|
||||
assert_eq!(v3(&s, card, "transform/position", 0.0), [-40.0, 0.0, 1000.0]);
|
||||
assert!(near(held1, held0), "the card held by the camera doubles: {held0:?} → {held1:?}");
|
||||
assert_eq!(v3(&s, held, "transform/position", 0.0), held_pos0.map(|v| v * 2.0));
|
||||
assert_eq!(v3(&s, held, "transform/scale", 0.0), held_scale0.map(|v| v * 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_comp_keeps_the_ease_of_depth_moves_under_a_2d_parent() {
|
||||
let mut s = comp(200, 100);
|
||||
let card = solid(&mut s, "#ff0000", 20, 10);
|
||||
let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [card], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [card], "parent": rig})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": card, "path": "transform/position", "time": 0, "value": [0, 0, 0]})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": card, "path": "transform/position", "time": 1, "value": [0, 0, 100]})).unwrap();
|
||||
s.execute("keys.setEase", json!({"layer": card, "path": "transform/position", "time": 0, "side": "out", "speed": 100, "influence": 50})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": card, "path": "transform/scale", "time": 0, "value": [100, 100, 100]})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": card, "path": "transform/scale", "time": 1, "value": [100, 100, 300]})).unwrap();
|
||||
s.execute("keys.setEase", json!({"layer": card, "path": "transform/scale", "time": 0, "side": "out", "speed": 300, "influence": 50})).unwrap();
|
||||
let (pos0, scale0) = (v3(&s, card, "transform/position", 0.25), v3(&s, card, "transform/scale", 0.25));
|
||||
|
||||
s.execute("comp.scale", json!({"factor": 2})).unwrap();
|
||||
// Twice the distance in the same time: the same ease, every frame twice as deep.
|
||||
let (pos, scale) = (v3(&s, card, "transform/position", 0.25), v3(&s, card, "transform/scale", 0.25));
|
||||
assert!((pos[2] - pos0[2] * 2.0).abs() < 1e-6, "{pos0:?} → {pos:?}");
|
||||
assert!((scale[2] - scale0[2] * 2.0).abs() < 1e-6, "{scale0:?} → {scale:?}");
|
||||
assert_eq!(scale[..2], scale0[..2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_comp_gives_z_its_own_speed_when_a_scale_key_has_one_ease() {
|
||||
let mut s = comp(200, 100);
|
||||
let card = solid(&mut s, "#ff0000", 20, 10);
|
||||
let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [card], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [card], "parent": rig})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": card, "path": "transform/scale", "time": 0, "value": [100, 100, 100]})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": card, "path": "transform/scale", "time": 1, "value": [100, 100, 300]})).unwrap();
|
||||
s.execute("keys.setEase", json!({"layer": card, "path": "transform/scale", "time": 0, "side": "out", "speed": 300, "influence": 50})).unwrap();
|
||||
// One ease for every dimension, as a project file may store it.
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let key =
|
||||
&mut std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(card)).unwrap().props.prop_mut("transform/scale").unwrap().keys
|
||||
[0];
|
||||
key.out_ease.truncate(1);
|
||||
let scale0 = v3(&s, card, "transform/scale", 0.25);
|
||||
|
||||
s.execute("comp.scale", json!({"factor": 2})).unwrap();
|
||||
let scale = v3(&s, card, "transform/scale", 0.25);
|
||||
assert!((scale[2] - scale0[2] * 2.0).abs() < 1e-6, "{scale0:?} → {scale:?}");
|
||||
assert_eq!(scale[..2], scale0[..2], "X and Y keep the shared ease");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_comp_scales_depth_once_through_a_3d_grandparent() {
|
||||
let mut s = comp(200, 100);
|
||||
s.execute("layer.newCamera", json!({})).unwrap();
|
||||
// card (3D) → A (2D null) → B (3D null): B's Scale already carries the depth.
|
||||
let card = solid(&mut s, "#ff0000", 20, 10);
|
||||
let a = s.execute("layer.newNull", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.newNull", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [card, b], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [100, 50, 0]})).unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [a], "parent": b})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [0, 0]})).unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [card], "parent": a})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": card, "path": "transform/position", "value": [-40, 0, 500]})).unwrap();
|
||||
let card0 = bounds(&s, 0.0, 0);
|
||||
assert!(card0.4 > 0, "the card is in view");
|
||||
|
||||
s.execute("comp.scale", json!({"factor": 2})).unwrap();
|
||||
assert_eq!(v3(&s, card, "transform/position", 0.0), [-40.0, 0.0, 500.0]);
|
||||
let card1 = bounds(&s, 0.0, 0);
|
||||
let near =
|
||||
[(card1.0, card0.0 * 2), (card1.1, card0.1 * 2), (card1.2, card0.2 * 2 + 1), (card1.3, card0.3 * 2 + 1)].iter().all(|&(x, y)| (x - y).abs() <= 2);
|
||||
assert!(near, "{card0:?} → {card1:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_comp_scales_once_through_a_camera_under_a_scaled_null() {
|
||||
let mut s = comp(200, 100);
|
||||
// solid → camera → B (3D null): B's Scale reaches the solid through the camera.
|
||||
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.newNull", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [cam], "parent": b})).unwrap();
|
||||
let held = solid(&mut s, "#00ff00", 10, 10);
|
||||
s.execute("layer.setSwitch", json!({"layers": [held], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [held], "parent": cam})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": held, "path": "transform/position", "value": [30, 0, 500]})).unwrap();
|
||||
let held0 = bounds(&s, 0.0, 1);
|
||||
assert!(held0.4 > 0, "the solid is in view: {held0:?}");
|
||||
|
||||
s.execute("comp.scale", json!({"factor": 2})).unwrap();
|
||||
assert_eq!(v3(&s, held, "transform/position", 0.0), [30.0, 0.0, 500.0]);
|
||||
assert_eq!(v3(&s, held, "transform/scale", 0.0), [100.0; 3]);
|
||||
let held1 = bounds(&s, 0.0, 1);
|
||||
let near =
|
||||
[(held1.0, held0.0 * 2), (held1.1, held0.1 * 2), (held1.2, held0.2 * 2 + 1), (held1.3, held0.3 * 2 + 1)].iter().all(|&(x, y)| (x - y).abs() <= 2);
|
||||
assert!(near, "{held0:?} → {held1:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_comp_scales_the_point_of_interest_depth_of_a_camera_under_a_2d_null() {
|
||||
let mut s = comp(200, 100);
|
||||
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [cam], "parent": rig})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [10, 20, -1000]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "transform/poi", "value": [10, 20, 200]})).unwrap();
|
||||
|
||||
s.execute("comp.scale", json!({"factor": 2})).unwrap();
|
||||
// The 2D null carries X and Y; the camera's own Z doubles at both ends, so it looks the same way.
|
||||
assert_eq!(v3(&s, cam, "transform/position", 0.0), [10.0, 20.0, -2000.0]);
|
||||
assert_eq!(v3(&s, cam, "transform/poi", 0.0), [10.0, 20.0, 400.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_comp_refuses_a_bad_factor_and_changes_nothing() {
|
||||
let mut s = comp(640, 360);
|
||||
solid(&mut s, "#ff0000", 100, 100);
|
||||
let before = s.project.clone();
|
||||
let cases = [
|
||||
(json!({}), "pass `factor`"),
|
||||
(json!({"factor": 0}), "4 to 30,000 px"),
|
||||
(json!({"factor": -2}), "4 to 30,000 px"),
|
||||
(json!({"width": 0}), "4 to 30,000 px"),
|
||||
(json!({"factor": 100}), "4 to 30,000 px"),
|
||||
(json!({"factor": 0.001}), "4 to 30,000 px"),
|
||||
(json!({"factor": 2, "width": 960}), "only one of"),
|
||||
(json!({"width": 960, "height": 360}), "only one of"),
|
||||
];
|
||||
for (p, why) in cases {
|
||||
match s.execute("comp.scale", p.clone()) {
|
||||
Err(EngineError::BadParams { cmd, msg }) => assert!(cmd == "comp.scale" && msg.contains(why), "{p}: {msg}"),
|
||||
other => panic!("{p} is refused as bad parameters, got {other:?}"),
|
||||
}
|
||||
assert!(s.project == before, "{p} leaves the project alone");
|
||||
}
|
||||
s.execute("comp.scale", json!({"factor": 1})).unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user