mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 20:16:52 -06:00
Refactor engine for strict Clippy checks
This commit is contained in:
@@ -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, &[]));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{EngineError, Result, Session, Value};
|
||||
|
||||
// ---------- enablement ----------
|
||||
|
||||
|
||||
@@ -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<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "layer.addMask")?;
|
||||
|
||||
@@ -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)? {
|
||||
|
||||
@@ -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<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod parent_chain_tests {
|
||||
use super::super::json;
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
|
||||
@@ -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<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
|
||||
@@ -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<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Value> {
|
||||
|
||||
@@ -121,16 +121,16 @@ fn parametric_path(g: &PropGroup, lt: effectcraft_time::Tick) -> Option<ShapePat
|
||||
let path = match g.match_id.as_str() {
|
||||
"rect" => 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.
|
||||
|
||||
@@ -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<bool> {
|
||||
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<bool> {
|
||||
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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::params::bad;
|
||||
use super::*;
|
||||
use super::{CommandSpec, Result, Value};
|
||||
|
||||
// ---------- parameter validation (agents) ----------
|
||||
|
||||
|
||||
@@ -221,7 +221,16 @@ pub fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option<ShapePa
|
||||
"rounded" | "roundedRect" => 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()
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{ItemId, Serialize, Value};
|
||||
|
||||
/// Events for frontends (drained each frame).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::prefs;
|
||||
|
||||
pub fn text_families() -> Vec<String> {
|
||||
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::config;
|
||||
|
||||
/// Host services (file access) injected by the frontend.
|
||||
pub trait Services: Send + Sync {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{EngineError, Event, Result, Session, Value, commands, guard};
|
||||
|
||||
impl Session {
|
||||
pub fn new() -> Session {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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`].
|
||||
///
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::Prefs;
|
||||
|
||||
impl Prefs {
|
||||
/// Remember a project in File ▸ Open Recent.
|
||||
|
||||
@@ -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<String, Value>;
|
||||
|
||||
@@ -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<u64> {
|
||||
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::<f64>() / 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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<u8> {
|
||||
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());
|
||||
|
||||
Reference in New Issue
Block a user