From f20d22bf36b2e1a91d8e1f01d01d6a50bfeac01d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:21 +0000 Subject: [PATCH] Refactor engine for strict Clippy checks --- crates/engine/src/analysis/roto.rs | 13 ++- crates/engine/src/cmd.rs | 2 +- crates/engine/src/cmd/enabled.rs | 2 +- crates/engine/src/cmd/layer/contents.rs | 5 +- crates/engine/src/cmd/layer/create.rs | 5 +- crates/engine/src/cmd/layer/parent.rs | 3 +- crates/engine/src/cmd/layer/replace.rs | 5 +- crates/engine/src/cmd/layer/select.rs | 2 +- crates/engine/src/cmd/layer/switches.rs | 5 +- crates/engine/src/cmd/layer/timing.rs | 5 +- crates/engine/src/cmd/layer/transform.rs | 5 +- crates/engine/src/cmd/params.rs | 2 +- crates/engine/src/cmd/paths.rs | 20 ++-- crates/engine/src/cmd/render_queue.rs | 82 ++++++++----- crates/engine/src/cmd/schema.rs | 2 +- crates/engine/src/cmd/shape_tool.rs | 11 +- crates/engine/src/doc_state.rs | 2 +- crates/engine/src/event.rs | 2 +- crates/engine/src/fonts.rs | 2 +- crates/engine/src/home/demo.rs | 22 +++- crates/engine/src/services.rs | 2 +- crates/engine/src/session.rs | 4 +- crates/engine/src/session/comps.rs | 2 +- crates/engine/src/session/dispatch.rs | 2 +- crates/engine/src/session/edit.rs | 2 +- crates/engine/src/session/render.rs | 2 +- crates/engine/src/settings/prefs/dialog.rs | 2 +- crates/engine/src/settings/prefs/document.rs | 2 +- crates/engine/src/settings/prefs/queries.rs | 2 +- crates/engine/src/settings/prefs/recent.rs | 2 +- crates/engine/src/settings/prefs/sections.rs | 2 +- crates/engine/src/tests/camera_track.rs | 116 ++++++++++++------- crates/engine/src/tests/mask_warp.rs | 42 ++++--- crates/engine/src/tests/rig3d.rs | 76 ++++++------ crates/engine/src/tests/vector_import.rs | 65 +++++++---- 35 files changed, 323 insertions(+), 197 deletions(-) diff --git a/crates/engine/src/analysis/roto.rs b/crates/engine/src/analysis/roto.rs index d9ce6e5af..813561969 100644 --- a/crates/engine/src/analysis/roto.rs +++ b/crates/engine/src/analysis/roto.rs @@ -450,7 +450,18 @@ async fn run_work(w: Work, shared: &RotoShared) { (RotoTask::Propagate, true) => om::ensure(&w.params, w.size, w.fps, *f, model.clone(), &mut get, &cancel).is_some(), (RotoTask::Freeze, true) => { let img = get(*f); - match img.map(|img| (om::frame_matte(&w.params, w.size, w.fps, *f, 1.0, &img, model.clone(), &mut get, &cancel), img)) { + match img.map(|img| { + ( + om::frame_matte( + &w.params, + &om::MatteFrame { layer_size: w.size, fps: w.fps, frame: *f, scale: 1.0, img: &img }, + model.clone(), + &mut get, + &cancel, + ), + img, + ) + }) { Some((om::FrameMatte::Alpha(a), img)) => { frozen.size = [img.width as usize, img.height as usize]; frozen.frames.insert(*f, fx::freeze_frame(&a, &[])); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 47b3cbde9..bceef85fb 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -156,7 +156,7 @@ mod enabled; mod params; mod schema; -use super::*; +use super::commands; pub use enabled::always; pub(crate) use enabled::{has_comp, has_keys, has_layers, has_project_selection, not_yet, not_yet_run}; pub use params::match_path_of; diff --git a/crates/engine/src/cmd/enabled.rs b/crates/engine/src/cmd/enabled.rs index ba2631b20..fb28b60c4 100644 --- a/crates/engine/src/cmd/enabled.rs +++ b/crates/engine/src/cmd/enabled.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EngineError, Result, Session, Value}; // ---------- enablement ---------- diff --git a/crates/engine/src/cmd/layer/contents.rs b/crates/engine/src/cmd/layer/contents.rs index 884d0df5c..d8824fc9e 100644 --- a/crates/engine/src/cmd/layer/contents.rs +++ b/crates/engine/src/cmd/layer/contents.rs @@ -1,5 +1,8 @@ use super::create::{apply_text_params, text_range_p}; -use super::*; +use super::{ + EngineError, GroupKind, Ids, ItemId, KV, LayerId, MaskMode, Node, Project, PropGroup, Result, Session, TextDoc, Value, b_p, bad, build, json, layer_mut, + layer_p, merge_p, str_p, +}; pub(super) fn add_mask(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.addMask")?; diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 7a01aa538..7962c2485 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + EngineError, Ids, ItemId, ItemKind, Justify, KV, Label, Layer, LayerId, LayerSource, Project, Result, Session, Solid, TextDoc, Tick, Value, b_p, bad, + build, comp_id, f_p, json, limits, str_p, +}; pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> { match p.get(k)? { diff --git a/crates/engine/src/cmd/layer/parent.rs b/crates/engine/src/cmd/layer/parent.rs index 8565cbbb1..fbaec14e4 100644 --- a/crates/engine/src/cmd/layer/parent.rs +++ b/crates/engine/src/cmd/layer/parent.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Comp, EngineError, ItemId, KV, Layer, LayerId, PropGroup, Result, Session, Tick, Value, b_p, bad, layers_p, resolve_layer}; /// Re-express a layer in its new parent space without moving its anchor. pub(crate) struct ParentFix { @@ -336,6 +336,7 @@ pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod parent_chain_tests { + use super::super::json; use super::*; use std::sync::Arc; diff --git a/crates/engine/src/cmd/layer/replace.rs b/crates/engine/src/cmd/layer/replace.rs index 3fa208d15..80e5be807 100644 --- a/crates/engine/src/cmd/layer/replace.rs +++ b/crates/engine/src/cmd/layer/replace.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + EngineError, FootageKind, Ids, ItemId, ItemKind, Layer, LayerSource, Project, Result, Session, Value, bad, build, has_comp, json, layer_mut, layers_p, + unlocked, +}; /// A layer whose source is an item (footage, precomposition or solid), as in After Effects' /// Replace Source. Text, shape, null, camera, light and 3D model layers have no AVItem source to diff --git a/crates/engine/src/cmd/layer/select.rs b/crates/engine/src/cmd/layer/select.rs index b7fded70d..0edf7a72c 100644 --- a/crates/engine/src/cmd/layer/select.rs +++ b/crates/engine/src/cmd/layer/select.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EngineError, LayerId, Result, Session, Value, b_p, comp_id, json, resolve_layer}; pub(super) fn select(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; diff --git a/crates/engine/src/cmd/layer/switches.rs b/crates/engine/src/cmd/layer/switches.rs index 046cc37a4..38689afbb 100644 --- a/crates/engine/src/cmd/layer/switches.rs +++ b/crates/engine/src/cmd/layer/switches.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + BlendMode, EngineError, FrameBlend, ItemId, KV, Layer, LayerId, MatteKind, Quality, Result, Session, TrackMatte, Value, b_p, bad, json, layer_mut, layer_p, + layers_p, merge_p, resolve_layer, str_p, +}; pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; diff --git a/crates/engine/src/cmd/layer/timing.rs b/crates/engine/src/cmd/layer/timing.rs index a1f83dc8f..711eefee2 100644 --- a/crates/engine/src/cmd/layer/timing.rs +++ b/crates/engine/src/cmd/layer/timing.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + EngineError, ItemKind, Label, Layer, LayerId, LayerSource, Result, Session, Tick, Value, b_p, bad, build, f_p, json, layer_mut, layers_p, merge_p, + resolve_layer, str_p, unlocked, with, +}; pub(super) fn timing(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; diff --git a/crates/engine/src/cmd/layer/transform.rs b/crates/engine/src/cmd/layer/transform.rs index 6612a2d07..8b75dcbc8 100644 --- a/crates/engine/src/cmd/layer/transform.rs +++ b/crates/engine/src/cmd/layer/transform.rs @@ -1,5 +1,8 @@ use super::create::{color_p, pixel_aspect_p}; -use super::*; +use super::{ + EngineError, ItemKind, KV, LayerId, LayerSource, PropGroup, Result, Session, Tick, Value, bad, has_layers, json, layer_mut, layer_p, layers_p, limits, + str_p, unlocked, +}; /// Move Position by `(dx, dy)`, including every key. Separated dimensions move X and Y as well /// as the combined property the channels were split from. diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 296ab6f6f..7c4e1c292 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Comp, EngineError, ItemId, Layer, LayerId, Result, Session, Tick, Value}; /// Hand a frontend-only command to the UI ([`crate::Event::Frontend`]). pub(crate) fn frontend(s: &mut Session, id: &str, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index 96767d967..afd7aba12 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -121,16 +121,16 @@ fn parametric_path(g: &PropGroup, lt: effectcraft_time::Tick) -> Option effectcraft_path::rect(v2("size", [100.0; 2]), v2("position", [0.0; 2]), f("roundness", 0.0)), "ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])), - "star" => effectcraft_path::polystar( - g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0, - f("points", 5.0), - v2("position", [0.0; 2]), - f("rotation", 0.0), - f("innerRadius", 50.0), - f("outerRadius", 100.0), - f("innerRoundness", 0.0), - f("outerRoundness", 0.0), - ), + "star" => effectcraft_path::polystar(effectcraft_path::Polystar { + star: g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0, + points: f("points", 5.0), + pos: v2("position", [0.0; 2]), + rotation: f("rotation", 0.0), + inner: f("innerRadius", 50.0), + outer: f("outerRadius", 100.0), + inner_round: f("innerRoundness", 0.0), + outer_round: f("outerRoundness", 0.0), + }), _ => return None, }; // A closed parametric shape is one subpath; anything else would not be this shape. diff --git a/crates/engine/src/cmd/render_queue.rs b/crates/engine/src/cmd/render_queue.rs index 09a7c7d2f..1cda510e0 100644 --- a/crates/engine/src/cmd/render_queue.rs +++ b/crates/engine/src/cmd/render_queue.rs @@ -205,23 +205,8 @@ fn apply_om_template(om: &mut OutputModule, t: &OutputModule) { /// The `format` parameter's values. const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff"; -/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest -/// (captured by `crop.useRoi`). -fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result { - let mut any = false; - if let Some(t) = str_p(p, "template") { - let t = templates.output_module(t).ok_or_else(|| bad(cmd, format!("no Output Module template “{t}”")))?; - apply_om_template(om, &t); - any = true; - } - let before = om.clone(); - if let Some(f) = str_p(p, "format") { - let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, FORMATS))?; - om.set_format(f); - } - // Before Channels: the WebM codec decides whether alpha can be written, so an explicit - // `channels: rgba` with `webmCodec: av1` is refused rather than dropped (#166). - any |= apply_codec_options(om, p, cmd)?; +/// `channels` (refusing RGBA where the format has no alpha) and `color`/`alphaMode`. +fn apply_channels_and_alpha(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> { if let Some(c) = str_p(p, "channels") { om.channels = match c.to_ascii_lowercase().replace([' ', '+'], "").as_str() { "rgb" => Channels::Rgb, @@ -244,21 +229,11 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option< _ => return Err(bad(cmd, "color: straight|premultiplied")), }; } - if let Some(q) = f_p(p, "quality") { - om.quality = q.clamp(1.0, 100.0) as u8; - } - if let Some(b) = f_p(p, "bitrate") { - om.bitrate_kbps = b.clamp(50.0, 500_000.0) as u32; - } - if let Some(b) = b_p(p, "webmBitrate") { - om.webm_bitrate = b; - } - if let Some(k) = p.get("keyframeInterval").and_then(Value::as_u64) { - om.keyframe_interval = k.min(100_000) as u32; - } - if let Some(pp) = str_p(p, "proresProfile") { - om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?; - } + Ok(()) +} + +/// `crop` plus the flat dialog keys (`cropTop`… and `resizeWidth`/`resizeHeight`). +fn apply_crop(om: &mut OutputModule, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result<()> { match p.get("crop") { Some(Value::Bool(b)) => om.crop.enabled = *b, Some(Value::Object(c)) => { @@ -298,6 +273,11 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option< om.resize.enabled = true; } } + Ok(()) +} + +/// `resize` (bool or object). +fn apply_resize(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> { match p.get("resize") { Some(Value::Bool(b)) => om.resize.enabled = *b, Some(Value::Object(r)) => { @@ -328,6 +308,44 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option< Some(Value::Null) | None => {} Some(_) => return Err(bad(cmd, "resize: bool or {enabled?, preset?, width?, height?, lockAspect?, quality?: low|high}")), } + Ok(()) +} + +/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest +/// (captured by `crop.useRoi`). +fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result { + let mut any = false; + if let Some(t) = str_p(p, "template") { + let t = templates.output_module(t).ok_or_else(|| bad(cmd, format!("no Output Module template “{t}”")))?; + apply_om_template(om, &t); + any = true; + } + let before = om.clone(); + if let Some(f) = str_p(p, "format") { + let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, FORMATS))?; + om.set_format(f); + } + // Before Channels: the WebM codec decides whether alpha can be written, so an explicit + // `channels: rgba` with `webmCodec: av1` is refused rather than dropped (#166). + any |= apply_codec_options(om, p, cmd)?; + apply_channels_and_alpha(om, p, cmd)?; + if let Some(q) = f_p(p, "quality") { + om.quality = q.clamp(1.0, 100.0) as u8; + } + if let Some(b) = f_p(p, "bitrate") { + om.bitrate_kbps = b.clamp(50.0, 500_000.0) as u32; + } + if let Some(b) = b_p(p, "webmBitrate") { + om.webm_bitrate = b; + } + if let Some(k) = p.get("keyframeInterval").and_then(Value::as_u64) { + om.keyframe_interval = k.min(100_000) as u32; + } + if let Some(pp) = str_p(p, "proresProfile") { + om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?; + } + apply_crop(om, roi, p, cmd)?; + apply_resize(om, p, cmd)?; if let Some(a) = p.get("audio") { om.audio = match a { Value::Bool(true) => AudioOutput::On, diff --git a/crates/engine/src/cmd/schema.rs b/crates/engine/src/cmd/schema.rs index 11e2ef9f2..c74ab277e 100644 --- a/crates/engine/src/cmd/schema.rs +++ b/crates/engine/src/cmd/schema.rs @@ -1,5 +1,5 @@ use super::params::bad; -use super::*; +use super::{CommandSpec, Result, Value}; // ---------- parameter validation (agents) ---------- diff --git a/crates/engine/src/cmd/shape_tool.rs b/crates/engine/src/cmd/shape_tool.rs index a48c32b7f..e1f3607b1 100644 --- a/crates/engine/src/cmd/shape_tool.rs +++ b/crates/engine/src/cmd/shape_tool.rs @@ -221,7 +221,16 @@ pub fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option effectcraft_path::rect(size, centre, roundness(size)), k => { let (star, points, outer, inner) = polystar_of(k, size)?; - effectcraft_path::polystar(star, points, centre, 0.0, inner, outer, 0.0, 0.0) + effectcraft_path::polystar(effectcraft_path::Polystar { + star, + points, + pos: centre, + rotation: 0.0, + inner, + outer, + inner_round: 0.0, + outer_round: 0.0, + }) } }; effectcraft_path::from_kurbo(&bez).into_iter().next() diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index e5fb1791b..954a7d24e 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, ItemId, Layer, LayerId, Serialize, Tick, Uid, commands, mask_track, media_browser, roto, viewer}; /// A keyframe reference: layer, property uid, key time (layer time). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/engine/src/event.rs b/crates/engine/src/event.rs index 0e60575de..e1f5e0535 100644 --- a/crates/engine/src/event.rs +++ b/crates/engine/src/event.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ItemId, Serialize, Value}; /// Events for frontends (drained each frame). #[derive(Clone, Debug, PartialEq, Serialize)] diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 039fd2bad..6b06d6f5b 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -1,4 +1,4 @@ -use super::*; +use super::prefs; pub fn text_families() -> Vec { effectcraft_text::families().into_iter().map(|(f, _)| f).collect() diff --git a/crates/engine/src/home/demo.rs b/crates/engine/src/home/demo.rs index df07f5204..656bd7708 100644 --- a/crates/engine/src/home/demo.rs +++ b/crates/engine/src/home/demo.rs @@ -130,8 +130,20 @@ fn solid_sized(p: &mut Project, comp: &Comp, folder: ItemId, name: &str, color: build::layer(p, comp, name, LayerSource::Solid { item: sid }, (w, h), None) } +/// What `card` draws: name, gradient end colors (hex), size, position and Y rotation. +#[derive(Clone, Copy)] +struct CardSpec<'a> { + name: &'a str, + a: &'a str, + b: &'a str, + size: (u32, u32), + pos: [f64; 3], + rot_y: f64, +} + /// A 3D card: a solid with a diagonal gradient and a thin border grid. -fn card(p: &mut Project, comp: &Comp, folder: ItemId, name: &str, a: &str, b: &str, size: (u32, u32), pos: [f64; 3], rot_y: f64) -> Layer { +fn card(p: &mut Project, comp: &Comp, folder: ItemId, spec: &CardSpec) -> Layer { + let CardSpec { name, a, b, size, pos, rot_y } = *spec; let mut l = solid_sized(p, comp, folder, name, [1.0, 1.0, 1.0], size.0, size.1); let (w, h) = (size.0 as f64, size.1 as f64); effect( @@ -192,11 +204,11 @@ fn showcase_3d(p: &mut Project, solids: ItemId) -> Comp { set(&mut floor, "materialOptions/specularIntensity", Value::Scalar(10.0)); // Cards at different depths and angles; the centre pair intersects. - let left = card(p, &c, solids, "Card Blue", "#2E7BF0", "#7A4DFF", (520, 340), [520.0, 560.0, 120.0], 28.0); - let right = card(p, &c, solids, "Card Coral", "#FF6A5C", "#FFB347", (520, 340), [1420.0, 560.0, 260.0], -32.0); - let mut x1 = card(p, &c, solids, "Cross A", "#1FD1A5", "#2E7BF0", (460, 460), [960.0, 470.0, 520.0], 40.0); + let left = card(p, &c, solids, &CardSpec { name: "Card Blue", a: "#2E7BF0", b: "#7A4DFF", size: (520, 340), pos: [520.0, 560.0, 120.0], rot_y: 28.0 }); + let right = card(p, &c, solids, &CardSpec { name: "Card Coral", a: "#FF6A5C", b: "#FFB347", size: (520, 340), pos: [1420.0, 560.0, 260.0], rot_y: -32.0 }); + let mut x1 = card(p, &c, solids, &CardSpec { name: "Cross A", a: "#1FD1A5", b: "#2E7BF0", size: (460, 460), pos: [960.0, 470.0, 520.0], rot_y: 40.0 }); set(&mut x1, "materialOptions/lightTransmission", Value::Scalar(30.0)); - let x2 = card(p, &c, solids, "Cross B", "#F0E14A", "#FF6A5C", (460, 460), [960.0, 470.0, 520.0], -40.0); + let x2 = card(p, &c, solids, &CardSpec { name: "Cross B", a: "#F0E14A", b: "#FF6A5C", size: (460, 460), pos: [960.0, 470.0, 520.0], rot_y: -40.0 }); // Hero title in 3D space, gently turning. let mut title = text_layer( p, diff --git a/crates/engine/src/services.rs b/crates/engine/src/services.rs index 01a2102ec..d962bf62d 100644 --- a/crates/engine/src/services.rs +++ b/crates/engine/src/services.rs @@ -1,4 +1,4 @@ -use super::*; +use super::config; /// Host services (file access) injected by the frontend. pub trait Services: Send + Sync { diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index d4feb6d67..18b0ff4cf 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1,7 +1,9 @@ use super::doc_state::{CompSelection, EditorState}; use super::error::{EngineError, Result}; use super::event::Event; -use super::*; +use super::{ + Arc, Comp, History, Image, ItemId, LayerId, Project, RenderOpts, Renderer, Session, ThumbnailJob, Tick, Value, camera_track, commands, guard, roto, warp, +}; mod comps; mod dispatch; diff --git a/crates/engine/src/session/comps.rs b/crates/engine/src/session/comps.rs index 3c663e22f..ed3810e79 100644 --- a/crates/engine/src/session/comps.rs +++ b/crates/engine/src/session/comps.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Comp, CompSelection, Event, ItemId, LayerId, Result, Session, Tick, Value, commands}; /// `t` as a current time of `comp`: a frame within its duration. fn comp_frame_at(comp: &Comp, t: Tick) -> Tick { diff --git a/crates/engine/src/session/dispatch.rs b/crates/engine/src/session/dispatch.rs index 815de866b..36c7f0a51 100644 --- a/crates/engine/src/session/dispatch.rs +++ b/crates/engine/src/session/dispatch.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EngineError, Event, Result, Session, Value, commands, guard}; impl Session { pub fn new() -> Session { diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index f9df9ef20..a8b03461a 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, EditorState, EngineError, Event, History, ItemId, Project, Result, Session, camera_track, commands, roto, warp}; impl Session { /// Apply an undoable edit. `merge`: consecutive edits with the same key fold into one step. diff --git a/crates/engine/src/session/render.rs b/crates/engine/src/session/render.rs index 855c9c401..53cd6d753 100644 --- a/crates/engine/src/session/render.rs +++ b/crates/engine/src/session/render.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EngineError, Image, ItemId, RenderOpts, Renderer, Result, Session, ThumbnailJob, Tick}; impl Session { /// Render a comp frame with the session's footage + expression hosts. diff --git a/crates/engine/src/settings/prefs/dialog.rs b/crates/engine/src/settings/prefs/dialog.rs index e0f49a8e9..e3f2006d8 100644 --- a/crates/engine/src/settings/prefs/dialog.rs +++ b/crates/engine/src/settings/prefs/dialog.rs @@ -1,5 +1,5 @@ use super::choices::{APPEARANCE_MODES, DARK_THEMES, LANGUAGES, LIGHT_THEMES, WINDOW_GRAPHICS}; -use super::*; +use super::{Value, json}; // ---------------------------------------------------------------- schema (Settings dialog) diff --git a/crates/engine/src/settings/prefs/document.rs b/crates/engine/src/settings/prefs/document.rs index 49f2e5e9d..9e991da46 100644 --- a/crates/engine/src/settings/prefs/document.rs +++ b/crates/engine/src/settings/prefs/document.rs @@ -1,7 +1,7 @@ use super::choices::{APPEARANCE_MODES, DARK_THEMES, LANGUAGES, LIGHT_THEMES, THEMES, WINDOW_GRAPHICS, choices, is_choice, theme_family}; use super::recent::MAX_EFFECTS_FAVORITES; use super::sections::{CompPreset, default_labels, parse_hex}; -use super::*; +use super::{Appearance, General, Map, PREFS_VERSION, Prefs, Startup, Value, json}; /// Bring an older settings document up to [`PREFS_VERSION`]. /// diff --git a/crates/engine/src/settings/prefs/queries.rs b/crates/engine/src/settings/prefs/queries.rs index e55f086ed..74be30f8f 100644 --- a/crates/engine/src/settings/prefs/queries.rs +++ b/crates/engine/src/settings/prefs/queries.rs @@ -1,5 +1,5 @@ use super::sections::parse_hex; -use super::*; +use super::{Label, Prefs, Serialize, Tick}; impl Prefs { /// Display name of a label (Labels settings). diff --git a/crates/engine/src/settings/prefs/recent.rs b/crates/engine/src/settings/prefs/recent.rs index c5321490d..2a77a61c2 100644 --- a/crates/engine/src/settings/prefs/recent.rs +++ b/crates/engine/src/settings/prefs/recent.rs @@ -1,4 +1,4 @@ -use super::*; +use super::Prefs; impl Prefs { /// Remember a project in File ▸ Open Recent. diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index 2b8d8adf6..6f2a1923c 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -1,5 +1,5 @@ use super::choices::theme_family; -use super::*; +use super::{BTreeMap, Deserialize, Label, Serialize, Value}; /// Unknown keys inside a page, kept so a newer version's settings survive a round trip. pub(super) type Extra = BTreeMap; diff --git a/crates/engine/src/tests/camera_track.rs b/crates/engine/src/tests/camera_track.rs index cc6378c4e..3ca8578a9 100644 --- a/crates/engine/src/tests/camera_track.rs +++ b/crates/engine/src/tests/camera_track.rs @@ -3,7 +3,7 @@ use effectcraft_effects::camera_tracker as ct; use effectcraft_geom::vec3; -use effectcraft_project::LayerId; +use effectcraft_project::{Comp, Layer, LayerId, PropGroup}; use effectcraft_raster::Image; use effectcraft_render::EvalCtx; use effectcraft_render::three_d::camera::active_camera; @@ -11,6 +11,7 @@ use effectcraft_track::camtrack::linalg::{self, M3, V3}; use serde_json::{Value, json}; use crate::Session; +use crate::camera_track::Placed; use crate::tests::track::{H, W, frame_time, setup, texture}; const F: f64 = 260.0; @@ -77,10 +78,9 @@ fn status(s: &mut Session) -> Value { s.execute("camera.solveStatus", json!({})).unwrap() } -#[test] -fn track_camera_solves_and_creates_layers_on_the_plane() { - let (mut s, clip) = tracked(json!({})); - let st = status(&mut s); +/// Solved with a sane error and focal length; returns the Camera Tracker effect group. +fn assert_solved(s: &mut Session, clip: LayerId) -> PropGroup { + let st = status(s); assert_eq!(st["solved"], true, "{st}"); let err = st["averageError"].as_f64().unwrap(); assert!(err < 0.5, "average error {err}"); @@ -90,9 +90,12 @@ fn track_camera_solves_and_creates_layers_on_the_plane() { // Method Used / Average Error are reported in the effect. let g = s.active_comp().unwrap().layer(clip).unwrap().effects().unwrap().groups().next().unwrap().clone(); assert!(matches!(g.prop(ct::METHOD_USED).map(|p| &p.value), Some(effectcraft_keyframe::Value::Str(m)) if !m.is_empty())); + g +} - // Pick floor points on frame 12 (their 3D positions lie on y = 1.5 in the true scene: the - // ones seen below the horizon at the bottom of the frame). +/// Pick floor points on frame 12 (their 3D positions lie on y = 1.5 in the true scene: the +/// ones seen below the horizon at the bottom of the frame). +fn select_floor_points(s: &mut Session) -> Vec { s.execute("time.set", json!({"time": frame_time(12).seconds()})).unwrap(); let pts = s.execute("camera.points", json!({})).unwrap(); let list = pts["points"].as_array().unwrap(); @@ -101,32 +104,19 @@ fn track_camera_solves_and_creates_layers_on_the_plane() { assert!(floor.len() >= 3, "{floor:?}"); s.execute("camera.selectPoints", json!({"points": floor})).unwrap(); assert_eq!(s.state.camera_points.len(), floor.len()); + floor +} - // Create Solid and Camera: one undo step, camera + solid. - let r = s.execute_checked("camera.createFromSolve", json!({"kind": "solid"})).unwrap(); - let cam = LayerId(r["camera"].as_u64().unwrap()); - let solid = LayerId(r["layers"][0].as_u64().unwrap()); - let comp = s.active_comp().unwrap().clone(); - assert!(comp.layer(cam).unwrap().is_camera()); - assert_eq!(comp.layer(cam).unwrap().name, "3D Tracker Camera"); - assert_eq!(comp.layers[0].id, cam); - let sl = comp.layer(solid).unwrap(); - assert!(sl.switches.three_d); - assert!(sl.name.starts_with("Track Solid")); - // The camera is keyed on every frame. - assert_eq!(comp.layer(cam).unwrap().props.prop("transform/position").unwrap().keys.len(), 25); - - // Every solved point projects through the created camera exactly where the solve puts it, - // and onto its tracked position (the footage fills the comp, so layer pixels are comp - // pixels): within 1 px for 95 % of the observations (the rest are the solve's outliers: - // features on occlusion edges), 0.5 px on average. +/// Every solved point projects through the created camera exactly where the solve puts it, +/// and onto its tracked position (the footage fills the comp, so layer pixels are comp +/// pixels): within 1 px for 95 % of the observations (the rest are the solve's outliers: +/// features on occlusion edges), 0.5 px on average. +fn assert_reprojection(s: &Session, comp: &Comp, pl: &Placed, g: &PropGroup) { let cid = s.active_comp_id().unwrap(); - let uid = g.uid; - let pl = crate::camera_track::placed(&s.project, cid, clip, uid).unwrap(); - let tracks = ct::tracks(&crate::camera_track::static_params(&g)).unwrap(); + let tracks = ct::tracks(&crate::camera_track::static_params(g)).unwrap(); let mut errs = vec![]; for k in [0u32, 6, 12, 18, 24] { - let ctx = EvalCtx::new(&s.project, cid, &comp, frame_time(k)); + let ctx = EvalCtx::new(&s.project, cid, comp, frame_time(k)); let camst = active_camera(&ctx); for sp in pl.solve.visible(k as usize) { let Some(obs) = tracks.track(sp.id).and_then(|t| t.at(k)) else { continue }; @@ -142,34 +132,31 @@ fn track_camera_solves_and_creates_layers_on_the_plane() { let p95 = errs[errs.len() * 95 / 100]; let mean = errs.iter().sum::() / errs.len() as f64; assert!(p95 < 1.0 && mean < 0.5, "p95 {p95} px, mean {mean} px"); +} - // The solid lies on the selected points' plane. +/// The solid lies on the selected points' plane and faces the camera. +fn assert_solid_on_plane(s: &Session, comp: &Comp, sl: &Layer, pl: &Placed, size: f64) { + let cid = s.active_comp_id().unwrap(); let tr = sl.props.sub("transform").unwrap(); let pos = tr.get("position").unwrap().value.as_vec3(); let ori = tr.get("orientation").unwrap().value.as_vec3(); let normal = linalg::mv(&linalg::from_euler_xyz(ori), [0.0, 0.0, -1.0]); - let size = r["target"]["size"].as_f64().unwrap(); for id in &s.state.camera_points { let w = pl.to_world(pl.solve.point(*id).unwrap().pos); let d = linalg::dot(linalg::sub(w, pos), normal).abs(); assert!(d < 0.05 * size.max(1.0) + 1.0, "point {id} is {d} px off the plane"); } - // The solid faces the camera. - let ctx = EvalCtx::new(&s.project, cid, &comp, frame_time(12)); + let ctx = EvalCtx::new(&s.project, cid, comp, frame_time(12)); let eye = active_camera(&ctx).eye; assert!(linalg::dot(normal, linalg::sub([eye.x, eye.y, eye.z], pos)) > 0.0); +} - // Undo removes both layers; redo brings them back. - let before = comp.layers.len(); - assert!(s.undo()); - assert_eq!(s.active_comp().unwrap().layers.len(), before - 2); - assert!(s.redo()); - assert_eq!(s.active_comp().unwrap().layers.len(), before); - - // A second create reuses the camera: Create Multiple Nulls and Camera. +/// A second create reuses the camera (Create Multiple Nulls and Camera), then Text and +/// Shadow Catcher (+ light). +fn assert_create_kinds(s: &mut Session, cam: LayerId, n_floor: usize) { let r = s.execute_checked("camera.createFromSolve", json!({"kind": "null", "multiple": true})).unwrap(); assert_eq!(r["camera"].as_u64().unwrap(), cam.0); - assert_eq!(r["layers"].as_array().unwrap().len(), floor.len()); + assert_eq!(r["layers"].as_array().unwrap().len(), n_floor); // Text and Shadow Catcher (+ light). let r = s.execute_checked("camera.createFromSolve", json!({"kind": "text"})).unwrap(); assert_eq!(r["layers"].as_array().unwrap().len(), 1); @@ -180,11 +167,12 @@ fn track_camera_solves_and_creates_layers_on_the_plane() { assert_eq!(comp.layer(ids[0]).unwrap().props.prop("materialOptions/acceptsShadows").unwrap().value.as_enum(), 2); assert!(comp.layer(ids[1]).unwrap().is_light()); assert!(s.execute_checked("camera.createFromSolve", json!({"kind": "teapot"})).is_err()); +} - // Set Ground Plane and Origin: the target becomes the origin; a null created there sits at - // (0, 0, 0) lying on the X-Z plane (its front facing up, −y). +/// Set Ground Plane and Origin, then an explicit target. +fn assert_ground_and_target(s: &mut Session) { s.execute_checked("camera.setGroundPlane", json!({})).unwrap(); - assert_eq!(status(&mut s)["groundPlane"], true); + assert_eq!(status(s)["groundPlane"], true); let r = s.execute_checked("camera.createFromSolve", json!({"kind": "null"})).unwrap(); let null = LayerId(r["layers"][0].as_u64().unwrap()); let tr = s.active_comp().unwrap().layer(null).unwrap().props.sub("transform").unwrap().clone(); @@ -203,6 +191,44 @@ fn track_camera_solves_and_creates_layers_on_the_plane() { assert!(linalg::norm(linalg::sub(pos, [10.0, 0.0, 20.0])) < 1e-6, "{pos:?}"); let front = linalg::mv(&linalg::from_euler_xyz(tr.get("orientation").unwrap().value.as_vec3()), [0.0, 0.0, -1.0]); assert!(linalg::norm(linalg::sub(front, [0.0, 0.0, -1.0])) < 1e-6, "{front:?}"); +} + +#[test] +fn track_camera_solves_and_creates_layers_on_the_plane() { + let (mut s, clip) = tracked(json!({})); + let g = assert_solved(&mut s, clip); + let floor = select_floor_points(&mut s); + + // Create Solid and Camera: one undo step, camera + solid. + let r = s.execute_checked("camera.createFromSolve", json!({"kind": "solid"})).unwrap(); + let cam = LayerId(r["camera"].as_u64().unwrap()); + let solid = LayerId(r["layers"][0].as_u64().unwrap()); + let comp = s.active_comp().unwrap().clone(); + assert!(comp.layer(cam).unwrap().is_camera()); + assert_eq!(comp.layer(cam).unwrap().name, "3D Tracker Camera"); + assert_eq!(comp.layers[0].id, cam); + let sl = comp.layer(solid).unwrap(); + assert!(sl.switches.three_d); + assert!(sl.name.starts_with("Track Solid")); + // The camera is keyed on every frame. + assert_eq!(comp.layer(cam).unwrap().props.prop("transform/position").unwrap().keys.len(), 25); + + let cid = s.active_comp_id().unwrap(); + let uid = g.uid; + let pl = crate::camera_track::placed(&s.project, cid, clip, uid).unwrap(); + assert_reprojection(&s, &comp, &pl, &g); + assert_solid_on_plane(&s, &comp, sl, &pl, r["target"]["size"].as_f64().unwrap()); + + // Undo removes both layers; redo brings them back. + let before = comp.layers.len(); + assert!(s.undo()); + assert_eq!(s.active_comp().unwrap().layers.len(), before - 2); + assert!(s.redo()); + assert_eq!(s.active_comp().unwrap().layers.len(), before); + + assert_create_kinds(&mut s, cam, floor.len()); + + assert_ground_and_target(&mut s); // Serde keeps the analysis. let back = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap(); diff --git a/crates/engine/src/tests/mask_warp.rs b/crates/engine/src/tests/mask_warp.rs index e20ec7cb6..adcea44be 100644 --- a/crates/engine/src/tests/mask_warp.rs +++ b/crates/engine/src/tests/mask_warp.rs @@ -239,6 +239,28 @@ fn set_param(s: &mut Session, layer: LayerId, path: &str, v: serde_json::Value) s.execute("prop.set", json!({"layer": layer.0, "path": format!("effects/#1/{path}"), "value": v})).unwrap(); } +/// Stabilize, Crop, Auto-scale (the default) hides the borders; Crop leaves an opaque centre. +fn assert_crop_framing(s: &mut Session, clip: LayerId) { + set_param(s, clip, "borders/framing", json!(2)); + let st = s.execute("warp.status", json!({})).unwrap(); + assert!(st["autoScale"].as_f64().unwrap() > 100.5, "{st}"); + for f in 0..25 { + assert_eq!(transparent(&render(s, f)), 0, "frame {f}"); + } + set_param(s, clip, "borders/framing", json!(1)); + let st = s.execute("warp.status", json!({})).unwrap(); + let crop: [f64; 4] = serde_json::from_value(st["crop"].clone()).unwrap(); + for f in [0, 9, 24] { + let img = render(s, f); + for y in (crop[1].ceil() as u32 + 1)..(crop[3].floor() as u32 - 1) { + for x in (crop[0].ceil() as u32 + 1)..(crop[2].floor() as u32 - 1) { + assert!(img.get(x as i64, y as i64)[3] > 0.99, "frame {f} ({x}, {y})"); + } + } + assert!(img.get(0, 0)[3] < 0.01); + } +} + #[test] fn warp_stabilizer_analyzes_and_stabilizes() { let (mut s, clip, solid) = setup(shaky); @@ -289,25 +311,7 @@ fn warp_stabilizer_analyzes_and_stabilizes() { } set_param(&mut s, clip, "stabilization/result", json!(0)); - // Stabilize, Crop, Auto-scale (the default) hides the borders; Crop leaves an opaque centre. - set_param(&mut s, clip, "borders/framing", json!(2)); - let st = s.execute("warp.status", json!({})).unwrap(); - assert!(st["autoScale"].as_f64().unwrap() > 100.5, "{st}"); - for f in 0..25 { - assert_eq!(transparent(&render(&s, f)), 0, "frame {f}"); - } - set_param(&mut s, clip, "borders/framing", json!(1)); - let st = s.execute("warp.status", json!({})).unwrap(); - let crop: [f64; 4] = serde_json::from_value(st["crop"].clone()).unwrap(); - for f in [0, 9, 24] { - let img = render(&s, f); - for y in (crop[1].ceil() as u32 + 1)..(crop[3].floor() as u32 - 1) { - for x in (crop[0].ceil() as u32 + 1)..(crop[2].floor() as u32 - 1) { - assert!(img.get(x as i64, y as i64)[3] > 0.99, "frame {f} ({x}, {y})"); - } - } - assert!(img.get(0, 0)[3] < 0.01); - } + assert_crop_framing(&mut s, clip); // Synthesize Edges fills most of the borders from neighbouring frames. set_param(&mut s, clip, "borders/framing", json!(3)); let synth: usize = (0..25).map(|f| transparent(&render(&s, f))).sum(); diff --git a/crates/engine/src/tests/rig3d.rs b/crates/engine/src/tests/rig3d.rs index d3b965107..d801a743a 100644 --- a/crates/engine/src/tests/rig3d.rs +++ b/crates/engine/src/tests/rig3d.rs @@ -49,45 +49,38 @@ fn camera(s: &mut Session) -> u64 { s.execute("layer.newCamera", json!({"name": "Cam", "position": [320, 180, -800], "poi": [320, 180, 0], "zoom": 900})).unwrap()["layer"].as_u64().unwrap() } -#[test] -fn stereo_rig_builds_eye_comps_and_glasses() { - let mut s = session(); - let src = cid(&s); - let cam = camera(&mut s); - let items_before = s.project.items.len(); - let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap(); - assert_eq!(s.project.items.len(), items_before + 3, "left, right and stereo comps"); - assert_eq!(r["master"].as_u64(), Some(cam)); - // Controls on a hidden null in the source comp. - let ctl = layer_in(&s, src, r["controls"].as_u64().unwrap()); +/// Controls on a hidden null in the source comp. +fn assert_stereo_controls(ctl: &Layer) { assert_eq!(ctl.name, "Stereo 3D Controls"); assert!(!ctl.switches.video); let fx = ctl.effects().unwrap().groups().next().unwrap().clone(); assert!(matches!(&fx.kind, GroupKind::Effect { effect } if effect == effectcraft_effects::controls::STEREO_CONTROLS)); assert_eq!(fx.get("sceneDepth").unwrap().value, KV::Scalar(4.0)); - // Eye comps: an eye camera (two-node, linked by expressions) and the collapsed source comp. - let d = 0.04 * 640.0; - for (key, name, x) in [("leftComp", "Scene Left Eye", 320.0 - d / 2.0), ("rightComp", "Scene Right Eye", 320.0 + d / 2.0)] { - let ec = ItemId(r[key].as_u64().unwrap()); - assert_eq!(s.project.item(ec).unwrap().name, name); - let c = s.project.comp(ec).unwrap(); - assert_eq!((c.width, c.height), (640, 360)); - assert_eq!(c.layers.len(), 2); - let cam = &c.layers[0]; - assert!(cam.is_camera() && effectcraft_render::three_d::camera::is_two_node(cam)); - assert!(close3(v3(cam, "transform/position"), [x, 180.0, -800.0], 1e-6), "{:?}", v3(cam, "transform/position")); - // Parallel eyes (no convergence): each looks straight ahead. - assert!(close3(v3(cam, "transform/poi"), [x, 180.0, 0.0], 1e-6), "{:?}", v3(cam, "transform/poi")); - assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().value, KV::Scalar(900.0)); - for p in ["transform/position", "transform/poi", "cameraOptions/zoom"] { - let e = cam.props.prop(p).unwrap().expr.clone().unwrap(); - assert!(e.text.contains("comp(\"Scene\")") && e.text.contains("Cam"), "{p}: {}", e.text); - } - let nest = &c.layers[1]; - assert_eq!(nest.source, LayerSource::Comp { item: src }); - assert!(nest.switches.collapse && nest.switches.three_d); +} + +/// An eye comp: an eye camera (two-node, linked by expressions) at `x` and the collapsed source comp. +fn assert_eye_comp(s: &Session, src: ItemId, ec: ItemId, name: &str, x: f64) { + assert_eq!(s.project.item(ec).unwrap().name, name); + let c = s.project.comp(ec).unwrap(); + assert_eq!((c.width, c.height), (640, 360)); + assert_eq!(c.layers.len(), 2); + let cam = &c.layers[0]; + assert!(cam.is_camera() && effectcraft_render::three_d::camera::is_two_node(cam)); + assert!(close3(v3(cam, "transform/position"), [x, 180.0, -800.0], 1e-6), "{:?}", v3(cam, "transform/position")); + // Parallel eyes (no convergence): each looks straight ahead. + assert!(close3(v3(cam, "transform/poi"), [x, 180.0, 0.0], 1e-6), "{:?}", v3(cam, "transform/poi")); + assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().value, KV::Scalar(900.0)); + for p in ["transform/position", "transform/poi", "cameraOptions/zoom"] { + let e = cam.props.prop(p).unwrap().expr.clone().unwrap(); + assert!(e.text.contains("comp(\"Scene\")") && e.text.contains("Cam"), "{p}: {}", e.text); } - // The stereo comp: left eye on top with 3D Glasses fed by both eyes; right eye hidden. + let nest = &c.layers[1]; + assert_eq!(nest.source, LayerSource::Comp { item: src }); + assert!(nest.switches.collapse && nest.switches.three_d); +} + +/// The stereo comp: left eye on top with 3D Glasses fed by both eyes; right eye hidden. +fn assert_stereo_comp(s: &Session, r: &serde_json::Value) { let oc = ItemId(r["stereoComp"].as_u64().unwrap()); let out = s.project.comp(oc).unwrap(); assert_eq!(s.project.item(oc).unwrap().name, "Scene Stereo 3D"); @@ -98,6 +91,23 @@ fn stereo_rig_builds_eye_comps_and_glasses() { assert!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.perspective.3dglasses")); assert_eq!(g.get("leftView").unwrap().value, KV::Layer(Some(l))); assert_eq!(g.get("rightView").unwrap().value, KV::Layer(Some(rr))); +} + +#[test] +fn stereo_rig_builds_eye_comps_and_glasses() { + let mut s = session(); + let src = cid(&s); + let cam = camera(&mut s); + let items_before = s.project.items.len(); + let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap(); + assert_eq!(s.project.items.len(), items_before + 3, "left, right and stereo comps"); + assert_eq!(r["master"].as_u64(), Some(cam)); + assert_stereo_controls(&layer_in(&s, src, r["controls"].as_u64().unwrap())); + let d = 0.04 * 640.0; + for (key, name, x) in [("leftComp", "Scene Left Eye", 320.0 - d / 2.0), ("rightComp", "Scene Right Eye", 320.0 + d / 2.0)] { + assert_eye_comp(&s, src, ItemId(r[key].as_u64().unwrap()), name, x); + } + assert_stereo_comp(&s, &r); // One undo step; redo; serde. let json = serde_json::to_string(&*s.project).unwrap(); let back: Project = serde_json::from_str(&json).unwrap(); diff --git a/crates/engine/src/tests/vector_import.rs b/crates/engine/src/tests/vector_import.rs index 63513b910..9cfb94ecc 100644 --- a/crates/engine/src/tests/vector_import.rs +++ b/crates/engine/src/tests/vector_import.rs @@ -2,7 +2,7 @@ use effectcraft_color::BlendMode; use effectcraft_keyframe::Value as KV; -use effectcraft_project::{ItemKind, LayerSource, MaskMode}; +use effectcraft_project::{Comp, ItemKind, Layer, LayerSource, MaskMode}; use effectcraft_psd::write::*; use effectcraft_psd::{DValue, Descriptor, Rect}; use serde_json::json; @@ -74,22 +74,8 @@ pub(crate) fn sample_psd() -> Vec { write(&d) } -#[test] -fn psd_imports_as_composition() { - let path = tmp("sample.psd"); - std::fs::write(&path, sample_psd()).unwrap(); - let mut s = Session::default(); - let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); - assert_eq!(r["errors"], json!([]), "{r}"); - let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap()); - assert_eq!(s.state.active_comp, Some(cid)); - assert_eq!(s.project.item(cid).unwrap().name, "sample"); - let c = s.project.comp(cid).unwrap().clone(); - assert_eq!((c.width, c.height), (120, 80)); - let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect(); - assert_eq!(names, ["Title", "Hue Shift", "Vector Masked", "Clipped", "Base", "Group A", "Background"]); - // Text layer: editable text, positioned at the type origin. - let title = &c.layers[0]; +/// Text layer: editable text, positioned at the type origin. +fn assert_title_layer(title: &Layer) { assert!(matches!(title.source, LayerSource::Text)); let Some(KV::Text(doc)) = title.props.prop("text/sourceText").map(|p| p.value.clone()) else { panic!("text") }; assert_eq!(doc.text, "Hello PSD"); @@ -97,14 +83,18 @@ fn psd_imports_as_composition() { assert_eq!(doc.font, "Inter"); assert_eq!(doc.style, "Bold"); assert_eq!(title.props.prop("transform/position").unwrap().value.as_vec2(), [8.0, 70.0]); - // Adjustment layer with Hue/Saturation. - let hue = &c.layers[1]; +} + +/// Adjustment layer with Hue/Saturation. +fn assert_hue_layer(hue: &Layer) { assert!(hue.switches.adjustment); let fx = hue.effects().unwrap().groups().next().unwrap(); assert_eq!(fx.get("hue").unwrap().value.as_f64(), 40.0); assert_eq!(fx.get("saturation").unwrap().value.as_f64(), 10.0); - // Vector mask → mask; Stroke style. - let vm = &c.layers[2]; +} + +/// Vector mask → mask; Stroke style. +fn assert_vector_mask_layer(vm: &Layer) { let m = vm.masks().unwrap().groups().next().unwrap(); assert!(matches!(m.kind, effectcraft_project::GroupKind::Mask { mode: MaskMode::Add, .. })); let Some(KV::Path(p)) = m.get("path").map(|p| p.value.clone()) else { panic!("mask path") }; @@ -113,19 +103,23 @@ fn psd_imports_as_composition() { let st = vm.props.group("layerStyles/stroke").unwrap(); assert_eq!(st.get("size").unwrap().value.as_f64(), 2.0); assert_eq!(st.get("position").unwrap().value, KV::Enum(1)); - // Clipped → preserve transparency; opacity and blend mode. +} + +/// Clipped → preserve transparency (opacity, blend mode); drop shadow. +fn assert_clipped_and_shadow(c: &Comp) { let clipped = &c.layers[3]; assert!(clipped.preserve_transparency); assert_eq!(clipped.blend_mode, BlendMode::Screen); assert!((clipped.props.prop("transform/opacity").unwrap().value.as_f64() - 128.0 / 255.0 * 100.0).abs() < 1e-9); - // Drop shadow. let ds = c.layers[4].props.group("layerStyles/dropShadow").unwrap(); assert_eq!(ds.get("distance").unwrap().value.as_f64(), 9.0); assert_eq!(ds.get("angle").unwrap().value.as_f64(), 45.0); assert_eq!(ds.get("useGlobalLight").unwrap().value, KV::Bool(false)); assert_eq!(ds.get("opacity").unwrap().value.as_f64(), 60.0); - // Group → collapsed precomp (pass through) with its layers. - let g = &c.layers[5]; +} + +/// Group → collapsed precomp (pass through) with its layers; footage items name their Photoshop layer. +fn assert_group_precomp(s: &Session, g: &Layer) { assert!(g.switches.collapse); let LayerSource::Comp { item } = g.source else { panic!("precomp") }; let inner = s.project.comp(item).unwrap(); @@ -140,6 +134,27 @@ fn psd_imports_as_composition() { assert_eq!((sl.name.as_str(), sl.layer_size), ("Masked Square", false)); assert_eq!((f.width, f.height), (120, 80)); assert_eq!(s.project.item(item).unwrap().name, "Masked Square/sample.psd"); +} + +#[test] +fn psd_imports_as_composition() { + let path = tmp("sample.psd"); + std::fs::write(&path, sample_psd()).unwrap(); + let mut s = Session::default(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap()); + assert_eq!(s.state.active_comp, Some(cid)); + assert_eq!(s.project.item(cid).unwrap().name, "sample"); + let c = s.project.comp(cid).unwrap().clone(); + assert_eq!((c.width, c.height), (120, 80)); + let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["Title", "Hue Shift", "Vector Masked", "Clipped", "Base", "Group A", "Background"]); + assert_title_layer(&c.layers[0]); + assert_hue_layer(&c.layers[1]); + assert_vector_mask_layer(&c.layers[2]); + assert_clipped_and_shadow(&c); + assert_group_precomp(&s, &c.layers[5]); // One undo step removes everything. s.execute("edit.undo", json!({})).unwrap(); assert!(s.project.comp(cid).is_none());