Files
effectcraft/crates/host/tests/vector_import.rs
T
Brandon ThomasandClaude Opus 5.5 406706e3d5 OUT-2: WebM export (VP9 intra encoder + alpha, Opus), WAV/AIFF audio-only outputs
- effectcraft-vp9enc (L0): VP9 profile 0 key-frame encoder from the VP9 bitstream spec
  (8x8 intra blocks, DC/V/H/TM, 4x4 DCT/ADST, lossless WHT at quality 100, tile columns);
  round-trips exactly through FilmCraft's VP9 decoder and ffmpeg.
- export: WebM muxer (EBML/Matroska spec) with VP9, VP9 alpha in BlockAdditional
  (AlphaMode 1), Opus (CodecDelay = pre-skip); WAV and AIFF outputs.
- OutputFormat WebM/Wav/Aiff in the Render Queue, renderQueue.add formats, CLI --format.
- Tests: FilmCraft decode-back (frames, size, pixels, audio RMS), ffprobe/ffmpeg oracles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:28:15 -04:00

187 lines
8.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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})");
}