299 lines
16 KiB
Rust
299 lines
16 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.
|
|
|
|
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> {
|
|
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(),
|
|
};
|
|
match effectcraft_export::export(&j, &mut |p| progress(p.done, p.total)) {
|
|
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 }),
|
|
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 {
|
|
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),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use serde_json::json;
|
|
|
|
#[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<&str> = got.iter().map(|(p, _)| p.as_str()).collect();
|
|
assert_eq!(names, [format!("{dir}/a.gif"), format!("{dir}/seq_00.png"), format!("{dir}/seq_01.png"), format!("{dir}/seq_02.png")]);
|
|
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() };
|
|
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);
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|