mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 21:48:20 -06:00
Merge main into enhance/avi-import
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
//! The Backgrounds animation presets through the wired session (with the expression engine):
|
||||
//! each one renders a non-flat, finite, deterministic frame that keeps moving, and undoes.
|
||||
|
||||
use effectcraft_engine::render::RenderOpts;
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
type Frame = Vec<[f32; 4]>;
|
||||
|
||||
/// The frame at `secs`, its mean luma, luma standard deviation and whether any channel is not finite.
|
||||
fn frame(s: &effectcraft_engine::Session, secs: f64) -> (Frame, f64, f64, bool) {
|
||||
let Some(cid) = s.active_comp_id() else { panic!("no active comp") };
|
||||
let img = s.render(cid, Tick::from_seconds_f64(secs), RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let bad = img.data.iter().any(|p| p.iter().any(|c| !c.is_finite()));
|
||||
let l: Vec<f64> = img.data.iter().map(|p| 0.2126 * p[0] as f64 + 0.7152 * p[1] as f64 + 0.0722 * p[2] as f64).collect();
|
||||
let n = l.len().max(1) as f64;
|
||||
let mean = l.iter().sum::<f64>() / n;
|
||||
let sd = (l.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n).sqrt();
|
||||
(img.data.clone(), mean, sd, bad)
|
||||
}
|
||||
|
||||
fn diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f64 {
|
||||
a.iter().zip(b).map(|(p, q)| (0..3).map(|i| (p[i] - q[i]).abs() as f64).sum::<f64>()).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_background_preset_renders_moves_and_undoes() {
|
||||
let mut s = effectcraft_host::session();
|
||||
s.execute("comp.new", json!({"name": "BG", "width": 640, "height": 360, "frameRate": 30, "duration": 8})).unwrap();
|
||||
let solid = s.execute("layer.newSolid", json!({"name": "S", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let list = s.execute("anim.builtinPresets", json!({"category": "Backgrounds"})).unwrap();
|
||||
let list = list.as_array().unwrap().clone();
|
||||
assert!(list.len() >= 15, "{}", list.len());
|
||||
for p in &list {
|
||||
let id = p["id"].as_str().unwrap();
|
||||
s.execute("time.set", json!({"time": 0.0})).unwrap();
|
||||
let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": id})).unwrap();
|
||||
assert_eq!(r["skipped"], json!([]), "{id}");
|
||||
let mut frames = Vec::new();
|
||||
for t in [0.0, 1.3, 3.7] {
|
||||
let (px, mean, sd, nan) = frame(&s, t);
|
||||
if let Some(dir) = std::env::var_os("EC_PRESET_DUMP") {
|
||||
// Contact-sheet helper for reviewing the looks by eye (not part of the check).
|
||||
let mut ppm = b"P6\n640 360\n255\n".to_vec();
|
||||
ppm.extend(px.iter().flat_map(|p| (0..3).map(|i| (p[i].clamp(0.0, 1.0) * 255.0) as u8)));
|
||||
let _ = std::fs::write(std::path::Path::new(&dir).join(format!("{id}_{t}.ppm")), ppm);
|
||||
}
|
||||
assert!(!nan, "{id} @ {t}: NaN or infinite pixel");
|
||||
assert!(sd > 0.015, "{id} @ {t}: flat frame (mean {mean:.3}, sd {sd:.4})");
|
||||
let (again, ..) = frame(&s, t);
|
||||
assert!(px == again, "{id} @ {t}: render is not deterministic");
|
||||
frames.push(px);
|
||||
}
|
||||
assert!(diff(&frames[0], &frames[1]) > 100.0, "{id}: frame does not move between 0 s and 1.3 s");
|
||||
assert!(diff(&frames[1], &frames[2]) > 100.0, "{id}: frame does not move between 1.3 s and 3.7 s");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let fx = s.active_comp().map_or(1, |c| c.layers[0].effects().map_or(0, |fx| fx.groups().count()));
|
||||
assert_eq!(fx, 0, "{id}: undo removes the effects");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! The Cryptomatte eyedropper on real OpenEXR footage (#473): `effect.pickCryptomatte` reads the
|
||||
//! file's Cryptomatte ranks and manifest, edits the Selection, and the rendered matte follows,
|
||||
//! including undo.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::time::Tick;
|
||||
use effectcraft_raster::channels3d::{crypto_float, crypto_hash};
|
||||
use serde_json::json;
|
||||
|
||||
const RIGHT: &str = "Right, \"Box\"";
|
||||
|
||||
/// An 8 × 4 EXR: white RGBA, and a CryptoObject layer with `Left` in columns before `split` and
|
||||
/// the comma-and-quotes name from there, both named in its manifest.
|
||||
fn crypto_exr(tag: &str, split: usize) -> String {
|
||||
use exr::prelude::*;
|
||||
let (w, h) = (8usize, 4usize);
|
||||
let ids: Vec<f32> = (0..w * h).map(|i| crypto_float(crypto_hash(if i % w < split { "Left" } else { RIGHT }))).collect();
|
||||
let ch = |n: &str, v: Vec<f32>| AnyChannel::new(n, FlatSamples::F32(v));
|
||||
let one = || vec![1.0f32; w * h];
|
||||
let channels = AnyChannels::sort(
|
||||
vec![ch("R", one()), ch("G", one()), ch("B", one()), ch("A", one()), ch("CryptoObject00.R", ids), ch("CryptoObject00.G", one())].into(),
|
||||
);
|
||||
let mut layer = Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels);
|
||||
let manifest = json!({"Left": format!("{:08x}", crypto_hash("Left")), RIGHT: format!("{:08x}", crypto_hash(RIGHT))}).to_string();
|
||||
for (k, v) in [("name", "CryptoObject".to_string()), ("manifest", manifest)] {
|
||||
layer.attributes.other.insert(Text::from(format!("cryptomatte/f834d0a/{k}").as_str()), AttributeValue::Text(Text::from(v.as_str())));
|
||||
}
|
||||
// A folder of its own: numbered siblings would import as an image sequence.
|
||||
let dir = std::env::temp_dir().join(format!("ec-crypto-pick-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("crypto.exr");
|
||||
Image::from_layer(layer).write().to_file(&path).unwrap();
|
||||
path.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// A session with the EXR as a layer of a 2 s comp, under a Cryptomatte effect.
|
||||
fn footage(tag: &str, split: usize) -> (Session, String) {
|
||||
let path = crypto_exr(tag, split);
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute("file.import", json!({"paths": [path]})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
s.execute("comp.new", json!({"name": "Crypto", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
|
||||
s.execute("layer.addItem", json!({"item": r["items"][0]})).unwrap();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
(s, path)
|
||||
}
|
||||
|
||||
/// The matte's alpha over the left and right halves.
|
||||
fn matte(s: &Session) -> [f32; 2] {
|
||||
let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default());
|
||||
[img.get(1, 1)[3], img.get(6, 2)[3]]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picking_on_a_cryptomatte_exr_edits_the_selection_and_the_matte() {
|
||||
let (mut s, path) = footage("edit", 4);
|
||||
s.execute_checked("prop.set", json!({"path": "effects/#1/display", "value": 1})).unwrap();
|
||||
assert_eq!(matte(&s), [0.0, 0.0], "nothing selected");
|
||||
let pick = |s: &mut Session, x: f64, mode: &str| s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": 1, "mode": mode})).unwrap();
|
||||
|
||||
assert_eq!(pick(&mut s, 1.0, "replace")["selection"], "Left");
|
||||
assert_eq!(matte(&s), [1.0, 0.0]);
|
||||
let r = pick(&mut s, 6.0, "add");
|
||||
assert_eq!((r["name"].as_str(), r["layer"].as_str()), (Some(RIGHT), Some("CryptoObject")), "{r}");
|
||||
assert_eq!(r["selection"], r#"Left, "Right, \"Box\"""#);
|
||||
assert_eq!(matte(&s), [1.0, 1.0], "the quoted name selects the right half");
|
||||
pick(&mut s, 2.0, "remove");
|
||||
assert_eq!(matte(&s), [0.0, 1.0]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(matte(&s), [1.0, 1.0]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(matte(&s), [1.0, 0.0]);
|
||||
let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap());
|
||||
}
|
||||
|
||||
/// At half resolution a preview pixel shows the aux texel at its centre (layer x 3 for preview
|
||||
/// pixel 1): `scale` makes the pick read that one, so what is clicked is what lights up.
|
||||
#[test]
|
||||
fn a_pick_at_preview_resolution_reads_the_pixel_the_preview_shows() {
|
||||
let (mut s, path) = footage("half", 3);
|
||||
s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap();
|
||||
let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 2.5, "y": 1.5, "scale": 0.5})).unwrap();
|
||||
assert_eq!(r["name"], RIGHT, "{r}");
|
||||
let opts = effectcraft_engine::render::RenderOpts { scale: 0.5, ..Default::default() };
|
||||
assert_eq!(s.render(s.active_comp_id().unwrap(), Tick::ZERO, opts).get(1, 0)[3], 1.0);
|
||||
// At full resolution the same point is Left's pixel.
|
||||
let r = s.execute_checked("effect.cryptomatteAt", json!({"effect": 1, "x": 2.5, "y": 1.5})).unwrap();
|
||||
assert_eq!(r["name"], "Left");
|
||||
let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap());
|
||||
}
|
||||
|
||||
/// A keyframed Selection picked at `time` is edited at that time, not at the current time.
|
||||
#[test]
|
||||
fn a_keyframed_selection_is_edited_where_it_was_read() {
|
||||
let (mut s, path) = footage("key", 4);
|
||||
for t in [0.0, 1.0] {
|
||||
s.execute_checked("prop.addKey", json!({"path": "effects/#1/selection", "time": t, "value": format!("<{:08x}>", crypto_hash(RIGHT))})).unwrap();
|
||||
}
|
||||
let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 0.5, "y": 1.5, "mode": "add", "time": 1.0})).unwrap();
|
||||
assert_eq!((r["changed"].as_bool(), r["selected"].as_bool()), (Some(true), Some(true)), "{r}");
|
||||
let sel = |s: &mut Session, t: f64| {
|
||||
let r = s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 0.5, "y": 1.5, "time": t})).unwrap();
|
||||
r["selected"].as_bool()
|
||||
};
|
||||
assert_eq!((sel(&mut s, 0.0), sel(&mut s, 1.0)), (Some(false), Some(true)), "only the key at 1 s gains Left");
|
||||
let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap());
|
||||
}
|
||||
|
||||
/// A precomp whose 3D layer an expression moves: the pick finds it where the render keys it
|
||||
/// (the pick's aux pass runs the session's expressions).
|
||||
#[test]
|
||||
fn a_pick_on_a_precomp_follows_expressions() {
|
||||
let mut s = effectcraft_host::session();
|
||||
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap()["comp"].clone();
|
||||
let l = s.execute("layer.newSolid", json!({"name": "Ball", "width": 6, "height": 6})).unwrap()["layer"].clone();
|
||||
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute_checked("prop.setExpression", json!({"layer": l, "path": "transform/position", "expression": "[33, 10, 0]"})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap();
|
||||
s.execute("layer.addItem", json!({"item": inner})).unwrap();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap();
|
||||
let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 33.5, "y": 10.5})).unwrap();
|
||||
assert_eq!(r["selection"], "Ball", "{r}");
|
||||
let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default());
|
||||
assert_eq!((img.get(33, 10)[3], img.get(20, 10)[3]), (1.0, 0.0));
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 20.5, "y": 10.5})).unwrap(), serde_json::Value::Null);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Deterministic rendering (better than After Effects): the same project renders bit-identical
|
||||
//! frames every time and whatever the number of render threads. Renders the demo comps (and a
|
||||
//! comp of seeded-random effects: particles, grain, noise, wiggle expressions, Shatter) twice
|
||||
//! on one thread and on several, and compares every float of every pixel.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::{ItemId, Project};
|
||||
use effectcraft_engine::render::{RenderOpts, Renderer};
|
||||
use effectcraft_engine::time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
fn render(s: &Session, p: &Project, comp: ItemId, t: f64) -> Vec<u32> {
|
||||
let mut r = Renderer::new(p, s.footage.as_ref(), RenderOpts { scale: 0.25, ..Default::default() });
|
||||
r.expr = s.expr.as_deref();
|
||||
// No layer cache: every render computes everything.
|
||||
let img = r.comp_frame(comp, Tick::from_seconds_f64(t));
|
||||
img.data.iter().flat_map(|px| px.iter().map(|c| c.to_bits())).collect()
|
||||
}
|
||||
|
||||
fn pool(n: usize) -> rayon::ThreadPool {
|
||||
rayon::ThreadPoolBuilder::new().num_threads(n).build().expect("thread pool")
|
||||
}
|
||||
|
||||
/// A comp of effects that draw random numbers (all seeded per instance).
|
||||
fn random_comp(s: &mut Session) -> ItemId {
|
||||
s.execute("comp.new", json!({"name": "Random", "width": 480, "height": 270, "frameRate": 24, "duration": 4})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let solid = |s: &mut Session, name: &str, color: &str| {
|
||||
s.execute("layer.newSolid", json!({"name": name, "color": color, "width": 480, "height": 270})).unwrap()["layer"].as_u64().unwrap()
|
||||
};
|
||||
let a = solid(s, "Particles", "#000000");
|
||||
for fx in ["ec.sim.ccparticleworld", "ec.noise.addgrain"] {
|
||||
s.execute("effect.apply", json!({"layer": a, "effect": fx})).unwrap();
|
||||
}
|
||||
let b = solid(s, "Noise", "#336699");
|
||||
for fx in ["ec.noise.fractal", "ec.sim.ccsnowfall", "ec.noise.noisehls"] {
|
||||
s.execute("effect.apply", json!({"layer": b, "effect": fx})).unwrap();
|
||||
}
|
||||
s.execute("prop.setExpression", json!({"layer": b, "path": "transform/position", "expression": "wiggle(3, 40)"})).unwrap();
|
||||
s.execute("prop.setExpression", json!({"layer": b, "path": "transform/opacity", "expression": "random(40, 90)"})).unwrap();
|
||||
let c = solid(s, "Shatter", "#cc8844");
|
||||
s.execute("layer.setTransform", json!({"layer": c, "prop": "scale", "value": [50, 50]})).unwrap();
|
||||
s.execute("effect.apply", json!({"layer": c, "effect": "ec.sim.shatter"})).unwrap();
|
||||
cid
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_are_bit_identical_across_runs_and_thread_counts() {
|
||||
let mut s = effectcraft_host::session();
|
||||
s.replace_project(effectcraft_engine::demo::demo_project(), None);
|
||||
let random = random_comp(&mut s);
|
||||
let p = (*s.project).clone();
|
||||
let mut comps: Vec<(ItemId, f64)> = p.comps().map(|(id, c)| (*id, c.duration.seconds())).collect();
|
||||
comps.sort_by_key(|c| c.0);
|
||||
assert!(comps.len() >= 3, "demo comps + the random comp");
|
||||
assert!(comps.iter().any(|c| c.0 == random));
|
||||
let (one, many) = (pool(1), pool(6));
|
||||
let mut frames = 0;
|
||||
for (comp, dur) in comps {
|
||||
for t in [0.0, dur * 0.37, dur * 0.81] {
|
||||
let a = one.install(|| render(&s, &p, comp, t));
|
||||
let b = many.install(|| render(&s, &p, comp, t));
|
||||
let c = many.install(|| render(&s, &p, comp, t));
|
||||
let d = one.install(|| render(&s, &p, comp, t));
|
||||
let name = &p.item(comp).unwrap().name;
|
||||
assert!(!a.is_empty());
|
||||
assert!(a == b, "{name} @ {t:.2}s: 1 thread vs 6 threads differ");
|
||||
assert!(b == c, "{name} @ {t:.2}s: two 6-thread renders differ");
|
||||
assert!(a == d, "{name} @ {t:.2}s: two 1-thread renders differ");
|
||||
frames += 1;
|
||||
}
|
||||
}
|
||||
assert!(frames >= 9);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Multi-layer OpenEXR footage and EXtractoR in a full session (#295): a file whose colour is
|
||||
//! only in named layers (as Blender and Nuke write them) imports, `layer.channels` lists its
|
||||
//! layers and channels, and EXtractoR shows the chosen ones.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// An 8×4 EXR with layers `diffuse` (orange), `spec` (blue) and `depth` (Z = 0.25), and no
|
||||
/// unnamed RGB layer.
|
||||
fn layered_exr() -> String {
|
||||
use exr::prelude::*;
|
||||
let (w, h) = (8usize, 4usize);
|
||||
let ch = |n: &str, v: f32| AnyChannel::new(n, FlatSamples::F32(vec![v; w * h]));
|
||||
let channels = AnyChannels::sort(
|
||||
vec![ch("diffuse.R", 1.0), ch("diffuse.G", 0.5), ch("diffuse.B", 0.0), ch("spec.R", 0.0), ch("spec.G", 0.0), ch("spec.B", 1.0), ch("depth.Z", 0.25)]
|
||||
.into(),
|
||||
);
|
||||
let image = Image::from_layer(Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels));
|
||||
let path = std::env::temp_dir().join(format!("ec-exr-layers-{}.exr", std::process::id()));
|
||||
image.write().to_file(&path).unwrap();
|
||||
path.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn px(s: &Session) -> [f32; 4] {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
s.render(cid, Tick::ZERO, Default::default()).get(4, 2)
|
||||
}
|
||||
|
||||
fn near(a: [f32; 4], b: [f32; 3]) -> bool {
|
||||
(0..3).all(|k| (a[k] - b[k]).abs() < 0.02)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extractor_shows_the_chosen_layer_and_channels_of_a_layered_exr() {
|
||||
let path = layered_exr();
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute("file.import", json!({"paths": [path]})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "the file imports: {r}");
|
||||
s.execute("comp.new", json!({"name": "EXR", "width": 8, "height": 4, "frameRate": 24, "duration": 1})).unwrap();
|
||||
let layer = s.execute("layer.addItem", json!({"item": r["items"][0]})).unwrap()["layer"].clone();
|
||||
// The picture is the colour layer (diffuse: orange).
|
||||
assert!(near(px(&s), [1.0, 0.735, 0.0]), "{:?}", px(&s));
|
||||
|
||||
let ch = s.execute("layer.channels", json!({"layer": layer})).unwrap();
|
||||
let names: Vec<&str> = ch["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect();
|
||||
assert_eq!(names, ["depth", "diffuse", "spec"], "{ch}");
|
||||
assert_eq!(ch["layers"][2]["rgba"], json!(["spec.R", "spec.G", "spec.B", ""]));
|
||||
assert_eq!(ch["layers"][0]["rgba"], json!(["depth.Z", "depth.Z", "depth.Z", ""]), "one channel fills red, green and blue");
|
||||
assert!(ch["channels"].as_array().unwrap().contains(&Value::from("depth.Z")));
|
||||
|
||||
// EXtractoR on the spec layer: blue.
|
||||
s.execute("effect.apply", json!({"layers": [layer], "effect": "EXtractoR"})).unwrap();
|
||||
for (param, v) in ["red", "green", "blue", "alpha"].iter().zip(["spec.R", "spec.G", "spec.B", ""]) {
|
||||
s.execute("prop.set", json!({"layer": layer, "path": format!("effects/#1/{param}"), "value": v})).unwrap();
|
||||
}
|
||||
assert!(near(px(&s), [0.0, 0.0, 1.0]), "{:?}", px(&s));
|
||||
// Depth in all three: grey at its value.
|
||||
for param in ["red", "green", "blue"] {
|
||||
s.execute("prop.set", json!({"layer": layer, "path": format!("effects/#1/{param}"), "value": "depth.Z"})).unwrap();
|
||||
}
|
||||
assert!(near(px(&s), [0.25, 0.25, 0.25]), "{:?}", px(&s));
|
||||
// A layer that isn't OpenEXR has no channels.
|
||||
let solid = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].clone();
|
||||
assert_eq!(s.execute("layer.channels", json!({"layer": solid})).unwrap()["layers"], json!([]));
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Footage changed on disk by another app, through the fully wired session (media decoding
|
||||
//! included): File ▸ Reload Footage shows the new pixels and keeps a Photoshop layer item on its
|
||||
//! layer, and `footage.reloadChanged` (Settings ▸ Import ▸ Automatically Reload Footage) finds
|
||||
//! changed files by itself.
|
||||
|
||||
use effectcraft_engine::render::RenderOpts;
|
||||
use effectcraft_engine::{Session, footage_reload};
|
||||
use effectcraft_project::{Footage, ItemId, ItemKind};
|
||||
use effectcraft_psd::Rect;
|
||||
use effectcraft_psd::write::*;
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let d = std::env::temp_dir().join(format!("effectcraft-host-reload-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
|
||||
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
|
||||
const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
|
||||
|
||||
/// A one-colour document; `extra` adds a hidden layer, so the file's size changes too.
|
||||
fn flat(rgba: [f32; 4], extra: bool) -> Vec<u8> {
|
||||
let mut d = WDoc::new(64, 48);
|
||||
d.layers = vec![WLayer::solid("Background", Rect::new(0, 0, 64, 48), rgba)];
|
||||
if extra {
|
||||
let mut hidden = WLayer::solid("Hidden", Rect::new(0, 0, 8, 8), RED);
|
||||
hidden.hidden = true;
|
||||
d.layers.push(hidden);
|
||||
}
|
||||
d.composite = Some(vec![rgba; 64 * 48]);
|
||||
write(&d)
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` so its modification time moves even on coarse file systems.
|
||||
fn save(path: &str, bytes: &[u8]) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
fn footage(s: &Session, id: ItemId) -> Footage {
|
||||
match &s.project.item(id).unwrap().kind {
|
||||
ItemKind::Footage(f) => f.clone(),
|
||||
k => panic!("not footage: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour (straight RGB) at `(x, y)` of comp `cid` at time 0.
|
||||
fn pixel(s: &Session, cid: ItemId, x: usize, y: usize) -> [f32; 3] {
|
||||
let img = s.render(cid, Tick::ZERO, RenderOpts::default());
|
||||
let p = img.data[y * img.width as usize + x];
|
||||
[p[0], p[1], p[2]].map(|c| if p[3] > 0.0 { c / p[3] } else { 0.0 })
|
||||
}
|
||||
|
||||
fn near(a: [f32; 3], b: [f32; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.02)
|
||||
}
|
||||
|
||||
/// A flat file as footage in a comp of its size: (session, footage item, comp).
|
||||
fn flat_in_comp(path: &str) -> (Session, ItemId, ItemId) {
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap();
|
||||
let item = ItemId(r["items"][0].as_u64().unwrap());
|
||||
s.execute_checked("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 30, "duration": 1})).unwrap();
|
||||
s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
(s, item, cid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_footage_shows_the_new_pixels() {
|
||||
let path = tmp("flat.psd");
|
||||
std::fs::write(&path, flat(RED, false)).unwrap();
|
||||
let (mut s, item, cid) = flat_in_comp(&path);
|
||||
assert!(near(pixel(&s, cid, 32, 24), RED));
|
||||
// Same size, new colour: the frames decoded before must not be shown again.
|
||||
save(&path, &flat(BLUE, false));
|
||||
let r = s.execute_checked("file.reloadFootage", json!({"items": [item.0]})).unwrap();
|
||||
assert_eq!(r["reloaded"], 1);
|
||||
assert!(near(pixel(&s, cid, 32, 24), BLUE), "{:?}", pixel(&s, cid, 32, 24));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_keeps_a_photoshop_layer_item_on_its_layer() {
|
||||
let path = tmp("layers.psd");
|
||||
let mut d = WDoc::new(64, 48);
|
||||
d.layers = vec![WLayer::solid("Background", Rect::new(0, 0, 64, 48), RED), WLayer::solid("Patch", Rect::new(0, 0, 32, 48), BLUE)];
|
||||
std::fs::write(&path, write(&d)).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "compositionLayerSizes"})).unwrap();
|
||||
let cid = ItemId(r["comps"][0].as_u64().unwrap());
|
||||
let items: Vec<ItemId> = r["items"].as_array().unwrap().iter().filter_map(|v| v.as_u64()).map(ItemId).collect();
|
||||
let patch = *items.iter().find(|i| s.project.item(**i).is_some_and(|it| it.name.starts_with("Patch/"))).unwrap();
|
||||
assert_eq!((footage(&s, patch).width, footage(&s, patch).layer.unwrap().index), (32, 1));
|
||||
assert!(near(pixel(&s, cid, 8, 24), BLUE));
|
||||
// In the other app: a layer added under the patch, and the patch narrower and green.
|
||||
d.layers = vec![
|
||||
WLayer::solid("Background", Rect::new(0, 0, 64, 48), RED),
|
||||
WLayer::solid("Added", Rect::new(40, 0, 8, 8), RED),
|
||||
WLayer::solid("Patch", Rect::new(0, 0, 24, 48), GREEN),
|
||||
];
|
||||
save(&path, &write(&d));
|
||||
let footage_items: Vec<u64> = items.iter().filter(|i| matches!(s.project.item(**i).map(|x| &x.kind), Some(ItemKind::Footage(_)))).map(|i| i.0).collect();
|
||||
s.execute_checked("file.reloadFootage", json!({"items": footage_items})).unwrap();
|
||||
let f = footage(&s, patch);
|
||||
let l = f.layer.clone().expect("still one layer of the file");
|
||||
assert_eq!((l.name.as_str(), l.index, f.width, f.height), ("Patch", 2, 24, 48));
|
||||
assert!(!f.missing);
|
||||
assert!(near(pixel(&s, cid, 8, 24), GREEN), "{:?}", pixel(&s, cid, 8, 24));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_footage_reloads_by_itself_without_an_undo_step() {
|
||||
let path = tmp("auto.psd");
|
||||
std::fs::write(&path, flat(RED, false)).unwrap();
|
||||
let (mut s, _, cid) = flat_in_comp(&path);
|
||||
s.mark_saved();
|
||||
let undo = s.history.undo.len();
|
||||
// First look: remembered, nothing to reload.
|
||||
assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0}));
|
||||
save(&path, &flat(BLUE, true));
|
||||
assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 1, "reloaded": 1}));
|
||||
assert!(near(pixel(&s, cid, 32, 24), BLUE));
|
||||
assert_eq!(s.history.undo.len(), undo, "the disk changed, not the project");
|
||||
assert!(!s.is_dirty());
|
||||
assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_still_being_written_is_tried_again() {
|
||||
let path = tmp("partial.psd");
|
||||
std::fs::write(&path, flat(RED, false)).unwrap();
|
||||
let (mut s, item, cid) = flat_in_comp(&path);
|
||||
s.execute_checked("footage.reloadChanged", json!({})).unwrap();
|
||||
// Another app has only written the start of the file so far.
|
||||
let full = flat(BLUE, true);
|
||||
save(&path, &full[..12]);
|
||||
assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0}));
|
||||
assert!(!footage(&s, item).missing, "a file being written isn't missing");
|
||||
// Meanwhile it renders as whatever part of it can be decoded (here nothing); the next scan,
|
||||
// after the other app has finished, reads it whole.
|
||||
save(&path, &full);
|
||||
assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 1, "reloaded": 1}));
|
||||
assert!(near(pixel(&s, cid, 32, 24), BLUE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_scan_reloads_changed_footage() {
|
||||
let path = tmp("scan.psd");
|
||||
std::fs::write(&path, flat(RED, false)).unwrap();
|
||||
let (mut s, _, cid) = flat_in_comp(&path);
|
||||
let scan = |s: &mut Session| {
|
||||
footage_reload::start_scan(s);
|
||||
let t0 = std::time::Instant::now();
|
||||
loop {
|
||||
if let Some(r) = footage_reload::poll_scan(s) {
|
||||
return r.unwrap();
|
||||
}
|
||||
assert!(t0.elapsed().as_secs() < 10, "scan never finished");
|
||||
std::thread::yield_now();
|
||||
}
|
||||
};
|
||||
assert_eq!(scan(&mut s), json!({"files": 0, "reloaded": 0}));
|
||||
save(&path, &flat(GREEN, true));
|
||||
assert_eq!(scan(&mut s), json!({"files": 1, "reloaded": 1}));
|
||||
assert!(near(pixel(&s, cid, 32, 24), GREEN));
|
||||
assert!(s.footage_scan.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_sequences_reload_by_themselves_only_with_all_footage() {
|
||||
let png = |path: &str, v: u8| {
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
image::RgbaImage::from_pixel(8, 8, image::Rgba([v, 0, 0, 255])).save(path).unwrap();
|
||||
};
|
||||
let (a, b) = (tmp("seq_0001.png"), tmp("seq_0002.png"));
|
||||
png(&a, 255);
|
||||
png(&b, 255);
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [&a], "sequence": true})).unwrap();
|
||||
let item = ItemId(r["items"][0].as_u64().unwrap());
|
||||
assert_eq!(footage(&s, item).sequence.len(), 2, "imported as a sequence");
|
||||
// Non-Sequence Footage (the default, as in After Effects): sequences are never stamped.
|
||||
assert_eq!(s.prefs.import.auto_reload_footage, "nonSequence");
|
||||
assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0}));
|
||||
png(&a, 0);
|
||||
assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0}));
|
||||
// All Footage: every frame is watched (first seen now, so only remembered).
|
||||
s.prefs.import.auto_reload_footage = "all".into();
|
||||
assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0}));
|
||||
std::fs::write(&b, b"not yet a png").unwrap();
|
||||
png(&b, 128);
|
||||
assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap()["files"], json!(1));
|
||||
}
|
||||
|
||||
/// #1070: a file decoded in one project and overwritten with one of the same size is read again
|
||||
/// when a new project imports it (neither File ▸ Reload Footage nor the change watcher knows it).
|
||||
#[test]
|
||||
fn a_new_project_importing_an_overwritten_file_shows_the_new_pixels() {
|
||||
let path = tmp("overwritten.psd");
|
||||
std::fs::write(&path, flat(RED, false)).unwrap();
|
||||
let (mut s, _item, cid) = flat_in_comp(&path);
|
||||
assert!(near(pixel(&s, cid, 32, 24), RED));
|
||||
// Another app overwrites the file with the same number of bytes.
|
||||
save(&path, &flat(BLUE, false));
|
||||
s.execute_checked("file.newProject", json!({})).unwrap();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path.clone()]})).unwrap();
|
||||
let item = r["items"][0].as_u64().unwrap();
|
||||
s.execute_checked("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 30, "duration": 1})).unwrap();
|
||||
s.execute_checked("layer.addItem", json!({"item": item})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
assert!(near(pixel(&s, cid, 32, 24), BLUE), "stale pixels from the earlier project: {:?}", pixel(&s, cid, 32, 24));
|
||||
}
|
||||
|
||||
fn layered_comp(s: &mut Session, path: &str, import_as: &str) -> ItemId {
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": import_as})).unwrap();
|
||||
ItemId(r["comps"][0].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// Composition imports create their sources before probing ordinary footage (#1070).
|
||||
#[test]
|
||||
fn fresh_layered_composition_import_refreshes_its_sources() {
|
||||
for import_as in ["composition", "compositionLayerSizes"] {
|
||||
let path = tmp(&format!("fresh-{import_as}.psd"));
|
||||
let (red, blue) = (flat(RED, false), flat(BLUE, false));
|
||||
assert_eq!(red.len(), blue.len(), "the overwritten file has the same byte size");
|
||||
std::fs::write(&path, red).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let old_comp = layered_comp(&mut s, &path, import_as);
|
||||
assert!(near(pixel(&s, old_comp, 32, 24), RED));
|
||||
s.execute_checked("file.newProject", json!({})).unwrap();
|
||||
save(&path, &blue);
|
||||
s.drain_events();
|
||||
let comp = layered_comp(&mut s, &path, import_as);
|
||||
assert_eq!(comp, old_comp, "new projects reuse their item ids");
|
||||
assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview");
|
||||
assert!(near(pixel(&s, comp, 32, 24), BLUE), "layered import showed {:?}", pixel(&s, comp, 32, 24));
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layered_reimport_refreshes_existing_layers_and_keeps_unrelated_decoded_files() {
|
||||
let path = tmp("layered-reimport.psd");
|
||||
let other = tmp("layered-unchanged.psd");
|
||||
std::fs::write(&path, flat(RED, false)).unwrap();
|
||||
std::fs::write(&other, flat(GREEN, false)).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let comp = layered_comp(&mut s, &path, "compositionLayerSizes");
|
||||
let r = s.execute_checked("file.import", json!({"paths": [&other]})).unwrap();
|
||||
let other_item = ItemId(r["items"][0].as_u64().unwrap());
|
||||
let other_footage = footage(&s, other_item);
|
||||
let cached = s.footage.frame(other_item, &other_footage, Tick::ZERO).unwrap();
|
||||
assert!(near(pixel(&s, comp, 32, 24), RED));
|
||||
let before = s.project.clone();
|
||||
let undo = s.history.undo.len();
|
||||
save(&path, &flat(BLUE, false));
|
||||
s.drain_events();
|
||||
layered_comp(&mut s, &path, "compositionLayerSizes");
|
||||
assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview");
|
||||
assert!(near(pixel(&s, comp, 32, 24), BLUE), "existing layers see the refreshed source");
|
||||
assert!(std::sync::Arc::ptr_eq(&cached, &s.footage.frame(other_item, &other_footage, Tick::ZERO).unwrap()));
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "composition import stays one undo step");
|
||||
s.undo();
|
||||
assert!(std::sync::Arc::ptr_eq(&s.project, &before));
|
||||
assert!(near(pixel(&s, comp, 32, 24), BLUE), "undo does not undo an external file write");
|
||||
std::fs::remove_file(path).unwrap();
|
||||
std::fs::remove_file(other).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_import_refreshes_layered_sources_before_the_probe_job_finishes() {
|
||||
let path = tmp("layered-background.psd");
|
||||
let other = tmp("layered-background.png");
|
||||
std::fs::write(&path, flat(RED, false)).unwrap();
|
||||
image::RgbaImage::from_pixel(64, 48, image::Rgba([0, 255, 0, 255])).save(&other).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let comp = layered_comp(&mut s, &path, "compositionLayerSizes");
|
||||
assert!(near(pixel(&s, comp, 32, 24), RED));
|
||||
save(&path, &flat(BLUE, false));
|
||||
s.drain_events();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [&path, &other], "importAs": "compositionLayerSizes", "background": true})).unwrap();
|
||||
assert_eq!(r["started"], true, "ordinary footage is probed in a background job");
|
||||
assert!(near(pixel(&s, comp, 32, 24), BLUE), "composition sources are refreshed before the job is applied");
|
||||
s.wait_jobs();
|
||||
assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview");
|
||||
assert!(near(pixel(&s, s.active_comp_id().unwrap(), 32, 24), BLUE));
|
||||
std::fs::remove_file(path).unwrap();
|
||||
std::fs::remove_file(other).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Animated GIF footage through the wired session (#651): the imported item is a movie whose
|
||||
//! frames follow the file's own delays in preview rendering, not one frozen still.
|
||||
|
||||
use effectcraft_engine::render::RenderOpts;
|
||||
use effectcraft_project::{ItemId, ItemKind};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let d = std::env::temp_dir().join(format!("effectcraft-host-gif-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// A 3-frame 16×16 GIF: red, green, blue, one tenth of a second each.
|
||||
fn animated_gif() -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut enc = gif::Encoder::new(&mut out, 16, 16, &[]).unwrap();
|
||||
for rgb in [[255u8, 0, 0], [0, 255, 0], [0, 0, 255]] {
|
||||
let mut f = gif::Frame::from_rgb(16, 16, &rgb.repeat(256));
|
||||
f.delay = 10;
|
||||
enc.write_frame(&f).unwrap();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn centre(s: &effectcraft_engine::Session, comp: ItemId, t: f64) -> [f32; 3] {
|
||||
let img = s.render(comp, Tick::from_seconds_f64(t), RenderOpts::default());
|
||||
let p = img.data[8 * 16 + 8];
|
||||
[p[0], p[1], p[2]]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_imported_animated_gif_plays_its_frames() {
|
||||
let mut s = effectcraft_host::session();
|
||||
let path = tmp("anim.gif");
|
||||
std::fs::write(&path, animated_gif()).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Gif", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let before = s.project.items.len();
|
||||
s.execute("file.import", json!({"paths": [path.clone()], "addToComp": true})).unwrap();
|
||||
let f = match s.project.items.values().find_map(|i| match &i.kind {
|
||||
ItemKind::Footage(f) if f.path == path => Some(f),
|
||||
_ => None,
|
||||
}) {
|
||||
Some(f) => f.clone(),
|
||||
None => panic!("no footage item for the GIF"),
|
||||
};
|
||||
assert_eq!(s.project.items.len(), before + 1, "one footage item, not a comp");
|
||||
assert_eq!((f.codec.as_str(), f.has_video), ("GIF", true), "probed as an animated movie");
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let (r, g, b) = (centre(&s, cid, 0.05), centre(&s, cid, 0.15), centre(&s, cid, 0.25));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(r[0] > 0.9 && r[1] < 0.1, "frame at 0.05 s is red, got {r:?}");
|
||||
assert!(g[1] > 0.9 && g[0] < 0.1, "frame at 0.15 s is green, got {g:?}");
|
||||
assert!(b[2] > 0.9 && b[0] < 0.1, "frame at 0.25 s is blue, got {b:?}");
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod background_presets;
|
||||
mod cryptomatte_pick;
|
||||
mod determinism;
|
||||
mod exr_layers;
|
||||
mod footage_reload;
|
||||
mod gif_animates;
|
||||
mod plugins;
|
||||
mod relink;
|
||||
mod tga_import;
|
||||
mod timeline_interop;
|
||||
mod vector_import;
|
||||
mod webm_metadata;
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Effect plug-ins in a full session: a Rust plug-in registered through the trait and the
|
||||
//! example WebAssembly plug-in (examples/plugins/posterize-bands) loaded with
|
||||
//! `effect.plugins.load`, both applied by id like built-ins and rendered.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::effects::plugin::{EffectPlugin, PluginFrame, PluginManifest, PluginParam, PluginParamKind, PluginParams, register_plugin};
|
||||
use effectcraft_engine::time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
struct Swap {
|
||||
m: PluginManifest,
|
||||
}
|
||||
|
||||
impl EffectPlugin for Swap {
|
||||
fn manifest(&self) -> &PluginManifest {
|
||||
&self.m
|
||||
}
|
||||
fn render(&self, frame: &mut PluginFrame, params: &PluginParams, _time: f64) -> Result<(), String> {
|
||||
if params.b("on") {
|
||||
for p in frame.pixels.iter_mut() {
|
||||
p.swap(0, 2);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn setup() -> (Session, u64) {
|
||||
let mut s = effectcraft_host::session();
|
||||
s.execute("comp.new", json!({"name": "P", "width": 40, "height": 20, "duration": 1})).unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"color": [1.0, 0.5, 0.0], "width": 40, "height": 20})).unwrap()["layer"].as_u64().unwrap();
|
||||
(s, l)
|
||||
}
|
||||
|
||||
fn px(s: &Session) -> [f32; 4] {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
s.render(cid, Tick::ZERO, Default::default()).get(20, 10)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_plugins_apply_by_id() {
|
||||
let m = PluginManifest {
|
||||
api: 1,
|
||||
id: "org.test.swap".into(),
|
||||
name: "Swap Red Blue".into(),
|
||||
category: "Channel".into(),
|
||||
version: "1".into(),
|
||||
author: String::new(),
|
||||
description: String::new(),
|
||||
params: vec![PluginParam { id: "on".into(), name: "On".into(), kind: PluginParamKind::Checkbox { default: true } }],
|
||||
};
|
||||
register_plugin(Arc::new(Swap { m })).unwrap();
|
||||
let (mut s, l) = setup();
|
||||
s.execute("effect.apply", json!({"layer": l, "effect": "org.test.swap"})).unwrap();
|
||||
let p = px(&s);
|
||||
assert!(p[0] < 0.01 && (p[1] - 0.5).abs() < 0.01 && p[2] > 0.99, "{p:?}");
|
||||
// Its parameters are ordinary properties: set (and animate) them like any effect's.
|
||||
s.execute("prop.set", json!({"layer": l, "path": "effects/#1/on", "value": false})).unwrap();
|
||||
let p = px(&s);
|
||||
assert!(p[0] > 0.99 && p[2] < 0.01, "{p:?}");
|
||||
let list = s.execute("effect.plugins.list", json!({})).unwrap();
|
||||
assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["id"] == "org.test.swap" && p["category"] == "Channel"));
|
||||
assert_eq!(list["api"], 1);
|
||||
assert!(s.execute("effect.list", json!({"filter": "swap red"})).unwrap().as_array().unwrap().len() == 1);
|
||||
}
|
||||
|
||||
/// Build the example plug-in (skipped when the wasm32 target isn't installed).
|
||||
fn build_example() -> Option<std::path::PathBuf> {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/plugins/posterize-bands");
|
||||
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| root.join("../../../target")).join("plugin-example");
|
||||
let target = if target.is_absolute() { target } else { root.join("../../..").join(target) };
|
||||
let st = std::process::Command::new(env!("CARGO"))
|
||||
.current_dir(&root)
|
||||
.args(["build", "--release", "--target", "wasm32-unknown-unknown", "--target-dir"])
|
||||
.arg(&target)
|
||||
.status()
|
||||
.ok()?;
|
||||
if !st.success() {
|
||||
eprintln!("skipping: the example plug-in didn't build (rustup target add wasm32-unknown-unknown)");
|
||||
return None;
|
||||
}
|
||||
Some(target.join("wasm32-unknown-unknown/release/posterize_bands.wasm"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn example_wasm_plugin_loads_and_renders() {
|
||||
let Some(wasm) = build_example() else { return };
|
||||
let (mut s, l) = setup();
|
||||
let r = s.execute("effect.plugins.load", json!({"path": wasm.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["loaded"][0]["id"], "org.effectcraft.example.posterize-bands", "{r}");
|
||||
s.execute("effect.apply", json!({"layer": l, "effect": "Posterize Bands"})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l, "path": "effects/#1/mix", "value": 0})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l, "path": "effects/#1/levels", "value": 2})).unwrap();
|
||||
// Luminance of (1, .5, 0) is ~0.57: with 2 levels it bands to 1, scaling the colour by 1/y
|
||||
// (8 bpc: red clips at 1).
|
||||
let p = px(&s);
|
||||
let y = 0.2126 + 0.7152 * 0.5;
|
||||
assert!((p[1] - 0.5 / y as f32).abs() < 0.01 && p[0] > 0.99 && p[2] < 0.01, "{p:?}");
|
||||
// Invert: band 0.
|
||||
s.execute("prop.set", json!({"layer": l, "path": "effects/#1/invert", "value": true})).unwrap();
|
||||
assert_eq!(px(&s)[..3], [0.0, 0.0, 0.0]);
|
||||
let list = s.execute("effect.plugins.list", json!({})).unwrap();
|
||||
assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["source"].as_str().unwrap_or("").ends_with(".wasm")));
|
||||
}
|
||||
|
||||
/// A parameterless plug-in module (WebAssembly text): `render` is `ec_render`'s body.
|
||||
fn wat_plugin(id: &str, api: i32, render: &str) -> String {
|
||||
let manifest = format!(r#"{{"api":1,"id":"{id}","name":"Host {id}","category":"Test Plug-ins","version":"1","params":[]}}"#);
|
||||
let len = manifest.len();
|
||||
let escaped = manifest.replace('"', "\\\"");
|
||||
format!(
|
||||
r#"(module
|
||||
(memory (export "memory") 2)
|
||||
(data (i32.const 16) "{escaped}")
|
||||
(func (export "ec_api_version") (result i32) i32.const {api})
|
||||
(func (export "ec_manifest_ptr") (result i32) i32.const 16)
|
||||
(func (export "ec_manifest_len") (result i32) i32.const {len})
|
||||
(func (export "ec_alloc") (param i32) (result i32) i32.const 1024)
|
||||
(func (export "ec_render") (param i32 i32 i32 i32 i32 f64 f64) (result i32) {render}))"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_loading_reports_bad_modules_and_survives_failing_renders() {
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-host-plugins-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let files = [
|
||||
("a-good.wasm", wat_plugin("org.test.host.good", 1, "i32.const 0").into_bytes()),
|
||||
("b-garbage.wasm", b"\0asm\x01\0\0\0garbage".to_vec()),
|
||||
("c-v2.wasm", wat_plugin("org.test.host.v2", 2, "i32.const 0").into_bytes()),
|
||||
("d-empty.wasm", vec![]),
|
||||
("e-trap.wasm", wat_plugin("org.test.host.trap", 1, "unreachable").into_bytes()),
|
||||
("notes.txt", b"not a plug-in".to_vec()),
|
||||
];
|
||||
for (name, bytes) in &files {
|
||||
std::fs::write(dir.join(name), bytes).unwrap();
|
||||
}
|
||||
let (mut s, l) = setup();
|
||||
let r = s.execute("effect.plugins.load", json!({"folder": dir.to_string_lossy()})).unwrap();
|
||||
let ids: Vec<&str> = r["loaded"].as_array().unwrap().iter().filter_map(|v| v["id"].as_str()).collect();
|
||||
assert_eq!(ids, ["org.test.host.good", "org.test.host.trap"], "{r}");
|
||||
let errs: Vec<String> = r["errors"].as_array().unwrap().iter().map(|e| e["path"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(errs.len(), 3, "{r}");
|
||||
assert!(errs.iter().all(|p| !p.ends_with(".txt")));
|
||||
assert!(r["errors"].to_string().contains("API 2"), "{r}");
|
||||
// A single file: errors come back as command errors; loading twice is refused.
|
||||
let good = dir.join("a-good.wasm").to_string_lossy().to_string();
|
||||
assert!(s.execute("effect.plugins.load", json!({"path": good})).unwrap_err().to_string().contains("already registered"));
|
||||
assert!(s.execute("effect.plugins.load", json!({"path": dir.join("missing.wasm").to_string_lossy()})).is_err());
|
||||
assert!(s.execute("effect.plugins.load", json!({"folder": dir.join("missing").to_string_lossy()})).is_err());
|
||||
assert!(s.execute("effect.plugins.load", json!({})).is_err());
|
||||
// A plug-in that traps on every frame leaves the layer as it was.
|
||||
let before = px(&s);
|
||||
s.execute("effect.apply", json!({"layer": l, "effect": "org.test.host.trap"})).unwrap();
|
||||
assert_eq!(px(&s), before);
|
||||
s.execute("effect.apply", json!({"layer": l, "effect": "org.test.host.good"})).unwrap();
|
||||
assert_eq!(px(&s), before);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Relinking footage that moved (docs/footage.md "Replacing and reloading"): Replace Footage on a
|
||||
//! missing item relinks the other missing items that moved with it, as in After Effects.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use effectcraft_engine::project::ItemKind;
|
||||
use serde_json::json;
|
||||
|
||||
fn icon() -> Vec<u8> {
|
||||
std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/app-icon/hicolor/16x16/apps/ai.storyteller.effectcraft.png")).unwrap()
|
||||
}
|
||||
|
||||
fn footage_paths(s: &effectcraft_engine::Session) -> Vec<(u64, String, bool)> {
|
||||
let mut v: Vec<(u64, String, bool)> = s
|
||||
.project
|
||||
.items
|
||||
.values()
|
||||
.filter_map(|i| match &i.kind {
|
||||
ItemKind::Footage(f) => Some((i.id.0, f.path.clone(), f.missing)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_a_missing_file_relinks_the_others_that_moved_with_it() {
|
||||
let root: PathBuf = std::env::temp_dir().join(format!("ec-host-relink-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let old = root.join("Job");
|
||||
std::fs::create_dir_all(old.join("stills")).unwrap();
|
||||
std::fs::write(old.join("stills/a.png"), icon()).unwrap();
|
||||
std::fs::write(old.join("stills/b.png"), icon()).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let paths: Vec<String> = ["stills/a.png", "stills/b.png"].iter().map(|p| old.join(p).to_string_lossy().to_string()).collect();
|
||||
s.execute("file.import", json!({"paths": paths, "sequence": false})).unwrap();
|
||||
let before = footage_paths(&s);
|
||||
assert_eq!(before.len(), 2, "{before:?}");
|
||||
// The job folder moves; both files go missing.
|
||||
let new = root.join("Archive").join("Job");
|
||||
std::fs::create_dir_all(root.join("Archive")).unwrap();
|
||||
std::fs::rename(&old, &new).unwrap();
|
||||
let r = s.execute("footage.check", json!({"wait": true})).unwrap();
|
||||
assert_eq!(r["missing"], 2, "{r}");
|
||||
// Locating one finds the other.
|
||||
let a = before.iter().find(|(_, p, _)| p.ends_with("a.png")).unwrap().0;
|
||||
let r = s.execute("file.replaceFootage", json!({"item": a, "path": new.join("stills").join("a.png").to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["relinked"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
let after = footage_paths(&s);
|
||||
assert!(after.iter().all(|(_, p, missing)| p.starts_with(&*new.to_string_lossy()) && !missing), "{after:?}");
|
||||
// One undo step puts both back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(footage_paths(&s).iter().map(|(_, p, _)| p.clone()).collect::<Vec<_>>(), before.iter().map(|(_, p, _)| p.clone()).collect::<Vec<_>>());
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Targa import through the same host and commands used by the desktop and CLI.
|
||||
|
||||
use effectcraft_engine::render::RenderOpts;
|
||||
use effectcraft_project::{FootageKind, ItemId, ItemKind};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn tga_stills_and_numbered_sequences_import_and_render() {
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-tga-import-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// A generated 4×4, 24-bit, top-origin TGA. No binary fixtures are retained.
|
||||
let write = |name: &str, bgr: [u8; 3]| {
|
||||
let mut bytes = vec![0; 18];
|
||||
bytes[2] = 2;
|
||||
bytes[12] = 4;
|
||||
bytes[14] = 4;
|
||||
bytes[16] = 24;
|
||||
bytes[17] = 0x20;
|
||||
bytes.extend_from_slice(&bgr.repeat(16));
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
path.to_string_lossy().to_string()
|
||||
};
|
||||
let still = write("still.TGA", [0, 0, 255]);
|
||||
let first = write("shot_0001.tga", [0, 0, 255]);
|
||||
write("shot_0002.tga", [255, 0, 0]);
|
||||
for (path, sequence) in [(still, false), (first, true)] {
|
||||
let mut session = effectcraft_host::session();
|
||||
let imported = session.execute_checked("file.import", json!({"paths": [path], "sequence": sequence})).unwrap();
|
||||
assert_eq!(imported["errors"], json!([]), "{imported}");
|
||||
assert_eq!(imported["items"].as_array().unwrap().len(), 1);
|
||||
let item = ItemId(imported["items"][0].as_u64().unwrap());
|
||||
let ItemKind::Footage(footage) = &session.project.item(item).unwrap().kind else { panic!("not footage") };
|
||||
assert_eq!(footage.kind, if sequence { FootageKind::Sequence } else { FootageKind::Still });
|
||||
assert_eq!(footage.sequence.len(), if sequence { 2 } else { 0 });
|
||||
session.execute_checked("comp.new", json!({"name": "TGA", "width": 4, "height": 4, "frameRate": 30, "duration": 1})).unwrap();
|
||||
session.execute_checked("layer.addItem", json!({"item": item.0})).unwrap();
|
||||
let comp = session.active_comp_id().unwrap();
|
||||
let image = session.render(comp, Tick::ZERO, RenderOpts::default());
|
||||
assert_eq!(image.get(2, 2), [1.0, 0.0, 0.0, 1.0]);
|
||||
if sequence {
|
||||
let image = session.render(comp, FrameRate::FPS_30.tick_of(1), RenderOpts::default());
|
||||
assert_eq!(image.get(2, 2), [0.0, 0.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Premiere Pro interop through timeline interchange, with real media: documents written in code
|
||||
//! with FilmCraft's writers are imported (`file.importTimeline`) and rendered; compositions are
|
||||
//! exported (`file.exportTimeline`, with a ProRes 4444 pre-render) and re-imported, and both
|
||||
//! render the same pixels.
|
||||
|
||||
use effectcraft_engine::render::RenderOpts;
|
||||
use effectcraft_interchange::{fc, fc_media, fc_project as fp, fc_time};
|
||||
use effectcraft_project::{ItemId, LayerSource};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
const W: u32 = 64;
|
||||
const H: u32 = 36;
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let d = std::env::temp_dir().join(format!("effectcraft-host-timeline-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn png(name: &str, rgb: [u8; 3]) -> String {
|
||||
let path = tmp(name);
|
||||
image::RgbImage::from_pixel(W, H, image::Rgb(rgb)).save(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn px(img: &Image, x: u32, y: u32) -> [f32; 4] {
|
||||
img.data[(y * img.width + x) as usize]
|
||||
}
|
||||
|
||||
fn close(a: [f32; 4], b: [f32; 3], tol: f32) -> bool {
|
||||
(0..3).all(|c| (a[c] - b[c]).abs() <= tol)
|
||||
}
|
||||
|
||||
fn rate() -> fc_time::FrameRate {
|
||||
fc_time::FrameRate { num: 25, den: 1 }
|
||||
}
|
||||
|
||||
fn secs(s: f64) -> fc_time::Tick {
|
||||
fc_time::Tick::from_seconds_f64(s)
|
||||
}
|
||||
|
||||
fn still(p: &mut fp::Project, path: &str) -> fp::ItemId {
|
||||
let info = fc_media::MediaInfo {
|
||||
name: String::new(),
|
||||
kind: fc_media::MediaKind::Still,
|
||||
duration: secs(10.0),
|
||||
video: Some(fc_media::VideoStreamInfo {
|
||||
width: W,
|
||||
height: H,
|
||||
frame_rate: rate(),
|
||||
par: (1, 1),
|
||||
codec: "png".into(),
|
||||
pixel_format: String::new(),
|
||||
color: Default::default(),
|
||||
has_alpha: false,
|
||||
bitrate: None,
|
||||
hdr: None,
|
||||
}),
|
||||
audio_streams: Vec::new(),
|
||||
container: String::new(),
|
||||
start_timecode: None,
|
||||
file_size: None,
|
||||
};
|
||||
let clip = fp::MediaClip {
|
||||
media: fp::MediaRef::File { path: path.into() },
|
||||
info,
|
||||
interpret: Default::default(),
|
||||
mark_in: None,
|
||||
mark_out: None,
|
||||
markers: vec![],
|
||||
offline: false,
|
||||
proxy: None,
|
||||
identity: None,
|
||||
};
|
||||
let name = path.rsplit('/').next().unwrap().to_string();
|
||||
p.add_item(&name, fp::Label::Iris, fp::ItemKind::Media(clip), None)
|
||||
}
|
||||
|
||||
/// V1: red for 2 s; V2: blue at 50 % opacity and 50 % scale; a cross dissolve from red to green
|
||||
/// at the 2 s cut on V1.
|
||||
fn document(fmt: fc::Format) -> Vec<u8> {
|
||||
let (red, green, blue) = (png("red.png", [255, 0, 0]), png("green.png", [0, 255, 0]), png("blue.png", [0, 0, 255]));
|
||||
let mut p = fp::Project::new("Interop");
|
||||
let (r, g, b) = (still(&mut p, &red), still(&mut p, &green), still(&mut p, &blue));
|
||||
let settings = fp::SequenceSettings { width: W, height: H, frame_rate: rate(), ..Default::default() };
|
||||
let seq = p.new_sequence("Interop", settings, 2, 0, None);
|
||||
let range = |s, d| fc_time::TimeRange::new(secs(s), secs(d));
|
||||
let cr = p.make_track_item(r, fp::TrackKind::Video, secs(0.0), range(0.0, 2.0), rate()).unwrap();
|
||||
let cg = p.make_track_item(g, fp::TrackKind::Video, secs(2.0), range(0.0, 2.0), rate()).unwrap();
|
||||
let mut cb = p.make_track_item(b, fp::TrackKind::Video, secs(0.0), range(0.0, 1.6), rate()).unwrap();
|
||||
cb.effect_mut("motion").unwrap().params.insert("scale".into(), fp::Param::new(fp::ParamValue::Float(50.0)));
|
||||
cb.effect_mut("opacity").unwrap().params.insert("opacity".into(), fp::Param::new(fp::ParamValue::Float(50.0)));
|
||||
let tr = fp::Transition {
|
||||
id: fp::TransitionId(p.alloc_id()),
|
||||
effect: fp::find_effect("cross_dissolve").unwrap().instance(),
|
||||
start: secs(1.6),
|
||||
duration: secs(0.8),
|
||||
from: Some(cr.id),
|
||||
to: Some(cg.id),
|
||||
align: fp::TransitionAlign::CenterAtCut,
|
||||
reverse: false,
|
||||
};
|
||||
let s = p.sequence_mut(seq).unwrap();
|
||||
s.video_tracks[0].items = vec![cr, cg];
|
||||
s.video_tracks[0].transitions = vec![tr];
|
||||
s.video_tracks[1].items = vec![cb];
|
||||
fc::export(&p, seq, fmt, &Default::default()).unwrap().0
|
||||
}
|
||||
|
||||
fn render(s: &effectcraft_engine::Session, cid: ItemId, secs: f64) -> Image {
|
||||
s.render(cid, Tick::from_seconds_f64(secs), RenderOpts::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_timelines_render_like_premiere() {
|
||||
for (fmt, motion) in [(fc::Format::Fcp7Xml, true), (fc::Format::Otio, false)] {
|
||||
let path = tmp(&format!("interop.{}", fmt.extension()));
|
||||
std::fs::write(&path, document(fmt)).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute("file.importTimeline", json!({"path": path})).unwrap();
|
||||
assert!(r["missing"].as_array().unwrap().is_empty(), "{r}");
|
||||
let cid = ItemId(r["comps"][0].as_u64().unwrap());
|
||||
let img = render(&s, cid, 1.0);
|
||||
// Corner: red (V1). Centre: blue at 50 % over red (FCP7 XML carries Motion/Opacity).
|
||||
assert!(close(px(&img, 1, 1), [1.0, 0.0, 0.0], 0.02), "{fmt:?} corner {:?}", px(&img, 1, 1));
|
||||
if motion {
|
||||
assert!(close(px(&img, W / 2, H / 2), [0.5, 0.0, 0.5], 0.06), "{fmt:?} centre {:?}", px(&img, W / 2, H / 2));
|
||||
}
|
||||
// Mid-dissolve: half red, half green.
|
||||
let mid = render(&s, cid, 2.0);
|
||||
assert!(close(px(&mid, 1, 1), [0.5, 0.5, 0.0], 0.08), "{fmt:?} dissolve {:?}", px(&mid, 1, 1));
|
||||
// After: green.
|
||||
assert!(close(px(&render(&s, cid, 3.0), 1, 1), [0.0, 1.0, 0.0], 0.02));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_prerenders_and_reimports_the_same_pixels() {
|
||||
let red = png("x_red.png", [255, 0, 0]);
|
||||
let mut s = effectcraft_host::session();
|
||||
s.execute("comp.new", json!({"name": "Spot", "width": W, "height": H, "frameRate": 25, "duration": 2})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let item = s.execute("file.import", json!({"paths": [red]})).unwrap()["items"][0].as_u64().unwrap();
|
||||
let lr = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": lr, "path": "transform/opacity", "value": 60})).unwrap();
|
||||
// A small solid: a rendered-only layer (Premiere's colour mattes fill the frame).
|
||||
s.execute("layer.newSolid", json!({"color": "#00ffff", "width": 16, "height": 12})).unwrap();
|
||||
let out = tmp("Spot.xml");
|
||||
let r = s.execute("file.exportTimeline", json!({"path": out})).unwrap();
|
||||
assert_eq!(r["format"], "xml");
|
||||
let pre = r["prerendered"].as_array().unwrap();
|
||||
assert_eq!(pre.len(), 1, "{r}");
|
||||
assert!(pre[0].as_str().unwrap().ends_with(".mov") && std::path::Path::new(pre[0].as_str().unwrap()).exists());
|
||||
let text = std::fs::read_to_string(&out).unwrap();
|
||||
assert!(text.contains("<xmeml") && text.contains(".mov"));
|
||||
|
||||
let r2 = s.execute("file.importTimeline", json!({"path": out})).unwrap();
|
||||
assert!(r2["missing"].as_array().unwrap().is_empty(), "{r2}");
|
||||
let back = ItemId(r2["comps"][0].as_u64().unwrap());
|
||||
let c = s.project.comp(back).unwrap();
|
||||
assert_eq!(c.layers.len(), 2);
|
||||
assert!(c.layers.iter().all(|l| matches!(l.source, LayerSource::Footage { .. })));
|
||||
for t in [0.0, 1.0] {
|
||||
let (a, b) = (render(&s, cid, t), render(&s, back, t));
|
||||
for (x, y) in [(1, 1), (W / 2, H / 2), (W - 2, H - 2), (W / 2 - 9, H / 2)] {
|
||||
let (pa, pb) = (px(&a, x, y), px(&b, x, y));
|
||||
assert!(close(pb, [pa[0], pa[1], pa[2]], 0.03), "t={t} ({x},{y}) {pa:?} vs {pb:?}");
|
||||
}
|
||||
}
|
||||
// Native .prproj is refused with a pointer to the XML route.
|
||||
let e = s.execute("file.importTimeline", json!({"path": "/x/a.prproj"})).unwrap_err().to_string();
|
||||
assert!(e.contains("Final Cut Pro XML"), "{e}");
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
//! Photoshop and SVG import through the fully wired session (media decoding included): pixels
|
||||
//! of imported compositions, SVG footage at any scale, and Create Shapes from Vector Layer.
|
||||
|
||||
use effectcraft_engine::render::RenderOpts;
|
||||
use effectcraft_project::ItemId;
|
||||
use effectcraft_psd::Rect;
|
||||
use effectcraft_psd::write::*;
|
||||
use effectcraft_raster::Image;
|
||||
use serde_json::json;
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let d = std::env::temp_dir().join(format!("effectcraft-host-vector-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn mean_diff(a: &Image, b: &Image) -> f32 {
|
||||
assert_eq!((a.width, a.height), (b.width, b.height));
|
||||
let mut s = 0.0;
|
||||
for (p, q) in a.data.iter().zip(&b.data) {
|
||||
for c in 0..4 {
|
||||
s += (p[c] - q[c]).abs();
|
||||
}
|
||||
}
|
||||
s / (a.data.len() * 4) as f32
|
||||
}
|
||||
|
||||
fn psd_doc() -> WDoc {
|
||||
let mut d = WDoc::new(64, 48);
|
||||
let mut masked = WLayer::solid("Masked", Rect::new(30, 8, 24, 24), [0.1, 0.9, 0.3, 1.0]);
|
||||
masked.mask = Some(WMask {
|
||||
rect: Rect::new(30, 8, 24, 24),
|
||||
data: (0..576).map(|i| if (i / 24) < 12 { 1.0 } else { 0.25 }).collect(),
|
||||
default_color: 0,
|
||||
disabled: false,
|
||||
});
|
||||
d.layers = vec![
|
||||
WLayer::solid("Background", Rect::new(0, 0, 64, 48), [0.2, 0.25, 0.3, 1.0]),
|
||||
WLayer::solid("Half Red", Rect::new(4, 4, 30, 20), [1.0, 0.0, 0.0, 1.0]).with_opacity(128),
|
||||
masked,
|
||||
WLayer::group_end(),
|
||||
WLayer::solid("In Group", Rect::new(10, 30, 20, 12), [0.9, 0.9, 0.1, 1.0]),
|
||||
WLayer::group("Group", true, *b"norm"),
|
||||
];
|
||||
d
|
||||
}
|
||||
|
||||
/// Straight-alpha over of the layers as Photoshop composites them (normal blending), as the
|
||||
/// merged image to compare the imported composition with.
|
||||
fn reference(bytes: &[u8]) -> Vec<[f32; 4]> {
|
||||
let p = effectcraft_psd::Psd::parse(bytes.to_vec()).unwrap();
|
||||
let mut acc = vec![[0.0f32; 4]; (p.width * p.height) as usize];
|
||||
for l in p.layers.iter().filter(|l| l.has_pixels() && !l.hidden) {
|
||||
let px = p.layer_pixels(l.index, true).unwrap();
|
||||
let o = l.opacity as f32 / 255.0;
|
||||
for (d, s) in acc.iter_mut().zip(&px.data) {
|
||||
let a = s[3] * o;
|
||||
let oa = a + d[3] * (1.0 - a);
|
||||
for c in 0..3 {
|
||||
d[c] = if oa > 0.0 { (s[c] * a + d[c] * d[3] * (1.0 - a)) / oa } else { 0.0 };
|
||||
}
|
||||
d[3] = oa;
|
||||
}
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_composition_renders_like_the_document() {
|
||||
let mut doc = psd_doc();
|
||||
let bytes = write(&doc);
|
||||
let merged = reference(&bytes);
|
||||
doc.composite = Some(merged.clone());
|
||||
let bytes = write(&doc);
|
||||
let path = tmp("layers.psd");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
// Footage (merged image).
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap();
|
||||
let merged_item = r["items"][0].as_u64().unwrap();
|
||||
// Composition and Composition – Retain Layer Sizes.
|
||||
let mut renders = vec![];
|
||||
for kind in ["composition", "compositionLayerSizes"] {
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": kind})).unwrap();
|
||||
let cid = ItemId(r["comps"][0].as_u64().unwrap());
|
||||
renders.push(s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()));
|
||||
}
|
||||
// The merged footage in a comp of the same size.
|
||||
s.execute("comp.new", json!({"name": "Merged", "width": 64, "height": 48, "frameRate": 30, "duration": 1})).unwrap();
|
||||
s.execute("layer.addItem", json!({"item": merged_item})).unwrap();
|
||||
let mcid = s.active_comp_id().unwrap();
|
||||
let merged_img = s.render(mcid, effectcraft_time::Tick::ZERO, RenderOpts::default());
|
||||
let expected = Image { width: 64, height: 48, data: merged.iter().map(|p| [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]).collect() };
|
||||
assert!(mean_diff(&merged_img, &expected) < 2.0 / 255.0, "merged footage");
|
||||
for (i, img) in renders.iter().enumerate() {
|
||||
let d = mean_diff(img, &expected);
|
||||
assert!(d < 2.0 / 255.0, "import kind {i}: mean diff {d}");
|
||||
}
|
||||
// A single layer as footage (Choose Layer).
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "layer": "Masked"})).unwrap();
|
||||
let it = s.project.item(ItemId(r["items"][0].as_u64().unwrap())).unwrap();
|
||||
assert_eq!(it.name, "Masked/layers.psd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_16bit_cmyk_composition_imports() {
|
||||
let mut doc = psd_doc();
|
||||
doc.depth = 16;
|
||||
doc.mode = effectcraft_psd::ColorMode::Cmyk;
|
||||
doc.rle = false;
|
||||
let bytes = write(&doc);
|
||||
let merged = reference(&bytes);
|
||||
let path = tmp("layers16.psd");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap();
|
||||
let cid = ItemId(r["comps"][0].as_u64().unwrap());
|
||||
let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
|
||||
let expected = Image { width: 64, height: 48, data: merged.iter().map(|p| [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]).collect() };
|
||||
let d = mean_diff(&img, &expected);
|
||||
assert!(d < 2.0 / 255.0, "mean diff {d}");
|
||||
}
|
||||
|
||||
const SVG: &[u8] = include_bytes!("../../../svg/tests/fixtures/basic-shapes.svg");
|
||||
const SVG2: &[u8] = include_bytes!("../../../svg/tests/fixtures/paths-gradients.svg");
|
||||
|
||||
#[test]
|
||||
fn svg_footage_and_shapes_from_vector_layer() {
|
||||
for (name, bytes) in [("basic-shapes.svg", SVG), ("paths-gradients.svg", SVG2)] {
|
||||
let path = tmp(name);
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let item = r["items"][0].as_u64().unwrap();
|
||||
let doc = effectcraft_svg::parse(bytes).unwrap();
|
||||
let (w, h) = doc.pixel_size();
|
||||
s.execute("comp.new", json!({"name": "V", "width": w, "height": h, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let lid = s.execute_checked("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let t = effectcraft_time::Tick::ZERO;
|
||||
let foot = s.render(cid, t, RenderOpts::default());
|
||||
let direct = effectcraft_svg::rasterize(&doc, w, h, 1.0);
|
||||
assert!(mean_diff(&foot, &direct) < 1.0 / 255.0, "{name}: footage = rasterised SVG");
|
||||
s.execute("layer.select", json!({"layers": [lid]})).unwrap();
|
||||
let r = s.execute_checked("layer.create", json!({"op": "shapesFromVector"})).unwrap();
|
||||
let sid = r["layers"][0].as_u64().unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
assert_eq!(c.layers[0].id.0, sid);
|
||||
assert!(!c.layers[1].switches.video);
|
||||
let shapes = s.render(cid, t, RenderOpts::default());
|
||||
let d = mean_diff(&shapes, &direct);
|
||||
assert!(d < 0.02, "{name}: shapes vs SVG mean diff {d}");
|
||||
// Scaled up 3×, the shape layer stays sharp and matches the SVG rasterised at 3×.
|
||||
s.execute("prop.set", json!({"layer": sid, "path": "transform/scale", "value": [300, 300]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": sid, "path": "transform/position", "value": [0, 0]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": sid, "path": "transform/anchor", "value": [0, 0]})).unwrap();
|
||||
let big = s.render(cid, t, RenderOpts::default());
|
||||
let direct3 = effectcraft_svg::rasterize(&doc, w, h, 3.0);
|
||||
let d3 = mean_diff(&big, &direct3);
|
||||
assert!(d3 < 0.03, "{name}: ×3 mean diff {d3}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_footage_continuously_rasterizes() {
|
||||
let path = tmp("cr.svg");
|
||||
std::fs::write(&path, SVG).unwrap();
|
||||
let doc = effectcraft_svg::parse(SVG).unwrap();
|
||||
let (w, h) = doc.pixel_size();
|
||||
let mut s = effectcraft_host::session();
|
||||
let item = s.execute_checked("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "C", "width": w, "height": h, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let lid = s.execute_checked("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (k, v) in [("transform/anchor", json!([0, 0])), ("transform/position", json!([0, 0])), ("transform/scale", json!([300, 300]))] {
|
||||
s.execute("prop.set", json!({"layer": lid, "path": k, "value": v})).unwrap();
|
||||
}
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let t = effectcraft_time::Tick::ZERO;
|
||||
let want = effectcraft_svg::rasterize(&doc, w, h, 3.0);
|
||||
let soft = mean_diff(&s.render(cid, t, RenderOpts::default()), &want);
|
||||
s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "collapse", "value": true})).unwrap();
|
||||
let sharp = mean_diff(&s.render(cid, t, RenderOpts::default()), &want);
|
||||
assert!(sharp < 0.004, "continuously rasterised diff {sharp}");
|
||||
assert!(sharp < soft, "sharper than the upscaled pixels ({sharp} vs {soft})");
|
||||
}
|
||||
|
||||
/// A two-layer Illustrator-style PDF (optional-content layers "Sky" and "Art"): a gradient sky,
|
||||
/// a red curved shape with a dark outline, and a clipped green bar.
|
||||
fn layered_pdf() -> Vec<u8> {
|
||||
let content = "/OC /L0 BDC\n/Pattern cs /P0 scn 0 0 120 80 re f\nEMC\n\
|
||||
/OC /L1 BDC\n1 0 0 rg 0.1 0.1 0.1 RG 3 w 20 20 m 20 60 l 60 70 100 60 v 100 20 l h B\n\
|
||||
q 30 0 40 80 re W n 0 0.8 0 rg 0 35 120 10 re f Q\nEMC\n";
|
||||
let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Resources << /Properties << /L0 5 0 R /L1 6 0 R >> /Pattern << /P0 7 0 R >> >> /Contents 4 0 R >>";
|
||||
let pattern = "<< /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 0 80] /Function << /FunctionType 2 /Domain [0 1] /C0 [0.9 0.9 1] /C1 [0.2 0.4 0.9] /N 1 >> /Extend [true true] >> >>";
|
||||
effectcraft_pdf::write::pdf(
|
||||
&[
|
||||
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
|
||||
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
|
||||
(3, page.into(), None),
|
||||
(4, "<< >>".into(), Some(content.as_bytes().to_vec())),
|
||||
(5, "<< /Type /OCG /Name (Sky) >>".into(), None),
|
||||
(6, "<< /Type /OCG /Name (Art) >>".into(), None),
|
||||
(7, pattern.into(), None),
|
||||
],
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_and_ai_footage_layers_and_shapes_from_vector_layer() {
|
||||
let bytes = layered_pdf();
|
||||
let doc = effectcraft_pdf::parse(&bytes).unwrap();
|
||||
let (w, h) = doc.pixel_size();
|
||||
assert_eq!((w, h), (120, 80));
|
||||
let direct = effectcraft_svg::rasterize(&doc, w, h, 1.0);
|
||||
for name in ["art.pdf", "art.ai"] {
|
||||
let path = tmp(name);
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let item = r["items"][0].as_u64().unwrap();
|
||||
let effectcraft_project::ItemKind::Footage(f) = &s.project.item(ItemId(item)).unwrap().kind else { panic!() };
|
||||
assert_eq!(f.codec, if name.ends_with(".ai") { "AI" } else { "PDF" });
|
||||
assert_eq!((f.width, f.height), (120, 80));
|
||||
s.execute("comp.new", json!({"name": "V", "width": w, "height": h, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let lid = s.execute_checked("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let t = effectcraft_time::Tick::ZERO;
|
||||
let foot = s.render(cid, t, RenderOpts::default());
|
||||
assert!(mean_diff(&foot, &direct) < 1.0 / 255.0, "{name}: footage = rasterised page");
|
||||
// Continuously Rasterize at 300 %.
|
||||
for (k, v) in [("transform/anchor", json!([0, 0])), ("transform/position", json!([0, 0])), ("transform/scale", json!([300, 300]))] {
|
||||
s.execute("prop.set", json!({"layer": lid, "path": k, "value": v})).unwrap();
|
||||
}
|
||||
let want = effectcraft_svg::rasterize(&doc, w, h, 3.0);
|
||||
let soft = mean_diff(&s.render(cid, t, RenderOpts::default()), &want);
|
||||
s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "collapse", "value": true})).unwrap();
|
||||
let sharp = mean_diff(&s.render(cid, t, RenderOpts::default()), &want);
|
||||
assert!(sharp < 0.004 && sharp < soft, "{name}: continuously rasterised {sharp} vs {soft}");
|
||||
s.execute("prop.set", json!({"layer": lid, "path": "transform/scale", "value": [100, 100]})).unwrap();
|
||||
// Create Shapes from Vector Layer.
|
||||
s.execute("layer.select", json!({"layers": [lid]})).unwrap();
|
||||
let r = s.execute_checked("layer.create", json!({"op": "shapesFromVector"})).unwrap();
|
||||
let sid = r["layers"][0].as_u64().unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
assert_eq!(c.layers[0].id.0, sid);
|
||||
let shapes = s.render(cid, t, RenderOpts::default());
|
||||
// The clipped bar (rows 33–47) keeps its clip through Merge Paths ▸ Intersect.
|
||||
let d = mean_diff(&shapes, &direct);
|
||||
assert!(d < 0.01, "{name}: shapes vs page mean diff {d}");
|
||||
for x in [10, 50, 100] {
|
||||
let (a, b) = (shapes.get(x, 40), direct.get(x, 40));
|
||||
assert!((0..4).all(|c| (a[c] - b[c]).abs() < 0.02), "{name}: bar clipped at x = {x}: {a:?} vs {b:?}");
|
||||
}
|
||||
let fill = shapes.get(60, 30);
|
||||
assert!(fill[0] > 0.9 && fill[1] < 0.1, "red shape {fill:?}");
|
||||
}
|
||||
// Import As: Composition — one layer per file layer, top first, together like the page.
|
||||
let path = tmp("layers.ai");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
for import_as in ["composition", "compositionLayerSizes"] {
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": import_as})).unwrap();
|
||||
let cid = ItemId(r["comps"][0].as_u64().unwrap());
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
let names: Vec<&str> = comp.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["Art", "Sky"]);
|
||||
assert_eq!((comp.width, comp.height), (120, 80));
|
||||
let art = comp.layers[0].id.0;
|
||||
let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
|
||||
let d = mean_diff(&img, &direct);
|
||||
assert!(d < 1.0 / 255.0, "layered comp vs page {d}");
|
||||
// Each layer's footage shows only its layer.
|
||||
s.execute("layer.setSwitch", json!({"layers": [art], "switch": "video", "value": false})).unwrap();
|
||||
let sky = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
|
||||
assert_eq!(sky.get(5, 5), img.get(5, 5));
|
||||
let p = sky.get(60, 30);
|
||||
assert!(p[2] > p[0], "only sky under the art {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_smart_object_imports_its_embedded_document() {
|
||||
// The embedded document: red left half, green right half (20×10).
|
||||
let mut inner = WDoc::new(20, 10);
|
||||
inner.layers =
|
||||
vec![WLayer::solid("Red", Rect::new(0, 0, 10, 10), [1.0, 0.0, 0.0, 1.0]), WLayer::solid("Green", Rect::new(10, 0, 10, 10), [0.0, 1.0, 0.0, 1.0])];
|
||||
let inner_bytes = write(&inner);
|
||||
let mut d = WDoc::new(64, 48);
|
||||
// Placed at 2× and turned a quarter clockwise: the content's top-left corner at (40, 4).
|
||||
let quad = [[40.0, 4.0], [40.0, 44.0], [20.0, 44.0], [20.0, 4.0]];
|
||||
d.layers = vec![
|
||||
WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]),
|
||||
// Placeholder pixels (grey) that the embedded file replaces.
|
||||
WLayer::solid("Placed", Rect::new(20, 4, 20, 40), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block("so-1", quad, [20.0, 10.0])),
|
||||
];
|
||||
d.linked = vec![("so-1".into(), "inner.psd".into(), *b"8BPS", inner_bytes)];
|
||||
let path = tmp("smart.psd");
|
||||
std::fs::write(&path, write(&d)).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let cid = ItemId(r["comps"][0].as_u64().unwrap());
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
let placed = comp.layers.iter().find(|l| l.name == "Placed").unwrap();
|
||||
let effectcraft_project::LayerSource::Footage { item } = placed.source else { panic!("footage layer") };
|
||||
let effectcraft_project::ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!() };
|
||||
assert_eq!((f.width, f.height), (20, 10));
|
||||
assert_eq!(f.layer.as_ref().unwrap().embedded.as_deref(), Some("so-1"));
|
||||
assert!(s.project.item(item).unwrap().name.contains("inner.psd"));
|
||||
let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
|
||||
// Rotated a quarter clockwise: the red half is on top (rows 4–24), green below.
|
||||
let (red, green) = (img.get(30, 14), img.get(30, 34));
|
||||
assert!(red[0] > 0.95 && red[1] < 0.05, "{red:?}");
|
||||
assert!(green[1] > 0.95 && green[0] < 0.05, "{green:?}");
|
||||
assert!(img.get(10, 24)[0] < 0.01 && img.get(10, 24)[3] > 0.99, "background outside");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_footage_imports() {
|
||||
let eps =
|
||||
b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 40 20\n1 0 0 setrgbcolor 0 0 20 20 rectfill 0 0 1 setrgbcolor newpath 30 10 8 0 360 arc fill\n%%EOF\n";
|
||||
let path = tmp("shape.eps");
|
||||
std::fs::write(&path, eps).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let item = r["items"][0].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "E", "width": 40, "height": 20, "frameRate": 30, "duration": 1})).unwrap();
|
||||
s.execute_checked("layer.addItem", json!({"item": item})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
|
||||
assert!(img.get(10, 10)[0] > 0.99 && img.get(30, 10)[2] > 0.99, "{:?} {:?}", img.get(10, 10), img.get(30, 10));
|
||||
}
|
||||
|
||||
/// A two-page PDF: page 2 has text in a non-embedded standard font inside a page-wide clip.
|
||||
fn two_page_pdf() -> Vec<u8> {
|
||||
effectcraft_pdf::write::pdf(
|
||||
&[
|
||||
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
|
||||
(2, "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>".into(), None),
|
||||
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Contents 4 0 R >>".into(), None),
|
||||
(4, "<< >>".into(), Some(b"1 0 0 rg 0 0 120 80 re f".to_vec())),
|
||||
(
|
||||
5,
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 60] /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> >> >> /Contents 6 0 R >>".into(),
|
||||
None,
|
||||
),
|
||||
(6, "<< >>".into(), Some(b"10 5 80 50 re W n 0 0 1 rg 0 0 100 60 re f 1 1 0 rg BT /F1 40 Tf 15 15 Td (Hi) Tj ET".to_vec())),
|
||||
],
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_pages_text_and_clips_to_masks() {
|
||||
let bytes = two_page_pdf();
|
||||
let path = tmp("pages.pdf");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
// Page 2 as footage (File ▸ Import ▸ Page), named after its page.
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "page": 2})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let item = ItemId(r["items"][0].as_u64().unwrap());
|
||||
let it = s.project.item(item).unwrap();
|
||||
assert_eq!(it.name, "pages.pdf (Page 2)");
|
||||
let effectcraft_project::ItemKind::Footage(f) = &it.kind else { panic!() };
|
||||
assert_eq!((f.width, f.height, f.page), (100, 60, 1));
|
||||
assert!(s.execute_checked("file.import", json!({"paths": [path], "page": 3})).unwrap()["errors"][0].as_str().unwrap().contains("no page 3"));
|
||||
assert!(s.execute("file.import", json!({"paths": [path], "page": 0})).is_err());
|
||||
let doc = effectcraft_pdf::parse_page(&bytes, 1).unwrap();
|
||||
let direct = effectcraft_svg::rasterize(&doc, 100, 60, 1.0);
|
||||
s.execute("comp.new", json!({"name": "P", "width": 100, "height": 60, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let lid = s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let t = effectcraft_time::Tick::ZERO;
|
||||
let foot = s.render(cid, t, RenderOpts::default());
|
||||
assert!(mean_diff(&foot, &direct) < 1.0 / 255.0, "page 2 footage");
|
||||
// Text drew (yellow glyphs over the blue page).
|
||||
let ink = (0..60).flat_map(|y| (0..100).map(move |x| (x, y))).filter(|&(x, y)| foot.get(x, y)[0] > 0.9).count();
|
||||
assert!(ink > 100, "text pixels {ink}");
|
||||
// Create Shapes from Vector Layer: the page-wide clip becomes the layer's mask, the text a
|
||||
// shape group named after it.
|
||||
s.execute("layer.select", json!({"layers": [lid]})).unwrap();
|
||||
let sid = s.execute_checked("layer.create", json!({"op": "shapesFromVector"})).unwrap()["layers"][0].as_u64().unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
let l = c.layer(effectcraft_project::LayerId(sid)).unwrap();
|
||||
assert_eq!(l.masks().map(|m| m.groups().count()), Some(1));
|
||||
fn names(g: &effectcraft_project::PropGroup, out: &mut Vec<String>) {
|
||||
for c in g.groups() {
|
||||
out.push(c.name.clone());
|
||||
names(c, out);
|
||||
}
|
||||
}
|
||||
let mut names_found = vec![];
|
||||
names(l.props.sub("contents").unwrap(), &mut names_found);
|
||||
let names = names_found;
|
||||
assert!(names.iter().any(|n| n == "Text: Hi"), "{names:?}");
|
||||
let shapes = s.render(cid, t, RenderOpts::default());
|
||||
let d = mean_diff(&shapes, &direct);
|
||||
assert!(d < 0.01, "shapes vs page {d}");
|
||||
assert_eq!(shapes.get(5, 30)[3], 0.0, "masked outside the clip");
|
||||
// Page 1 as a composition.
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "page": 1})).unwrap();
|
||||
let comp = s.project.comp(ItemId(r["comps"][0].as_u64().unwrap())).unwrap();
|
||||
assert_eq!((comp.width, comp.height), (120, 80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_smart_objects_with_perspective_and_warp_bake_as_placed() {
|
||||
let mut inner = WDoc::new(20, 10);
|
||||
inner.layers =
|
||||
vec![WLayer::solid("Red", Rect::new(0, 0, 10, 10), [1.0, 0.0, 0.0, 1.0]), WLayer::solid("Green", Rect::new(10, 0, 10, 10), [0.0, 1.0, 0.0, 1.0])];
|
||||
let inner_bytes = write(&inner);
|
||||
let mut d = WDoc::new(64, 48);
|
||||
// A perspective trapezoid, and an affine placement with an Arc warp.
|
||||
let trap = [[20.0, 4.0], [44.0, 10.0], [44.0, 38.0], [20.0, 44.0]];
|
||||
let warp = warp_descriptor("warpArc", 50.0, 0.0, 0.0, [0.0, 0.0, 20.0, 10.0], None);
|
||||
d.layers = vec![
|
||||
WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]),
|
||||
WLayer::solid("Pinned", Rect::new(20, 4, 24, 40), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block_warped("so-1", trap, [20.0, 10.0], None)),
|
||||
];
|
||||
d.linked = vec![("so-1".into(), "inner.psd".into(), *b"8BPS", inner_bytes.clone())];
|
||||
let mut d2 = WDoc::new(64, 48);
|
||||
d2.layers = vec![
|
||||
WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]),
|
||||
WLayer::solid("Arc", Rect::new(20, 10, 40, 20), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block_warped(
|
||||
"so-2",
|
||||
[[20.0, 10.0], [60.0, 10.0], [60.0, 30.0], [20.0, 30.0]],
|
||||
[20.0, 10.0],
|
||||
Some(warp),
|
||||
)),
|
||||
];
|
||||
d2.linked = vec![("so-2".into(), "inner.psd".into(), *b"8BPS", inner_bytes)];
|
||||
let parsed = effectcraft_psd::Psd::parse(write(&d2)).unwrap();
|
||||
let so = parsed.layers.iter().find_map(|l| l.smart_object.clone()).unwrap();
|
||||
assert_eq!(so.warp.as_ref().map(|w| w.style.as_str()), Some("warpArc"));
|
||||
let mut s = effectcraft_host::session();
|
||||
let render = |s: &mut effectcraft_engine::Session, doc: &WDoc, file: &str| {
|
||||
let path = tmp(file);
|
||||
std::fs::write(&path, write(doc)).unwrap();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let cid = ItemId(r["comps"][0].as_u64().unwrap());
|
||||
let placed = s.project.comp(cid).unwrap().layers.iter().any(|l| {
|
||||
let effectcraft_project::LayerSource::Footage { item } = l.source else { return false };
|
||||
matches!(&s.project.item(item).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if f.layer.as_ref().is_some_and(|x| x.placed))
|
||||
});
|
||||
assert!(placed, "{file}: baked as placed");
|
||||
s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default())
|
||||
};
|
||||
let img = render(&mut s, &d, "pinned.psd");
|
||||
let (red, green) = (img.get(24, 24), img.get(40, 24));
|
||||
assert!(red[0] > 0.9 && red[1] < 0.1, "{red:?}");
|
||||
assert!(green[1] > 0.9 && green[0] < 0.1, "{green:?}");
|
||||
// Above the slanted top edge (y = 4 + 6·(x − 20)/24): background.
|
||||
assert!(img.get(42, 6)[0] < 0.05 && img.get(42, 6)[1] < 0.05, "{:?}", img.get(42, 6));
|
||||
let img = render(&mut s, &d2, "arc.psd");
|
||||
// The arc keeps the top centre and pulls the corners down and out.
|
||||
let top = img.get(40, 11);
|
||||
assert!(top[0] > 0.5 || top[1] > 0.5, "top centre covered {top:?}");
|
||||
assert!(img.get(21, 11)[0] < 0.05 && img.get(21, 11)[1] < 0.05, "corner region empty {:?}", img.get(21, 11));
|
||||
}
|
||||
|
||||
/// A PDF with two placed images (one turned a quarter) between a background and a shape.
|
||||
fn image_pdf() -> Vec<u8> {
|
||||
let content = "0.9 0.9 0.9 rg 0 0 120 80 re f\nq 60 0 0 30 10 40 cm /Im Do Q\nq 0 40 -20 0 110 10 cm /Im Do Q\n0 0 1 rg 20 5 30 20 re f\n";
|
||||
// 8×8 pixels in quadrants: red, green / white, black.
|
||||
let quad = |x: usize, y: usize| -> [u8; 3] { [[[255, 0, 0], [0, 255, 0]], [[255, 255, 255], [0, 0, 0]]][y / 4][x / 4] };
|
||||
let px: Vec<u8> = (0..64).flat_map(|i| quad(i % 8, i / 8)).collect();
|
||||
effectcraft_pdf::write::pdf(
|
||||
&[
|
||||
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
|
||||
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
|
||||
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >>".into(), None),
|
||||
(4, "<< >>".into(), Some(content.as_bytes().to_vec())),
|
||||
(5, "<< /Type /XObject /Subtype /Image /Width 8 /Height 8 /ColorSpace /DeviceRGB /BitsPerComponent 8 >>".into(), Some(px)),
|
||||
],
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_from_vector_layer_keeps_images_as_footage_layers() {
|
||||
let bytes = image_pdf();
|
||||
let doc = effectcraft_pdf::parse(&bytes).unwrap();
|
||||
let (w, h) = doc.pixel_size();
|
||||
let direct = effectcraft_svg::rasterize(&doc, w, h, 1.0);
|
||||
let path = tmp("images.pdf");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let item = s.execute_checked("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "I", "width": w, "height": h, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let lid = s.execute_checked("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let t = effectcraft_time::Tick::ZERO;
|
||||
let foot = s.render(cid, t, RenderOpts::default());
|
||||
assert!(mean_diff(&foot, &direct) < 1.0 / 255.0);
|
||||
s.execute("layer.select", json!({"layers": [lid]})).unwrap();
|
||||
let r = s.execute_checked("layer.create", json!({"op": "shapesFromVector"})).unwrap();
|
||||
assert_eq!(r["skipped"], json!([]), "{r}");
|
||||
let sid = r["layers"][0].as_u64().unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
// In the document's paint order: the shape drawn last, the two images (the later one
|
||||
// higher), the background drawn first, then the hidden source.
|
||||
let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(names, ["images.pdf Outlines", "Image", "Image", "images.pdf Outlines 1", "images.pdf"]);
|
||||
assert_eq!(c.layers[0].id.0, sid);
|
||||
assert_eq!(r["layers"].as_array().unwrap().len(), 2);
|
||||
for l in &c.layers[1..3] {
|
||||
assert_eq!(l.parent.map(|p| p.0), Some(sid), "{}", l.name);
|
||||
assert!(matches!(l.source, effectcraft_project::LayerSource::Footage { .. }));
|
||||
}
|
||||
assert!(!c.layers[4].switches.video);
|
||||
let shapes = s.render(cid, t, RenderOpts::default());
|
||||
let d = mean_diff(&shapes, &direct);
|
||||
assert!(d < 0.02, "converted vs page: mean diff {d}");
|
||||
// Image pixels in place: quarters of the upright image and of the turned one, and the
|
||||
// shape over the background.
|
||||
for (x, y) in [(25, 17), (55, 17), (25, 32), (55, 32), (95, 60), (95, 40), (105, 40), (30, 65)] {
|
||||
let (a, b) = (shapes.get(x, y), direct.get(x, y));
|
||||
assert!((0..3).all(|k| (a[k] - b[k]).abs() < 0.05), "({x}, {y}): {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn retain_pdf(width: u32) -> Vec<u8> {
|
||||
effectcraft_pdf::write::pdf(
|
||||
&[
|
||||
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
|
||||
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
|
||||
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Resources << /Properties << /L0 5 0 R /L1 6 0 R >> >> /Contents 4 0 R >>".into(), None),
|
||||
(
|
||||
4,
|
||||
"<< >>".into(),
|
||||
Some(
|
||||
format!("/OC /L0 BDC q 20 15 {width} 20 re W n 1 0 0 rg 0 0 120 80 re f Q EMC /OC /L1 BDC 0 0 1 RG 4 w 70 40 m 100 40 l S EMC")
|
||||
.into_bytes(),
|
||||
),
|
||||
),
|
||||
(5, "<< /Type /OCG /Name (Box) >>".into(), None),
|
||||
(6, "<< /Type /OCG /Name (Line) >>".into(), None),
|
||||
],
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_composition_retains_layer_sizes_and_positions() {
|
||||
use effectcraft_project::{ItemKind, LayerSource, Project};
|
||||
use effectcraft_time::Tick;
|
||||
|
||||
for name in ["retain.ai", "retain.pdf"] {
|
||||
let path = tmp(name);
|
||||
let bytes = retain_pdf(30);
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "compositionLayerSizes"})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let cid = ItemId(r["comps"][0].as_u64().unwrap());
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
assert_eq!((comp.width, comp.height), (120, 80));
|
||||
assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Line", "Box"]);
|
||||
let mut ids = vec![];
|
||||
for (name, size, anchor, position) in [("Box", (30, 20), [15.0, 10.0], [35.0, 55.0]), ("Line", (30, 4), [15.0, 2.0], [85.0, 40.0])] {
|
||||
let l = comp.layer_by_name(name).unwrap();
|
||||
let LayerSource::Footage { item } = l.source else { panic!("footage") };
|
||||
let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!("footage") };
|
||||
assert_eq!((f.width, f.height), size, "{name} uses its artwork bounds");
|
||||
assert!(f.layer.as_ref().unwrap().layer_size);
|
||||
assert_eq!(l.props.prop("transform/anchor").unwrap().value.as_vec2(), anchor);
|
||||
assert_eq!(l.props.prop("transform/position").unwrap().value.as_vec2(), position);
|
||||
let frame = s.footage.frame(item, f, Tick::ZERO).unwrap();
|
||||
assert_eq!((frame.width, frame.height), size);
|
||||
let sharp = s.footage.vector_frame(item, f, 2.0).unwrap();
|
||||
assert_eq!((sharp.width, sharp.height), (size.0 * 2, size.1 * 2));
|
||||
assert!(sharp.get(1, 1)[3] > 0.99);
|
||||
ids.push(item);
|
||||
}
|
||||
let direct = effectcraft_svg::rasterize(&effectcraft_pdf::parse(&bytes).unwrap(), 120, 80, 1.0);
|
||||
assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001);
|
||||
let saved = s.project.to_json();
|
||||
s.project = std::sync::Arc::new(Project::from_json(&saved).unwrap());
|
||||
assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001, "reopened project");
|
||||
let box_id = s.active_comp().unwrap().layer_by_name("Box").unwrap().id.0;
|
||||
s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [box_id]})).unwrap();
|
||||
assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001, "shape conversion preserves placement");
|
||||
s.execute_checked("edit.undo", json!({})).unwrap();
|
||||
std::fs::write(&path, retain_pdf(40)).unwrap();
|
||||
s.execute_checked("file.reloadFootage", json!({"items": ids})).unwrap();
|
||||
let ItemKind::Footage(f) = &s.project.item(ids[0]).unwrap().kind else { panic!("footage") };
|
||||
assert_eq!((f.width, f.height), (40, 20), "reload refreshes the layer's own dimensions");
|
||||
let frame = s.footage.frame(ids[0], f, Tick::ZERO).unwrap();
|
||||
assert_eq!((frame.width, frame.height), (40, 20));
|
||||
assert!(frame.get(39, 10)[0] > 0.99, "reload invalidates cached pixels");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! A WebM rendered by EffectCraft and imported back reports its real frame count (#1429). Matroska
|
||||
//! stores the duration in whole milliseconds, so at 30 fps a 1-frame file lasts 33 ms, just short
|
||||
//! of one 33.33 ms frame.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let d = std::env::temp_dir().join(format!("effectcraft-host-webm-meta-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_webm_at_30_fps_reports_every_frame() {
|
||||
for n in [1u32, 4] {
|
||||
let mut s = effectcraft_host::session();
|
||||
let out = tmp(&format!("frames-{n}.webm"));
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 36, "frameRate": 30, "duration": 1})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "BG", "color": "#142850"})).unwrap();
|
||||
let span = json!({"comp": "Main", "format": "webm", "output": out, "timeSpan": "custom", "start": 0, "duration": n as f64 / 30.0});
|
||||
s.execute("renderQueue.add", span).unwrap();
|
||||
s.execute("renderQueue.render", json!({"wait": true})).unwrap();
|
||||
let r = s.execute("file.import", json!({"paths": [out.clone()]})).unwrap();
|
||||
let m = s.execute("item.metadata", json!({"item": r["items"][0]})).unwrap();
|
||||
let _ = std::fs::remove_file(&out);
|
||||
assert_eq!(m["item"]["frames"], json!(n), "{n} rendered frames: {m}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user