567 lines
33 KiB
Rust
567 lines
33 KiB
Rust
//! A fully wired [`Session`]: footage decoding through `effectcraft-media` (FilmCraft's codecs),
|
|
//! the media importer, the expression engine, JavaScript scripting (`effectcraft-script`) and
|
|
//! Render Queue export through `effectcraft-export` (FilmCraft's encoders). Frontends (desktop,
|
|
//! CLI, MCP, web) start here.
|
|
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
|
|
use std::sync::Arc;
|
|
|
|
use effectcraft_engine::project::render_queue::OutputFormat;
|
|
use effectcraft_engine::{ExportJob, ExportResult, Exporter, Importer, Session};
|
|
use effectcraft_project::Footage;
|
|
pub use effectcraft_script as script;
|
|
|
|
struct MediaImporter;
|
|
|
|
impl Importer for MediaImporter {
|
|
fn probe(&self, path: &str) -> Result<Footage, String> {
|
|
effectcraft_media::probe(path).map_err(|e| e.to_string())
|
|
}
|
|
}
|
|
|
|
/// Render Queue export via `effectcraft-export`: to the file system, or to `sink` (the web app
|
|
/// turns written files into downloads).
|
|
#[derive(Default)]
|
|
pub struct FileExporter {
|
|
pub sink: Option<Arc<effectcraft_export::Sink>>,
|
|
}
|
|
|
|
impl Exporter for FileExporter {
|
|
fn formats(&self) -> Vec<OutputFormat> {
|
|
effectcraft_export::available_formats()
|
|
}
|
|
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))
|
|
}
|
|
|
|
fn export_async<'a>(
|
|
&'a self,
|
|
job: &'a ExportJob<'a>,
|
|
progress: &'a mut dyn FnMut(u64, u64) -> bool,
|
|
) -> effectcraft_engine::render_queue::LocalFuture<'a, Result<ExportResult, String>> {
|
|
Box::pin(async move {
|
|
let j = effectcraft_export::Job {
|
|
project: job.project,
|
|
footage: job.footage,
|
|
expr: job.expr,
|
|
accel: job.accel,
|
|
comp: job.item.comp,
|
|
settings: &job.item.settings,
|
|
output: &job.item.output,
|
|
path: job.path,
|
|
sink: self.sink.as_deref(),
|
|
options: effectcraft_export::JobOptions {
|
|
log: job.item.log,
|
|
label: job.label.clone(),
|
|
storage: job.storage,
|
|
overflow: job.project.render_prefs.overflow_folders.clone(),
|
|
},
|
|
nested_switches: job.nested_switches,
|
|
};
|
|
match effectcraft_export::export_async(&j, &mut |p| progress(p.done, p.total)).await {
|
|
Ok(r) => Ok(ExportResult {
|
|
path: r.path,
|
|
frames: r.frames,
|
|
width: r.width,
|
|
height: r.height,
|
|
bytes: r.bytes,
|
|
seconds: r.seconds,
|
|
audio: r.audio,
|
|
log: r.log,
|
|
overflow: r.overflow,
|
|
}),
|
|
Err(effectcraft_export::ExportError::Cancelled) => Err(effectcraft_engine::render_queue::CANCELLED.into()),
|
|
Err(e) => Err(e.to_string()),
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/// A new session with media, import, expressions, scripting and export enabled.
|
|
pub fn session() -> Session {
|
|
Session {
|
|
// The desktop app, the CLI and the MCP server share installed Roto Brush models.
|
|
models_dir: config_dir().map(|d| d.join("models")),
|
|
exporter: Some(Arc::new(FileExporter::default())),
|
|
footage: Arc::new(effectcraft_media::MediaPool::new()),
|
|
importer: Some(Arc::new(MediaImporter)),
|
|
expr: Some(Arc::new(effectcraft_expr::Expressions)),
|
|
expr_check: Some(effectcraft_expr::check_syntax),
|
|
script: Some(effectcraft_script::runner),
|
|
script_ui: effectcraft_engine::scriptui::ScriptUi { dispatch: Some(effectcraft_script::dispatch_ui), ..Default::default() },
|
|
plugin_loader: effectcraft_plugin::wasm_available().then_some(effectcraft_plugin::loader as effectcraft_engine::PluginLoader),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// The platform config directory for EffectCraft (`EFFECTCRAFT_CONFIG_DIR` overrides):
|
|
/// `~/Library/Application Support/EffectCraft` (macOS), `%APPDATA%\EffectCraft` (Windows),
|
|
/// `$XDG_CONFIG_HOME/effectcraft` or `~/.config/effectcraft` (Linux and others).
|
|
pub fn config_dir() -> Option<std::path::PathBuf> {
|
|
use std::path::PathBuf;
|
|
if let Some(d) = std::env::var_os("EFFECTCRAFT_CONFIG_DIR") {
|
|
return Some(PathBuf::from(d));
|
|
}
|
|
let home = || std::env::var_os("HOME").map(PathBuf::from);
|
|
if cfg!(target_os = "macos") {
|
|
return home().map(|h| h.join("Library/Application Support/EffectCraft"));
|
|
}
|
|
if cfg!(target_os = "windows") {
|
|
return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("EffectCraft"));
|
|
}
|
|
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home().map(|h| h.join(".config"))).map(|c| c.join("effectcraft"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use serde_json::json;
|
|
|
|
/// Layer ▸ Camera ▸ Link Focus Distance to Point of Interest / to Layer: the expressions
|
|
/// evaluate to the camera's focus on the target.
|
|
#[test]
|
|
fn focus_link_expressions_track_the_target() {
|
|
use effectcraft_engine::geom::vec3;
|
|
let mut s = super::session();
|
|
s.execute("comp.new", json!({"name": "F", "width": 400, "height": 300, "duration": 2})).unwrap();
|
|
let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
|
s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap();
|
|
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [260, 120, 400]})).unwrap();
|
|
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
|
let cid = s.active_comp_id().unwrap();
|
|
let focus = |s: &effectcraft_engine::Session| {
|
|
let comp = s.project.comp(cid).unwrap();
|
|
let mut ctx = effectcraft_engine::render::EvalCtx::new(&s.project, cid, comp, s.time());
|
|
ctx.expr = s.expr.as_deref();
|
|
let l = comp.layer(effectcraft_engine::project::LayerId(cam)).unwrap();
|
|
ctx.value(l, l.props.prop("cameraOptions/focusDistance").unwrap()).as_f64()
|
|
};
|
|
let depth = |s: &effectcraft_engine::Session| {
|
|
let comp = s.project.comp(cid).unwrap();
|
|
let ctx = effectcraft_engine::render::EvalCtx::new(&s.project, cid, comp, s.time());
|
|
let c = comp.layer(effectcraft_engine::project::LayerId(cam)).unwrap();
|
|
let tl = comp.layer(effectcraft_engine::project::LayerId(t)).unwrap();
|
|
let cs = effectcraft_engine::render::three_d::camera::layer_camera(&ctx, c);
|
|
cs.depth(ctx.world_matrix(tl).apply(vec3(25.0, 25.0, 0.0)))
|
|
};
|
|
s.execute("layer.select", json!({"layers": [cam, t]})).unwrap();
|
|
let set = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap();
|
|
assert!((set - depth(&s)).abs() < 1e-6 && (focus(&s) - set).abs() < 1e-6);
|
|
s.execute("camera.linkFocusToLayer", json!({})).unwrap();
|
|
// Move the target: the linked focus follows.
|
|
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 900]})).unwrap();
|
|
assert!((focus(&s) - depth(&s)).abs() < 1e-3, "{} vs {}", focus(&s), depth(&s));
|
|
s.execute("camera.linkFocusToPoi", json!({"camera": cam})).unwrap();
|
|
let poi = {
|
|
let l = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(cam)).unwrap();
|
|
let p = l.props.prop("transform/position").unwrap().value.as_vec3();
|
|
let q = l.props.prop("transform/poi").unwrap().value.as_vec3();
|
|
((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt()
|
|
};
|
|
assert!((focus(&s) - poi).abs() < 1e-6, "{} vs {poi}", focus(&s));
|
|
}
|
|
|
|
#[test]
|
|
fn wired_session_renders_demo_with_expressions() {
|
|
let mut s = super::session();
|
|
s.execute("file.openDemoProject", json!({})).unwrap();
|
|
let cid = s.active_comp_id().unwrap();
|
|
let lid = s.active_comp().unwrap().layers[1].id.0;
|
|
s.execute("prop.setExpression", json!({"layer": lid, "path": "transform/rotation", "expression": "time * 90"})).unwrap();
|
|
let img = s.render(cid, s.time(), effectcraft_engine::render::RenderOpts { scale: 0.25, ..Default::default() });
|
|
assert!(img.data.iter().any(|p| p[3] > 0.5));
|
|
}
|
|
|
|
/// File ▸ Import of glTF/GLB/OBJ models through the media layer, placed as model layers and
|
|
/// drawn by the Advanced 3D renderer.
|
|
#[test]
|
|
fn imported_models_render_in_advanced_3d() {
|
|
let fx = |n: &str| format!("{}/../model/tests/fixtures/{n}", env!("CARGO_MANIFEST_DIR"));
|
|
let mut s = super::session();
|
|
s.execute("comp.new", json!({"name": "M", "width": 160, "height": 120, "renderer": "advanced3d"})).unwrap();
|
|
let r = s.execute("file.import", json!({"paths": [fx("cube.obj"), fx("quad.glb"), fx("quad.gltf")]})).unwrap();
|
|
assert_eq!(r["errors"], json!([]), "{r}");
|
|
let items: Vec<u64> = r["items"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
assert_eq!(items.len(), 3);
|
|
let cube = s.execute("layer.addItem", json!({"item": items[0]})).unwrap()["layer"].as_u64().unwrap();
|
|
s.execute("prop.set", json!({"layer": cube, "path": "transform/rotationY", "value": 30})).unwrap();
|
|
s.execute("prop.set", json!({"layer": cube, "path": "transform/rotationX", "value": 20})).unwrap();
|
|
let cid = s.active_comp_id().unwrap();
|
|
let opts = effectcraft_engine::render::RenderOpts::default();
|
|
let img = s.render(cid, s.time(), opts);
|
|
let covered = img.data.iter().filter(|p| p[3] > 0.99).count();
|
|
assert!(covered > 1000, "the cube covers part of the frame: {covered}");
|
|
// Red faces (material Red) are visible.
|
|
assert!(img.data.iter().any(|p| p[3] > 0.99 && p[0] > 0.5 && p[1] < 0.2));
|
|
// The textured binary glTF quad.
|
|
for _ in 0..3 {
|
|
s.execute("edit.undo", json!({})).unwrap();
|
|
}
|
|
s.execute("layer.addItem", json!({"item": items[1]})).unwrap();
|
|
let img = s.render(cid, s.time(), opts);
|
|
assert!(img.data.iter().filter(|p| p[3] > 0.99).count() > 1000);
|
|
// Classic 3D doesn't draw models.
|
|
s.execute("comp.renderer", json!({"renderer": "classic3d"})).unwrap();
|
|
assert!(s.render(cid, s.time(), opts).data.iter().all(|p| p[3] == 0.0));
|
|
}
|
|
|
|
/// The web app's path: outputs go to a sink (downloads), never to the file system.
|
|
#[test]
|
|
fn render_queue_exports_to_a_sink() {
|
|
use std::sync::{Arc, Mutex};
|
|
let got: Arc<Mutex<Vec<(String, Vec<u8>)>>> = Arc::default();
|
|
let g = got.clone();
|
|
let mut s = super::session();
|
|
s.exporter = Some(Arc::new(super::FileExporter { sink: Some(Arc::new(move |p: &str, d: Vec<u8>| g.lock().unwrap().push((p.to_string(), d)))) }));
|
|
s.execute("file.openDemoProject", json!({})).unwrap();
|
|
let dir = "/no-such-dir-effectcraft-sink-test";
|
|
s.execute(
|
|
"renderQueue.add",
|
|
json!({"format": "gif", "output": format!("{dir}/a.gif"), "resolution": 0.0625, "timeSpan": "custom", "start": 0.0, "end": 0.2}),
|
|
)
|
|
.unwrap();
|
|
s.execute(
|
|
"renderQueue.add",
|
|
json!({"format": "png", "output": format!("{dir}/seq_[##].png"), "resolution": 0.0625, "timeSpan": "custom", "start": 0.0, "end": 0.1}),
|
|
)
|
|
.unwrap();
|
|
s.execute("renderQueue.render", json!({"wait": true})).unwrap();
|
|
s.poll_render();
|
|
let mut got = got.lock().unwrap().clone();
|
|
got.sort_by(|a, b| a.0.cmp(&b.0));
|
|
let names: Vec<std::path::PathBuf> = got.iter().map(|(p, _)| std::path::PathBuf::from(p)).collect();
|
|
let expected = [format!("{dir}/a.gif"), format!("{dir}/seq_00.png"), format!("{dir}/seq_01.png"), format!("{dir}/seq_02.png")]
|
|
.map(|p| std::path::absolute(p).unwrap());
|
|
assert_eq!(names, expected);
|
|
assert!(got[0].1.starts_with(b"GIF89a"));
|
|
assert!(got[1].1.starts_with(b"\x89PNG"));
|
|
assert!(!std::path::Path::new(dir).exists());
|
|
}
|
|
|
|
#[test]
|
|
fn render_queue_exports_demo_comp() {
|
|
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-rq");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
let mut s = super::session();
|
|
s.execute("file.openDemoProject", json!({})).unwrap();
|
|
let out = dir.join("[compName]_[width]x[height].[fileExtension]");
|
|
let r = s
|
|
.execute(
|
|
"renderQueue.add",
|
|
json!({"format": "h264", "output": out.to_string_lossy(), "resolution": 0.125, "timeSpan": "custom", "start": 0.0, "end": 0.4}),
|
|
)
|
|
.unwrap();
|
|
let path = r["outputPath"].as_str().unwrap().to_string();
|
|
assert!(path.ends_with("_240x134.mp4"), "{path}");
|
|
// A second item: PNG sequence, rendered in the background.
|
|
s.execute("renderQueue.add", json!({"format": "png", "output": dir.join("seq_[###].png").to_string_lossy(), "resolution": 0.0625, "timeSpan": "custom", "start": 0.0, "end": 0.2})).unwrap();
|
|
let r = s.execute("renderQueue.render", json!({"wait": false})).unwrap();
|
|
assert_eq!(r["items"].as_array().unwrap().len(), 2);
|
|
let t0 = std::time::Instant::now();
|
|
while s.is_rendering() {
|
|
assert!(t0.elapsed().as_secs() < 300, "render timed out");
|
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
s.poll_render();
|
|
}
|
|
s.poll_render();
|
|
let list = s.execute("renderQueue.list", json!({})).unwrap();
|
|
for it in list["items"].as_array().unwrap() {
|
|
assert_eq!(it["statusLabel"], "Done", "{it}");
|
|
assert!(it["render_time"].as_f64().is_some());
|
|
assert!(std::path::Path::new(it["last_output"].as_str().unwrap()).exists(), "{it}");
|
|
}
|
|
assert!(std::path::Path::new(&path).metadata().unwrap().len() > 500);
|
|
assert!(dir.join("seq_000.png").exists() && dir.join("seq_005.png").exists() && !dir.join("seq_006.png").exists());
|
|
// Rendering again needs a re-queue.
|
|
assert!(s.execute("renderQueue.render", json!({})).is_err());
|
|
s.execute("renderQueue.setRender", json!({"index": 1, "render": true})).unwrap();
|
|
assert_eq!(s.project.render_queue[0].status, effectcraft_engine::project::render_queue::RenderStatus::Queued);
|
|
}
|
|
|
|
/// Value of a property of layer `l` at time 0 (keys + expression).
|
|
fn value(s: &effectcraft_engine::Session, l: u64, path: &str) -> effectcraft_engine::keyframe::Value {
|
|
let cid = s.active_comp_id().unwrap();
|
|
let comp = s.project.comp(cid).unwrap();
|
|
let layer = comp.layer(effectcraft_engine::project::LayerId(l)).unwrap();
|
|
let ctx =
|
|
effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Default::default(), expr: s.expr.as_deref(), footage: None };
|
|
ctx.value(layer, layer.props.prop(path).unwrap())
|
|
}
|
|
|
|
#[test]
|
|
fn pick_whip_expressions_evaluate() {
|
|
let mut s = super::session();
|
|
s.execute("comp.new", json!({"name": "C", "width": 200, "height": 200, "frameRate": 30, "duration": 2})).unwrap();
|
|
let a = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
|
let b = s.execute("layer.newSolid", json!({"name": "Src", "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
|
s.execute("prop.set", json!({"layer": b, "path": "transform/rotation", "value": 33})).unwrap();
|
|
s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [12, 34]})).unwrap();
|
|
s.execute("effect.apply", json!({"layer": b, "effect": "Gaussian Blur"})).unwrap();
|
|
s.execute("prop.set", json!({"layer": b, "path": "effects/#1/blurriness", "value": 21})).unwrap();
|
|
s.execute("layer.addMask", json!({"layer": b})).unwrap();
|
|
s.execute("prop.set", json!({"layer": b, "path": "masks/#1/feather", "value": [7, 7]})).unwrap();
|
|
let link = |s: &mut effectcraft_engine::Session, from: &str, to: &str| {
|
|
s.execute("prop.pickWhip", json!({"layer": a, "path": from, "target": {"layer": b, "path": to}})).unwrap();
|
|
};
|
|
link(&mut s, "transform/opacity", "transform/rotation");
|
|
assert_eq!(value(&s, a, "transform/opacity").as_f64(), 33.0);
|
|
link(&mut s, "transform/scale", "transform/rotation");
|
|
let cid = s.active_comp_id().unwrap();
|
|
let ep = effectcraft_expr::eval_property(&s.project, cid, effectcraft_engine::project::LayerId(a), "transform/scale", 0.0);
|
|
assert!(ep.is_ok(), "{ep:?}");
|
|
assert_eq!(value(&s, a, "transform/scale").as_vec2(), [33.0, 33.0]);
|
|
link(&mut s, "transform/position", "transform/position");
|
|
assert_eq!(value(&s, a, "transform/position").as_vec2(), [12.0, 34.0]);
|
|
link(&mut s, "transform/rotation", "effects/#1/blurriness");
|
|
assert_eq!(value(&s, a, "transform/rotation").as_f64(), 21.0);
|
|
link(&mut s, "transform/opacity", "masks/#1/feather");
|
|
assert_eq!(value(&s, a, "transform/opacity").as_f64(), 7.0);
|
|
// Separated dimensions: X Position reference.
|
|
s.execute("prop.separateDimensions", json!({"layer": b})).unwrap();
|
|
link(&mut s, "transform/rotation", "transform/positionX");
|
|
assert_eq!(value(&s, a, "transform/rotation").as_f64(), 12.0);
|
|
}
|
|
|
|
/// The expression error bar's list: evaluation errors and syntax errors, with the layer and
|
|
/// property they belong to.
|
|
#[test]
|
|
fn expression_errors_are_collected() {
|
|
let mut s = super::session();
|
|
s.execute("comp.new", json!({"name": "C", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
|
|
let a = s.execute("layer.newSolid", json!({"name": "Box", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
|
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "50"})).unwrap();
|
|
assert_eq!(s.execute("expr.errors", json!({})).unwrap()["count"], 0);
|
|
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "thisComp.layer(\"Nope\").rotation"})).unwrap();
|
|
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "1 +* ("})).unwrap();
|
|
let r = s.execute("expr.errors", json!({})).unwrap();
|
|
assert_eq!(r["count"], 2, "{r}");
|
|
let errs = r["errors"].as_array().unwrap();
|
|
let rot = errs.iter().find(|e| e["path"] == "transform/rotation").unwrap();
|
|
assert!(rot["message"].as_str().unwrap().contains("Nope"), "{rot}");
|
|
assert_eq!(rot["layerIndex"], 1);
|
|
assert_eq!(rot["layerName"], "Box");
|
|
assert_eq!(rot["disabled"], false);
|
|
let op = errs.iter().find(|e| e["path"] == "transform/opacity").unwrap();
|
|
assert_eq!(op["disabled"], true);
|
|
assert!(r["text"][0].as_str().unwrap().contains("of layer 1 ('Box') in comp 'C'"), "{r}");
|
|
// A user-disabled (but valid) expression is not an error.
|
|
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "45"})).unwrap();
|
|
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "enabled": false})).unwrap();
|
|
assert_eq!(s.execute("expr.errors", json!({})).unwrap()["count"], 1);
|
|
// The Expression Language menu is categorised.
|
|
let m = s.execute("expr.languageMenu", json!({})).unwrap();
|
|
assert!(m.as_array().unwrap().iter().any(|c| c["category"] == "Footage" && c["items"].as_array().unwrap().iter().any(|i| i["text"] == "sourceData")));
|
|
}
|
|
|
|
/// File ▸ Import of JSON/CSV data files as data footage, read by expressions.
|
|
#[test]
|
|
fn imported_data_files_drive_expressions() {
|
|
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-data");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
std::fs::write(dir.join("levels.csv"), "label,value\nlow,10\nhigh,90\n").unwrap();
|
|
std::fs::write(dir.join("cfg.json"), r#"{"angle": 33}"#).unwrap();
|
|
std::fs::write(dir.join("broken.json"), "{nope").unwrap();
|
|
let mut s = super::session();
|
|
s.execute("comp.new", json!({"name": "C", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
|
|
let paths: Vec<String> = ["levels.csv", "cfg.json", "broken.json"].iter().map(|n| dir.join(n).to_string_lossy().to_string()).collect();
|
|
let r = s.execute("file.import", json!({"paths": paths})).unwrap();
|
|
assert_eq!(r["items"].as_array().unwrap().len(), 2, "{r}");
|
|
assert_eq!(r["errors"].as_array().unwrap().len(), 1, "{r}");
|
|
let csv = r["items"][0].as_u64().unwrap();
|
|
assert_eq!(s.project.item(effectcraft_engine::project::ItemId(csv)).unwrap().type_name(), "Data");
|
|
assert!(s.execute("layer.addItem", json!({"item": csv})).is_err(), "data files are not layers");
|
|
let a = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
|
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "footage(\"levels.csv\").sourceData[1].value"})).unwrap();
|
|
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "footage(\"cfg.json\").sourceData.angle"})).unwrap();
|
|
assert_eq!(value(&s, a, "transform/opacity").as_f64(), 90.0);
|
|
assert_eq!(value(&s, a, "transform/rotation").as_f64(), 33.0);
|
|
}
|
|
|
|
/// sampleImage through the renderer: a box's Fill colour follows the solid under it.
|
|
#[test]
|
|
fn sample_image_drives_a_colour() {
|
|
let mut s = super::session();
|
|
s.execute("comp.new", json!({"name": "C", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
|
|
s.execute("layer.newSolid", json!({"name": "Bg", "color": "#00ff00"})).unwrap();
|
|
let a = s.execute("layer.newSolid", json!({"name": "Fg", "color": "#ffffff", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
|
|
s.execute("effect.apply", json!({"layer": a, "effect": "Fill"})).unwrap();
|
|
s.execute(
|
|
"prop.setExpression",
|
|
json!({"layer": a, "path": "effects/#1/color", "expression": "thisComp.layer(\"Bg\").sampleImage(thisComp.layer(\"Bg\").fromComp(toComp(anchorPoint)))"}),
|
|
)
|
|
.unwrap();
|
|
let cid = s.active_comp_id().unwrap();
|
|
let img = s.render(cid, s.time(), effectcraft_engine::render::RenderOpts::default());
|
|
let px = img.get(50, 50);
|
|
assert!(px[1] > 0.99 && px[0] < 0.01, "the box is filled with the sampled green: {px:?}");
|
|
}
|
|
|
|
/// Proxies through the real media and export layers: Render Settings ▸ Proxy Use decides
|
|
/// whether an export uses the proxy, and Create Proxy renders and attaches one.
|
|
#[test]
|
|
fn proxies_in_exports_and_create_proxy() {
|
|
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-proxy");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let p = |n: &str| dir.join(n).to_string_lossy().to_string();
|
|
let mut s = super::session();
|
|
// Source and proxy stills, written by the app itself.
|
|
s.execute("comp.new", json!({"name": "Red", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
|
|
let red = s.active_comp_id().unwrap();
|
|
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
|
s.execute("comp.saveFrameAs", json!({"path": p("red.png")})).unwrap();
|
|
s.execute("comp.new", json!({"name": "Blue", "width": 8, "height": 8, "frameRate": 10, "duration": 1})).unwrap();
|
|
s.execute("layer.newSolid", json!({"color": "#0000ff"})).unwrap();
|
|
s.execute("comp.saveFrameAs", json!({"path": p("blue.png")})).unwrap();
|
|
let foot = s.execute("file.import", json!({"paths": [p("red.png")]})).unwrap()["items"][0].as_u64().unwrap();
|
|
s.execute("file.setProxy", json!({"item": foot, "path": p("blue.png")})).unwrap();
|
|
s.execute("comp.new", json!({"name": "Out", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
|
|
let out = s.active_comp_id().unwrap();
|
|
s.execute("layer.addItem", json!({"item": foot})).unwrap();
|
|
// Export one frame with each Proxy Use.
|
|
for (name, using) in [("all", "use_all_[#].png"), ("none", "use_none_[#].png")] {
|
|
s.execute(
|
|
"renderQueue.add",
|
|
json!({"comp": out.0, "format": "png", "output": p(using), "timeSpan": "custom", "start": 0.0, "end": 0.1, "proxyUse": name}),
|
|
)
|
|
.unwrap();
|
|
}
|
|
s.execute("renderQueue.render", json!({"wait": true})).unwrap();
|
|
s.poll_render();
|
|
let centre = |s: &mut effectcraft_engine::Session, path: &str| {
|
|
let id = s.execute("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap();
|
|
s.execute("comp.new", json!({"name": "Probe", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
|
|
s.execute("layer.addItem", json!({"item": id})).unwrap();
|
|
let c = s.active_comp_id().unwrap();
|
|
s.render(c, Default::default(), Default::default()).get(8, 8)
|
|
};
|
|
let all = centre(&mut s, &p("use_all_0.png"));
|
|
assert!(all[2] > 0.99 && all[0] < 0.01, "Use All Proxies renders the blue proxy: {all:?}");
|
|
let none = centre(&mut s, &p("use_none_0.png"));
|
|
assert!(none[0] > 0.99 && none[2] < 0.01, "Use No Proxies renders the red source: {none:?}");
|
|
// Create Proxy ▸ Still: rendered at half size and attached to the comp.
|
|
s.execute("file.createProxy", json!({"kind": "still", "comp": red.0, "path": p("red_proxy_[#####].png")})).unwrap();
|
|
s.execute("renderQueue.render", json!({"wait": true})).unwrap();
|
|
s.poll_render();
|
|
let px = s.project.item(red).unwrap().proxy.clone().expect("Create Proxy attached the rendered still");
|
|
assert!(px.enabled);
|
|
assert_eq!((px.footage.width, px.footage.height), (8, 8));
|
|
assert!(std::path::Path::new(&px.footage.path).exists(), "{}", px.footage.path);
|
|
}
|
|
|
|
/// A script builds a comp, queues it and renders it through the Render Queue.
|
|
#[test]
|
|
fn script_renders_through_the_render_queue() {
|
|
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-script");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
let out = dir.join("scripted.gif");
|
|
let mut s = super::session();
|
|
let code = format!(
|
|
r#"
|
|
var comp = app.project.items.addComp("Scripted", 64, 48, 1, 0.2, 10);
|
|
var sq = comp.layers.addSolid([1, 0.5, 0], "Square", 16, 16, 1);
|
|
sq.transform.position.setValueAtTime(0, [8, 24]);
|
|
sq.transform.position.setValueAtTime(0.1, [56, 24]);
|
|
var item = app.project.renderQueue.items.add(comp);
|
|
item.outputModule(1).applyTemplate("GIF");
|
|
item.outputModule(1).file = new File({});
|
|
app.project.renderQueue.render();
|
|
[item.status == RQItemStatus.DONE, item.outputModule(1).file.fsName, app.project.renderQueue.numItems]
|
|
"#,
|
|
serde_json::to_string(&out.to_string_lossy()).unwrap()
|
|
);
|
|
let r = s.execute("script.run", json!({"code": code})).unwrap();
|
|
assert_eq!(r["ok"], json!(true), "{r}");
|
|
assert_eq!(r["result"][0], json!(true), "{r}");
|
|
assert_eq!(r["result"][2], json!(1));
|
|
let bytes = std::fs::read(&out).unwrap();
|
|
assert!(bytes.starts_with(b"GIF89a"));
|
|
}
|
|
|
|
/// importFile, layers.add(footage) and project save/open from a script.
|
|
#[test]
|
|
fn script_imports_footage_and_saves() {
|
|
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-script-import");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let q = |n: &str| serde_json::to_string(&dir.join(n).to_string_lossy()).unwrap();
|
|
let mut s = super::session();
|
|
let code = format!(
|
|
r#"
|
|
var c = app.project.items.addComp("Src", 32, 32, 1, 1, 10);
|
|
c.layers.addSolid([0, 1, 0], "G", 32, 32, 1);
|
|
app.run("comp.saveFrameAs", {{comp: c.id, path: {png}}});
|
|
var still = app.project.importFile(new ImportOptions(new File({png})));
|
|
var main = app.project.items.addComp("Main", 64, 64, 1, 1, 10);
|
|
var l = main.layers.add(still);
|
|
app.project.save(new File({proj}));
|
|
[still instanceof FootageItem, still.width, still.mainSource.isStill, l.source.name, app.project.file.name]
|
|
"#,
|
|
png = q("frame.png"),
|
|
proj = q("p.ecproj")
|
|
);
|
|
let r = s.execute("script.run", json!({"code": code})).unwrap();
|
|
assert_eq!(r["ok"], json!(true), "{r}");
|
|
assert_eq!(r["result"], json!([true, 32, true, "frame.png", "p.ecproj"]), "{r}");
|
|
let r = s.execute("script.run", json!({"code": format!("app.open(new File({})); app.project.numItems", q("p.ecproj"))})).unwrap();
|
|
assert_eq!(r["result"], json!(5), "{r}"); // Src, Solids, G, frame.png, Main
|
|
}
|
|
|
|
#[test]
|
|
fn expression_syntax_errors_disable_the_expression() {
|
|
let mut s = super::session();
|
|
s.execute("comp.new", json!({"name": "C", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
|
|
let a = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
|
let r = s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "1 +* ("})).unwrap();
|
|
assert!(r["error"].is_string(), "{r}");
|
|
let pr = s.active_comp().unwrap().layers[0].props.prop("transform/opacity").unwrap().clone();
|
|
assert!(!pr.expr.unwrap().enabled);
|
|
assert_eq!(value(&s, a, "transform/opacity").as_f64(), 100.0);
|
|
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "50"})).unwrap();
|
|
assert_eq!(value(&s, a, "transform/opacity").as_f64(), 50.0);
|
|
}
|
|
/// Create Stereo 3D Rig: the eye cameras' expressions follow the master camera and the
|
|
/// Stereo 3D Controls (and agree with the values the command wrote).
|
|
#[test]
|
|
fn stereo_rig_expressions_follow_the_master_camera() {
|
|
let mut s = super::session();
|
|
s.execute("comp.new", json!({"name": "Shot", "width": 640, "height": 360, "frameRate": 30, "duration": 2})).unwrap();
|
|
let src = s.active_comp_id().unwrap();
|
|
let cam = s.execute("layer.newCamera", json!({"name": "Main", "position": [320, 180, -800], "poi": [320, 180, 0], "zoom": 900})).unwrap()["layer"]
|
|
.as_u64()
|
|
.unwrap();
|
|
let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap();
|
|
let ctl = r["controls"].as_u64().unwrap();
|
|
let left = effectcraft_engine::project::ItemId(r["leftComp"].as_u64().unwrap());
|
|
// The left eye camera's evaluated value (expressions on).
|
|
let v3 = |s: &effectcraft_engine::Session, path: &str| {
|
|
let comp = s.project.comp(left).unwrap();
|
|
let layer = &comp.layers[0];
|
|
let ctx = effectcraft_engine::render::EvalCtx {
|
|
project: &s.project,
|
|
comp_id: left,
|
|
comp,
|
|
time: Default::default(),
|
|
expr: s.expr.as_deref(),
|
|
footage: None,
|
|
};
|
|
ctx.value(layer, layer.props.prop(path).unwrap()).as_vec3()
|
|
};
|
|
let close = |a: [f64; 3], b: [f64; 3]| (0..3).all(|i| (a[i] - b[i]).abs() < 1e-6);
|
|
let d = 0.04 * 640.0;
|
|
assert!(close(v3(&s, "transform/position"), [320.0 - d / 2.0, 180.0, -800.0]), "{:?}", v3(&s, "transform/position"));
|
|
assert!(close(v3(&s, "transform/poi"), [320.0 - d / 2.0, 180.0, 0.0]), "{:?}", v3(&s, "transform/poi"));
|
|
// Moving the master moves the eyes.
|
|
s.execute("comp.open", json!({"comp": src.0})).unwrap();
|
|
s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [300, 180, -800]})).unwrap();
|
|
s.execute("prop.set", json!({"layer": cam, "path": "transform/poi", "value": [300, 180, 0]})).unwrap();
|
|
assert!(close(v3(&s, "transform/position"), [300.0 - d / 2.0, 180.0, -800.0]), "{:?}", v3(&s, "transform/position"));
|
|
// Controls: Center & Left puts the left eye a full separation left; convergence toes in.
|
|
s.execute("prop.set", json!({"layer": ctl, "path": "effects/#1/configuration", "value": 2})).unwrap();
|
|
s.execute("prop.set", json!({"layer": ctl, "path": "effects/#1/convergence", "value": true})).unwrap();
|
|
assert!(close(v3(&s, "transform/position"), [300.0 - d, 180.0, -800.0]), "{:?}", v3(&s, "transform/position"));
|
|
assert!(close(v3(&s, "transform/poi"), [300.0, 180.0, 0.0]), "{:?}", v3(&s, "transform/poi"));
|
|
}
|
|
}
|