effectcraft/crates/engine/src/tests_proxy.rs

284 lines
14 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.

//! Proxies (Set Proxy, Use Proxy, Proxy Use render settings, comp proxies, Create Proxy) and
//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light), rendered through
//! a synthetic footage source.
use std::sync::Arc;
use effectcraft_project::render_queue::ProxyUse;
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId, ItemKind};
use effectcraft_raster::Image;
use effectcraft_time::{FrameRate, Tick};
use serde_json::json;
use crate::Session;
use crate::render::{FootageSource, RenderOpts};
/// Files by name: `red16.png` (16×16 red), `blue8.png` (8×8 blue), `lines.mov` (interlaced:
/// even rows red, odd rows blue; 4×4, 10 fps), `matte_black.png` / `matte_white.png` (a
/// semi-transparent green premultiplied against black / white, read as straight), `half.png`
/// (white at 25 % alpha), `wide.png` (10×10 white).
struct Synthetic;
fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] {
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("");
match name {
"red16.png" => [1.0, 0.0, 0.0, 1.0],
"blue8.png" => [0.0, 0.0, 1.0, 1.0],
"lines.mov" => {
if y.is_multiple_of(2) {
[1.0, 0.0, 0.0, 1.0]
} else {
[0.0, 0.0, 1.0, 1.0]
}
}
// Straight interpretation of a file whose colour is green·a + matte·(1 − a), a = 0.5.
"matte_black.png" => [0.0, 0.5, 0.0, 0.5],
"matte_white.png" => [0.5, 1.0, 0.5, 0.5],
"half.png" => [1.0, 1.0, 1.0, 0.25],
_ => {
let _ = x;
[1.0, 1.0, 1.0, 1.0]
}
}
}
fn size_of(path: &str) -> (u32, u32) {
match std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("") {
"red16.png" => (16, 16),
"blue8.png" => (8, 8),
"lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" => (4, 4),
_ => (10, 10),
}
}
impl FootageSource for Synthetic {
fn frame(&self, _: ItemId, f: &Footage, _t: Tick) -> Option<Arc<Image>> {
let (w, h) = size_of(&f.path);
let mut img = Image::new(w, h);
for y in 0..h {
for x in 0..w {
// Decoders hand out premultiplied pixels (straight file colour × alpha).
let p = file_px(&f.path, x, y);
img.set(x, y, [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]);
}
}
Some(Arc::new(img))
}
}
struct Probe;
impl crate::Importer for Probe {
fn probe(&self, path: &str) -> Result<Footage, String> {
let (w, h) = size_of(path);
let video = path.ends_with(".mov");
Ok(Footage {
path: path.into(),
kind: if video { FootageKind::Video } else { FootageKind::Still },
width: w,
height: h,
has_video: true,
frame_rate: FrameRate::from_f64(10.0),
duration: Tick::from_seconds_f64(if video { 2.0 } else { 0.0 }),
..Default::default()
})
}
}
fn session() -> Session {
Session { footage: Arc::new(Synthetic), importer: Some(Arc::new(Probe)), ..Default::default() }
}
fn import(s: &mut Session, path: &str) -> ItemId {
ItemId(s.execute("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap())
}
fn px(s: &Session, comp: ItemId, t: f64, opts: RenderOpts, x: i64, y: i64) -> [f32; 4] {
s.render(comp, Tick::from_seconds_f64(t), opts).get(x, y)
}
fn red(p: [f32; 4]) -> bool {
p[0] > 0.99 && p[2] < 0.01 && p[3] > 0.99
}
fn blue(p: [f32; 4]) -> bool {
p[2] > 0.99 && p[0] < 0.01 && p[3] > 0.99
}
#[test]
fn footage_proxy_follows_use_proxy_and_proxy_use() {
let mut s = session();
let full = import(&mut s, "/m/red16.png");
s.execute("comp.new", json!({"name": "C", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
let c = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": full.0})).unwrap();
let o = RenderOpts::default();
assert!(red(px(&s, c, 0.0, o, 8, 8)));
s.execute("file.setProxy", json!({"item": full.0, "path": "/m/blue8.png"})).unwrap();
// The 8×8 proxy fills the 16×16 footage frame.
for (x, y) in [(1, 1), (8, 8), (14, 14)] {
assert!(blue(px(&s, c, 0.0, o, x, y)), "proxy at {x},{y}");
}
assert!(s.project.item(full).unwrap().proxy.as_ref().unwrap().enabled);
// Render Settings ▸ Proxy Use.
assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseNone, ..o }, 8, 8)));
assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseCompOnly, ..o }, 8, 8)));
// Use Proxy off: the viewer shows the source, Use All Proxies still uses it.
s.execute("file.useProxy", json!({"item": full.0})).unwrap();
assert!(!s.project.item(full).unwrap().proxy.as_ref().unwrap().enabled);
assert!(red(px(&s, c, 0.0, o, 8, 8)));
assert!(blue(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseAll, ..o }, 8, 8)));
// Undo / Set Proxy ▸ None.
s.execute("edit.undo", json!({})).unwrap();
assert!(blue(px(&s, c, 0.0, o, 8, 8)));
s.execute("file.setProxyNone", json!({"item": full.0})).unwrap();
assert!(s.project.item(full).unwrap().proxy.is_none());
assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseAll, ..o }, 8, 8)));
// Render queue items carry Proxy Use (Best Settings: none).
let r = s.execute("renderQueue.add", json!({"proxyUse": "all"})).unwrap();
assert_eq!(s.project.render_queue[0].settings.proxy_use, ProxyUse::UseAll, "{r}");
assert_eq!(effectcraft_project::render_queue::RenderSettings::default().proxy_use, ProxyUse::UseNone);
}
#[test]
fn comp_proxy_and_create_proxy() {
let mut s = session();
s.execute("comp.new", json!({"name": "Inner", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
let inner = s.active_comp_id().unwrap();
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
let outer = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
let o = RenderOpts::default();
assert!(red(px(&s, outer, 0.0, o, 8, 8)));
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/blue8.png"})).unwrap();
assert!(blue(px(&s, outer, 0.0, o, 14, 1)));
assert!(blue(px(&s, outer, 0.0, RenderOpts { proxy: ProxyUse::UseCompOnly, ..o }, 8, 8)));
assert!(red(px(&s, outer, 0.0, RenderOpts { proxy: ProxyUse::UseNone, ..o }, 8, 8)));
s.execute("file.setProxyNone", json!({"item": inner.0})).unwrap();
// Create Proxy queues a half-resolution render whose post-render action sets the proxy.
s.state.project_selection = vec![inner];
let r = s.execute("file.createProxy", json!({"kind": "still"})).unwrap();
let it = s.project.render_queue.last().unwrap().clone();
assert_eq!(it.comp, inner);
assert_eq!(it.post_render, effectcraft_project::render_queue::PostRenderAction::SetProxy);
assert_eq!(it.settings.resolution, 0.5);
assert!(r["output"].as_str().unwrap().contains("Inner_proxy_"), "{r}");
let m = s.execute("file.createProxy", json!({"kind": "movie", "comp": inner.0})).unwrap();
assert!(m["output"].as_str().unwrap().ends_with("Inner_proxy.mov"), "{m}");
// The post-render action (what a finished render runs) attaches the file.
let id = it.id;
s.post_render_action(id, "/m/blue8.png").unwrap();
assert_eq!(s.project.item(inner).unwrap().proxy.as_ref().unwrap().footage.path, "/m/blue8.png");
assert!(blue(px(&s, outer, 0.0, o, 8, 8)));
}
#[test]
fn interpret_proxy_settings_apply_to_the_proxy() {
let mut s = session();
let full = import(&mut s, "/m/red16.png");
s.execute("file.setProxy", json!({"item": full.0, "path": "/m/half.png"})).unwrap();
s.execute("file.interpretProxy", json!({"item": full.0, "alpha": "ignore", "invertAlpha": true})).unwrap();
let px = s.project.item(full).unwrap().proxy.clone().unwrap();
assert_eq!(px.footage.alpha, AlphaMode::Ignore);
assert!(px.footage.invert_alpha);
let f = match &s.project.item(full).unwrap().kind {
ItemKind::Footage(f) => f.clone(),
_ => panic!("not footage"),
};
assert_eq!(f.alpha, AlphaMode::Straight, "the main footage keeps its interpretation");
}
/// Separate Fields: each field becomes a frame at twice the frame rate, the other field's lines
/// interpolated; the dominant field comes first.
#[test]
fn separate_fields_make_frames_from_fields() {
let mut s = session();
let mov = import(&mut s, "/m/lines.mov");
s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 20, "duration": 1})).unwrap();
let c = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": mov.0})).unwrap();
let o = RenderOpts::default();
// Progressive: alternating rows.
assert!(red(px(&s, c, 0.0, o, 1, 0)) && blue(px(&s, c, 0.0, o, 1, 1)));
s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "upper"})).unwrap();
// Field 1 (upper = even rows, red) then field 2 (odd rows, blue), each 1/20 s.
for y in 0..3 {
assert!(red(px(&s, c, 0.0, o, 1, y)), "upper field at row {y}");
assert!(blue(px(&s, c, 0.05, o, 1, y + 1)), "lower field at row {}", y + 1);
}
s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "lower"})).unwrap();
assert!(blue(px(&s, c, 0.0, o, 1, 2)));
assert!(red(px(&s, c, 0.05, o, 1, 1)));
let r = s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "off"})).unwrap();
assert_eq!(r["items"][0]["fields"], "Off");
}
/// Non-square pixels: a 10×10 footage with pixel aspect 2 covers 20×10 comp pixels.
#[test]
fn pixel_aspect_stretches_footage_in_the_comp() {
let mut s = session();
let w = import(&mut s, "/m/wide.png");
s.execute("comp.new", json!({"name": "C", "width": 40, "height": 20, "frameRate": 10, "duration": 1})).unwrap();
let c = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": w.0})).unwrap();
let o = RenderOpts::default();
let covered = |s: &Session| s.render(c, Tick::ZERO, o).data.iter().filter(|p| p[3] > 0.5).count();
assert_eq!(covered(&s), 100);
s.execute("file.interpretFootage", json!({"item": w.0, "pixelAspect": 2.0})).unwrap();
assert_eq!(covered(&s), 200);
// Centered on the comp: x 10..30 (edge pixels are filtered).
assert!(px(&s, c, 0.0, o, 11, 10)[3] > 0.99 && px(&s, c, 0.0, o, 28, 10)[3] > 0.99);
assert!(px(&s, c, 0.0, o, 8, 10)[3] < 0.01 && px(&s, c, 0.0, o, 31, 10)[3] < 0.01);
// A comp with the same pixel aspect shows the footage 1:1.
s.execute("comp.settings", json!({"pixelAspect": 2.0})).unwrap();
assert_eq!(covered(&s), 100);
}
/// Alpha ▸ Guess: premultiplied files are recognised with their matte colour; Invert Alpha
/// and Interpret As Linear Light change the decoded pixels.
#[test]
fn guess_alpha_invert_and_linear_light() {
let mut s = session();
let b = import(&mut s, "/m/matte_black.png");
let w = import(&mut s, "/m/matte_white.png");
let h = import(&mut s, "/m/half.png");
let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind {
ItemKind::Footage(f) => f.clone(),
_ => panic!("not footage"),
};
s.execute("file.interpretFootage", json!({"items": [b.0, w.0, h.0], "alpha": "guess"})).unwrap();
assert_eq!(foot(&s, b).alpha, AlphaMode::Premultiplied);
assert_eq!(foot(&s, b).premul_color, [0.0; 3]);
assert_eq!(foot(&s, w).alpha, AlphaMode::Premultiplied);
assert_eq!(foot(&s, w).premul_color, [1.0; 3]);
// White at 25 %: not explained by a black matte (1 > 0.25) but it is by white… either way
// the guess is consistent with the pixels.
assert!(matches!(foot(&s, h).alpha, AlphaMode::Straight | AlphaMode::Premultiplied));
// An explicit matte colour.
s.execute("file.interpretFootage", json!({"item": b.0, "alpha": "premultiplied", "matteColor": "#808080"})).unwrap();
assert!((foot(&s, b).premul_color[0] - 128.0 / 255.0).abs() < 1e-3);
// Invert Alpha: 25 % → 75 %.
s.execute("file.interpretFootage", json!({"item": h.0, "alpha": "straight", "invertAlpha": true})).unwrap();
s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
let c = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": h.0})).unwrap();
let p = px(&s, c, 0.0, RenderOpts::default(), 1, 1);
assert!((p[3] - 0.75).abs() < 0.01 && p[0] > 0.7, "{p:?}");
// Interpret As Linear Light keeps alpha and re-encodes the colour.
s.execute("file.interpretFootage", json!({"item": h.0, "invertAlpha": false, "linearLight": true})).unwrap();
let p = px(&s, c, 0.0, RenderOpts::default(), 1, 1);
assert!((p[3] - 0.25).abs() < 0.01, "{p:?}");
// Remember / Apply Interpretation carries the new settings.
s.state.project_selection = vec![h];
s.execute("file.rememberInterpretation", json!({"item": h.0})).unwrap();
s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap();
assert!(foot(&s, w).linear_light);
// Color ▸ Preserve RGB (#411) is set, rendered and carried by Apply Interpretation.
let out = s.execute("file.interpretFootage", json!({"item": h.0, "preserveRgb": true})).unwrap();
assert_eq!(out["items"][0]["preserveRgb"], true);
let p = px(&s, c, 0.0, RenderOpts::default(), 1, 1);
assert!((p[0] - 0.25).abs() < 0.01, "white at 25 %, unconverted: {p:?}");
s.execute("file.rememberInterpretation", json!({"item": h.0})).unwrap();
s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap();
assert!(foot(&s, w).preserve_rgb);
}