mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
Merge main into #2300; Cineon conversion kept in footage_frame_raw (main routes proxies through footage_frame), frame-blend cache key hashes both premul colour and Cineon settings, spec strings combined
This commit is contained in:
@@ -208,7 +208,7 @@ pub(crate) fn source_frame(
|
||||
t: Tick,
|
||||
expr: Option<&dyn ExprHost>,
|
||||
) -> Option<(Arc<Image>, [f64; 2])> {
|
||||
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None };
|
||||
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None, property_overrides: &[], media_overrides: &[] };
|
||||
if let effectcraft_project::LayerSource::Footage { item } = &layer.source
|
||||
&& let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind)
|
||||
&& f.has_video
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! the Warp Stabilizer, `Renderer::layer_input`) for every frame between the layer's In and Out
|
||||
//! points on a background thread, and feeds the frames to `effectcraft_track::stabilize`'s
|
||||
//! analyzer (step 1 of 2, "Analyzing in background"); step 2 ("Stabilizing") derives the
|
||||
//! stabilization plan once to validate the result. When the job finishes, the analysis is
|
||||
//! stabilization plan once to validate and cache the result. When the job finishes, the analysis is
|
||||
//! written into the effect's hidden Analysis parameter as one undo step, together with a key of
|
||||
//! what it was made from (the layer's source, In/Out, start, stretch and Time Remap). Edits that
|
||||
//! change those clear the analysis (`invalidate`, run after every edit) and queue the effect
|
||||
@@ -21,7 +21,7 @@ use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSour
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, StabSettings, WarpAnalysis};
|
||||
use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, WarpAnalysis};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::tracking::lock;
|
||||
@@ -245,9 +245,8 @@ async fn run_work(w: WarpWork, shared: &WarpShared) {
|
||||
let lt0 = layer.layer_time(w.times[0]).seconds();
|
||||
let fd = if w.times.len() > 1 { layer.layer_time(w.times[1]).seconds() - lt0 } else { comp.frame_duration().seconds() };
|
||||
let a = an.finish(lt0, fd);
|
||||
// Step 2: derive a plan once (validates the motion and warms nothing else up).
|
||||
let plan = effectcraft_track::stabilize::plan(&a, &StabSettings { fps: if fd > 0.0 { 1.0 / fd } else { 30.0 }, ..Default::default() });
|
||||
if plan.warps.len() != a.frames.len() || plan.warps.iter().any(|h| h.0.iter().flatten().any(|v| !v.is_finite())) {
|
||||
// Plan from the stored JSON, exactly as rendering does, and retain it for the first render.
|
||||
if !warp_stab::prepare_default_plan(&a.to_json()) {
|
||||
return fail("the stabilization could not be computed");
|
||||
}
|
||||
*lock(&shared.result) = Some(a);
|
||||
|
||||
@@ -45,6 +45,7 @@ fn copy(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
clip.push(KeyClip { layer: lid, path, keys });
|
||||
}
|
||||
let n: usize = clip.iter().map(|c| c.keys.len()).sum();
|
||||
s.state.keyframe_text = crate::keyframe_text::text(&s.project, comp, &s.state.selected_keys, &[]);
|
||||
s.state.key_clipboard = clip;
|
||||
s.state.clip_is_keys = true;
|
||||
Ok(json!(n))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! EffectCraft's own animation preset library (Effects & Presets ▸ * Animation Presets): the
|
||||
//! Transitions category (original presets built on our Transition effects and layer opacity) and
|
||||
//! the Backgrounds category (generators, noise and colour effects driven by expressions).
|
||||
//! the Backgrounds category (generators, noise and colour effects driven by expressions) and the
|
||||
//! Behaviors category (transform and opacity driven by expressions, no effects).
|
||||
//!
|
||||
//! Each preset adds effects (with values and keyframes) and/or keys layer properties, starting
|
||||
//! at the current time, on every selected layer. The library is data
|
||||
@@ -26,13 +27,17 @@ pub const TRANSITIONS_JSON: &str = include_str!("../../presets/transitions.json"
|
||||
/// The original background presets (`presets/backgrounds.json`).
|
||||
pub const BACKGROUNDS_JSON: &str = include_str!("../../presets/backgrounds.json");
|
||||
|
||||
/// The original behavior presets (`presets/behaviors.json`): expression-driven motion on a layer's
|
||||
/// transform and opacity, no effects.
|
||||
pub const BEHAVIORS_JSON: &str = include_str!("../../presets/behaviors.json");
|
||||
|
||||
/// Keys per animated property, so a hostile inline preset can't allocate without bound.
|
||||
const MAX_KEYS: usize = 256;
|
||||
/// Longest expression text an inline preset may carry.
|
||||
const MAX_EXPR: usize = 4096;
|
||||
|
||||
fn presets() -> Vec<Value> {
|
||||
[TRANSITIONS_JSON, BACKGROUNDS_JSON]
|
||||
[TRANSITIONS_JSON, BACKGROUNDS_JSON, BEHAVIORS_JSON]
|
||||
.iter()
|
||||
.flat_map(|src| serde_json::from_str::<Value>(src).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default())
|
||||
.collect()
|
||||
|
||||
@@ -10,11 +10,16 @@ use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project,
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::effect::{effect_time_error, retime_effect};
|
||||
use super::{CommandSpec, bad, frontend, has_comp, has_keys, has_layers, layer_mut, layers_p, match_path_of, resolve_effect_path, selected_leaf_props, str_p};
|
||||
use crate::{EngineError, KeyRef, Result, Session, cmd};
|
||||
|
||||
/// Preset file format version tag (`*.ecpreset`, JSON).
|
||||
pub const PRESET_TAG: &str = "effectcraftAnimationPreset";
|
||||
/// The preset format version that `anim.savePreset` writes.
|
||||
pub const PRESET_VERSION: u64 = 2;
|
||||
/// The first preset version that stores effect keys relative to the earliest key (#1971).
|
||||
const RELATIVE_EFFECT_KEYS: u64 = 2;
|
||||
|
||||
fn has_selected_props(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
@@ -45,13 +50,26 @@ fn selected_effects(s: &Session) -> Vec<(LayerId, PropGroup)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The earliest key anywhere in `groups`, nested controls included.
|
||||
fn earliest_key<'a>(groups: impl IntoIterator<Item = &'a PropGroup>) -> Option<Tick> {
|
||||
let mut first: Option<Tick> = None;
|
||||
for g in groups {
|
||||
g.walk("", &mut |_, pr| {
|
||||
if let Some(t) = pr.keys.iter().map(|k| k.time).min() {
|
||||
first = Some(first.map_or(t, |f| f.min(t)));
|
||||
}
|
||||
});
|
||||
}
|
||||
first
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- presets
|
||||
|
||||
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("anim.savePreset", "missing `path`"))?.to_string();
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let t = s.time();
|
||||
let effects: Vec<(LayerId, PropGroup)> = selected_effects(s);
|
||||
let mut effects: Vec<(LayerId, PropGroup)> = selected_effects(s);
|
||||
let effect_uids: Vec<Uid> = effects.iter().map(|(_, g)| g.uid).collect();
|
||||
let mut props = vec![];
|
||||
for (lid, uid) in selected_leaf_props(s) {
|
||||
@@ -67,7 +85,11 @@ fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("anim.savePreset", "select properties or effects to save"));
|
||||
}
|
||||
// Key times are stored relative to the earliest key so the preset applies at the CTI.
|
||||
let first = props.iter().flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time)).min().unwrap_or(Tick::ZERO);
|
||||
let first =
|
||||
props.iter().flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time)).chain(earliest_key(effects.iter().map(|(_, g)| g))).min().unwrap_or(Tick::ZERO);
|
||||
for (_, g) in &mut effects {
|
||||
retime_effect(g, 1.0, "anim.savePreset", |t| Ok(Tick(t.0.checked_sub(first.0).ok_or_else(|| effect_time_error("anim.savePreset"))?)))?;
|
||||
}
|
||||
let props_json: Vec<Value> = props
|
||||
.iter()
|
||||
.map(|(_, mp, pr)| {
|
||||
@@ -82,12 +104,27 @@ fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_p(p, "name")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default());
|
||||
let doc = json!({PRESET_TAG: 1, "name": name, "properties": props_json, "effects": fx_json});
|
||||
let doc = json!({PRESET_TAG: PRESET_VERSION, "name": name, "properties": props_json, "effects": fx_json});
|
||||
let text = serde_json::to_string_pretty(&doc).unwrap_or_default();
|
||||
s.services.write_file(&path, text.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
Ok(json!({"path": path, "properties": props.len(), "effects": effects.len()}))
|
||||
}
|
||||
|
||||
/// Rewrite an effect property path in a preset (`effects/<effect id>[#n]/<param>`) to the current
|
||||
/// parameter id when it names a parameter that the effect has since renamed or moved into a
|
||||
/// twirl-down group. Any other path comes back unchanged.
|
||||
fn upgrade_effect_path(path: &str) -> String {
|
||||
let Some((seg, tail)) = path.strip_prefix("effects/").and_then(|r| r.split_once('/')) else { return path.to_string() };
|
||||
let effect = match seg.rsplit_once('#') {
|
||||
Some((m, n)) if !m.is_empty() && n.parse::<usize>().is_ok() => m,
|
||||
_ => seg,
|
||||
};
|
||||
match effectcraft_effects::find(effect).and_then(|spec| effectcraft_effects::migrate::current_param_id(spec, tail)) {
|
||||
Some(id) if id != tail => format!("effects/{seg}/{id}"),
|
||||
_ => path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc: Value = match (p.get("preset"), str_p(p, "path")) {
|
||||
(Some(v), _) => v.clone(),
|
||||
@@ -110,14 +147,22 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.save_prefs();
|
||||
s.prefs_revision += 1;
|
||||
}
|
||||
let effects: Vec<PropGroup> =
|
||||
let mut effects: Vec<PropGroup> =
|
||||
doc["effects"].as_array().map(|a| a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()).unwrap_or_default();
|
||||
// Older presets stored effect keys at the source layer's times.
|
||||
if doc[PRESET_TAG].as_u64().unwrap_or(1) < RELATIVE_EFFECT_KEYS
|
||||
&& let Some(first) = earliest_key(&effects)
|
||||
{
|
||||
for g in &mut effects {
|
||||
retime_effect(g, 1.0, "anim.applyPreset", |t| Ok(Tick(t.0.checked_sub(first.0).ok_or_else(|| effect_time_error("anim.applyPreset"))?)))?;
|
||||
}
|
||||
}
|
||||
let props: Vec<(String, Property)> = doc["properties"]
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| {
|
||||
let path = v["path"].as_str()?.to_string();
|
||||
let path = upgrade_effect_path(v["path"].as_str()?);
|
||||
let value: KV = serde_json::from_value(v["value"].clone()).ok()?;
|
||||
let keys: Vec<Keyframe> = serde_json::from_value(v["keys"].clone()).unwrap_or_default();
|
||||
let expr = serde_json::from_value(v["expression"].clone()).ok().flatten();
|
||||
@@ -130,16 +175,30 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let t = s.time();
|
||||
// Effect bounds per layer size the point defaults of parameters added since the preset was saved.
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let sizes: BTreeMap<LayerId, [f64; 2]> =
|
||||
ids.iter().filter_map(|lid| comp.layer(*lid).map(|l| (*lid, effectcraft_render::effect_bounds(&s.project, comp, l).0))).collect();
|
||||
let mut applied = 0;
|
||||
let mut skipped = vec![];
|
||||
s.edit("Apply Animation Preset", None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
for lid in &ids {
|
||||
let size = sizes.get(lid).copied().unwrap_or([100.0, 100.0]);
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
if let Some(fx) = l.props.sub_mut("effects") {
|
||||
for g in &effects {
|
||||
let mut g = g.clone();
|
||||
// Effects saved by an earlier version get the upgrade a project gets on open.
|
||||
let spec = match &g.kind {
|
||||
GroupKind::Effect { effect } => effectcraft_effects::find(effect),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(spec) = spec {
|
||||
effectcraft_effects::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), size);
|
||||
}
|
||||
retime_effect(&mut g, 1.0, "anim.applyPreset", |t| Ok(Tick(t.0.checked_add(lt.0).ok_or_else(|| effect_time_error("anim.applyPreset"))?)))?;
|
||||
g.reassign_uids(&mut next);
|
||||
next += 1;
|
||||
fx.children.push(Node::Group(g));
|
||||
|
||||
@@ -152,7 +152,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn save_copy_xml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?;
|
||||
let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view())?).map_err(EngineError::Other)?;
|
||||
let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view(), false)?).map_err(EngineError::Other)?;
|
||||
s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
s.toast(format!("Saved an XML copy to {path}"));
|
||||
Ok(json!({"path": path, "bytes": xml.len()}))
|
||||
|
||||
@@ -5,7 +5,7 @@ use effectcraft_project::Marker;
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
|
||||
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
|
||||
use crate::prefs::CompPreset;
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
|
||||
@@ -82,7 +82,7 @@ pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> std::result::Result<Vec
|
||||
/// Save Frame As ▸ File with `queue`: a Render Queue item for the current frame, from the Frame
|
||||
/// Default templates (Edit ▸ Templates).
|
||||
fn queue_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use effectcraft_project::render_queue::{RenderQueueItem, TimeSpan};
|
||||
use effectcraft_project::render_queue::{Channels, RenderQueueItem, TimeSpan};
|
||||
use effectcraft_project::render_templates::TemplateSlot;
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
@@ -91,6 +91,9 @@ fn queue_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
it.settings = s.project.render_templates.default_render_settings(TemplateSlot::Still);
|
||||
it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() };
|
||||
it.output = s.project.render_templates.default_output_module(TemplateSlot::Still);
|
||||
if b_p(p, "transparent") == Some(false) {
|
||||
it.output.channels = Channels::Rgb;
|
||||
}
|
||||
if let Some(path) = str_p(p, "path") {
|
||||
it.output.output = path.to_string();
|
||||
} else if !it.output.format.is_sequence() {
|
||||
@@ -107,7 +110,7 @@ fn queue_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn save_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("queue").and_then(Value::as_bool) == Some(true) {
|
||||
if b_p(p, "queue") == Some(true) {
|
||||
return queue_frame(s, p);
|
||||
}
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("comp.saveFrameAs", "missing `path` (.png)"))?.to_string();
|
||||
@@ -117,7 +120,7 @@ fn save_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0);
|
||||
let bg = comp.background;
|
||||
let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() });
|
||||
let rgba = img.to_rgba8_over(bg);
|
||||
let rgba = if b_p(p, "transparent") == Some(false) { img.to_rgba8_over(bg) } else { img.to_rgba8() };
|
||||
let png = encode_png(&rgba, img.width, img.height).map_err(|e| EngineError::Other(format!("PNG encoding failed: {e}")))?;
|
||||
s.services.write_file(&path, &png).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
s.toast(format!("Saved frame to {path}"));
|
||||
@@ -235,7 +238,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"File...",
|
||||
["Composition", "Save Frame As"],
|
||||
Some("Cmd+Alt+S"),
|
||||
"{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now)}",
|
||||
"{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now), transparent?: bool (default true)}",
|
||||
has_comp,
|
||||
save_frame
|
||||
),
|
||||
|
||||
@@ -170,6 +170,21 @@ pub struct FillPlan {
|
||||
pub dir: String,
|
||||
}
|
||||
|
||||
/// The fill holds every frame as float RGBA (16 bytes a pixel), its hole mask (1) and the filled
|
||||
/// copy (16) until it finishes (#1789): when even that is more than `available`, the error to
|
||||
/// report up front, instead of running out of memory partway through.
|
||||
fn memory_shortfall(frames: usize, w: u32, h: u32, available: u64) -> Option<String> {
|
||||
let need = (frames as u64).saturating_mul(u64::from(w)).saturating_mul(u64::from(h)).saturating_mul(33);
|
||||
(need > available).then(|| {
|
||||
let gb = |b: u64| b as f64 / 1e9;
|
||||
format!(
|
||||
"Content-Aware Fill of {frames} frames at {w}×{h} needs at least {:.1} GB of memory, and {:.1} GB is free: fill a shorter range (set the work area and choose Work Area) or close other applications",
|
||||
gb(need),
|
||||
gb(available)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Run a fill: the layer's frames (rendered in passes with a job worker's GPU), the fill, the
|
||||
/// PNG sequence written into `plan.dir` (`Services::store_file`). Returns the files.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -275,6 +290,9 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let (fa, fb) = (fr.frame_at(fr.snap_nearest(a)), fr.frame_at(b - Tick(1)));
|
||||
let times: Vec<Tick> = (fa..=fb).map(|f| fr.tick_of(f)).collect();
|
||||
if let Some(e) = crate::sysinfo::memory().and_then(|m| memory_shortfall(times.len(), w, h, m.available)) {
|
||||
return Err(bad(c, e));
|
||||
}
|
||||
let opts = FillOpts {
|
||||
method: FillMethod::from_name(&st.method).unwrap_or_default(),
|
||||
lighting: lighting(&st.lighting_correction).unwrap_or(0.0),
|
||||
@@ -424,3 +442,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::memory_shortfall;
|
||||
|
||||
/// #1789: 10 s of 1080p30 (300 frames) needs at least 20 GB, which 4 GB free can't hold.
|
||||
#[test]
|
||||
fn a_fill_that_cannot_fit_in_memory_is_refused_up_front() {
|
||||
let e = memory_shortfall(300, 1920, 1080, 4_000_000_000).expect("refused");
|
||||
assert!(e.contains("300 frames") && e.contains("Work Area"), "{e}");
|
||||
assert_eq!(memory_shortfall(30, 1920, 1080, 4_000_000_000), None, "a second of it fits");
|
||||
// Hostile sizes saturate instead of overflowing.
|
||||
assert!(memory_shortfall(usize::MAX, u32::MAX, u32::MAX, u64::MAX - 1).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
//! Layer ▸ Create: Convert to Editable Text (Photoshop type layers), Create Shapes from Text,
|
||||
//! Create Masks from Text and Create Shapes from Vector Layer (SVG footage).
|
||||
//! Create Masks from Text, Create Shapes from Vector Layer (SVG footage) and Create 3D Layer
|
||||
//! Instance (a 2D copy of a 3D model layer that 2D effects can be applied to).
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{ItemKind, LayerSource, Solid};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, has_layers, layers_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector";
|
||||
const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector|instance3d";
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let op = str_p(p, "op").ok_or_else(|| bad("layer.create", format!("missing `op` ({OPS})")))?;
|
||||
@@ -19,6 +22,69 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let time = s.time();
|
||||
let mut created = vec![];
|
||||
let mut skipped = vec![];
|
||||
// All the selected models in one edit: one undo (or redo) removes (or restores) every
|
||||
// instance at once, as one command in the History panel.
|
||||
if op == "instance3d" {
|
||||
// After Effects' Create 3D Layer Instance (Layer ▸ Create): 3D model layers take
|
||||
// no masks or effects, so a comp-sized solid goes at the top of the layer stack
|
||||
// with a 3D Layer Instance effect referencing the model — a 2D copy of its render,
|
||||
// through the comp's camera, that 2D effects can be applied to. The model is switched
|
||||
// off, as After Effects does (back on to keep its shadow).
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let mut eligible = vec![];
|
||||
for lid in &lids {
|
||||
match comp.layer(*lid) {
|
||||
Some(src) if src.source.is_model() => eligible.push((*lid, src.clone())),
|
||||
Some(src) => skipped.push(format!("{}: not a 3D model layer", src.name)),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if !eligible.is_empty() {
|
||||
let ids = s.edit("Create 3D Layer Instance", None, |proj, st| {
|
||||
let mut ids = vec![];
|
||||
for (lid, src) in &eligible {
|
||||
let name = proj.comp(cid).ok_or(EngineError::NoComp)?.unique_layer_name(&format!("{} Instance", src.name));
|
||||
let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder));
|
||||
let sid = proj.add_item(
|
||||
&name,
|
||||
src.label,
|
||||
Some(folder),
|
||||
ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: comp.width, height: comp.height, pixel_aspect: 1.0 }),
|
||||
);
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (comp.width, comp.height), None);
|
||||
// A soloed source keeps its Solo switch with video off, and any_solo hides
|
||||
// the non-soloed copy: the copy takes the Solo over to stay visible.
|
||||
l.switches.solo = src.switches.solo;
|
||||
// A 3D Layer Instance effect referencing the 3D layer: the solid shows its
|
||||
// render (the 2D copy).
|
||||
let fx = effectcraft_effects::find("ec.3d.instance3d").ok_or_else(|| bad("layer.create", "the 3D Layer Instance effect is missing"))?;
|
||||
let mut g = effectcraft_effects::instantiate(fx, &mut Ids(&mut proj.next_id), fx.name, [comp.width as f64, comp.height as f64]);
|
||||
if let Some(pr) = g.get_mut("layer") {
|
||||
pr.value = KV::Layer(Some(lid.0));
|
||||
}
|
||||
if let Some(fxg) = l.props.sub_mut("effects") {
|
||||
fxg.children.push(g.into());
|
||||
}
|
||||
// The model is switched off (After Effects turns the referenced layer off);
|
||||
// the copies go at the top of the stack, selected.
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
if let Some(m) = c.layers.iter_mut().find(|x| x.id == *lid) {
|
||||
m.switches.video = false;
|
||||
}
|
||||
let id = l.id;
|
||||
c.layers.insert(0, l);
|
||||
ids.push(id);
|
||||
}
|
||||
st.selected_layers = ids.clone();
|
||||
Ok(ids)
|
||||
})?;
|
||||
created.extend(ids.iter().map(|i| i.0));
|
||||
}
|
||||
if created.is_empty() && !skipped.is_empty() {
|
||||
return Err(bad("layer.create", skipped.join("; ")));
|
||||
}
|
||||
return Ok(json!({"layers": created, "skipped": skipped}));
|
||||
}
|
||||
for lid in lids {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let Some(src) = comp.layer(lid).cloned() else { continue };
|
||||
@@ -198,5 +264,5 @@ fn insert_above(
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector, layers?}", has_layers, create)]
|
||||
vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector|instance3d, layers?}", has_layers, create)]
|
||||
}
|
||||
|
||||
@@ -74,9 +74,9 @@ fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!(s.state.selected_layers.len()))
|
||||
}
|
||||
fn deselect_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// A deselected layer never keeps an open text edit: commit the edit first, exactly as
|
||||
// `text.endEdit` (an empty Type-tool layer included), so every route — menu, shortcut,
|
||||
// script, MCP — leaves one final state.
|
||||
// A deselected layer never keeps an open text edit: commit it first, exactly as `text.endEdit`,
|
||||
// so every route — menu, shortcut, script, MCP — leaves one final state. An empty Type-tool
|
||||
// layer survives, as it does in After Effects.
|
||||
super::text_edit::end_edit(s, &Value::Null)?;
|
||||
s.state.selected_layers.clear();
|
||||
s.state.selected_props.clear();
|
||||
@@ -146,6 +146,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.selected_layers = created.clone();
|
||||
Ok(created)
|
||||
})?;
|
||||
// Duplicating selects the copies, so an open text edit on a now-deselected layer ends
|
||||
// (#2226): typing must not keep editing an unselected layer.
|
||||
if s.state.text_edit.as_ref().is_some_and(|e| !s.state.selected_layers.contains(&e.layer)) {
|
||||
super::text_edit::end_edit(s, &Value::Null)?;
|
||||
}
|
||||
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
@@ -263,6 +268,7 @@ fn clear_clipboards(s: &mut Session) {
|
||||
s.state.contents_clipboard.clear();
|
||||
s.state.link_clipboard = None;
|
||||
s.state.value_clipboard.clear();
|
||||
s.state.keyframe_text = None;
|
||||
s.state.clip_is_keys = false;
|
||||
}
|
||||
|
||||
@@ -329,10 +335,12 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
}
|
||||
let mut keys = s.state.selected_keys.clone();
|
||||
let mut values = vec![];
|
||||
let mut statics = vec![];
|
||||
for (lid, uid) in &leaves {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
let Some(pr) = l.props.find(*uid) else { continue };
|
||||
if pr.keys.is_empty() {
|
||||
statics.push((*lid, *uid));
|
||||
if let Some(path) = l.props.match_path_of(*uid)
|
||||
&& !values.iter().any(|(p, _)| *p == path)
|
||||
{
|
||||
@@ -342,6 +350,7 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time }));
|
||||
}
|
||||
}
|
||||
let text = crate::keyframe_text::text(&s.project, comp, &keys, &statics);
|
||||
clear_clipboards(s);
|
||||
let mut n = 0;
|
||||
if !keys.is_empty() {
|
||||
@@ -350,6 +359,7 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
s.state.selected_keys = selected;
|
||||
n = r?.as_u64().unwrap_or(0);
|
||||
}
|
||||
s.state.keyframe_text = text;
|
||||
let v = values.len();
|
||||
s.state.value_clipboard = values;
|
||||
// Keys alone answer as `keys.copy` does (their count).
|
||||
|
||||
@@ -136,7 +136,16 @@ fn warning(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == uid)).ok_or_else(|| bad(cmd, "no such effect"))?;
|
||||
let t = super::time_p(s, p, Some(comp));
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
let ctx = effectcraft_render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: t,
|
||||
expr: s.expr.as_deref(),
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
Ok(json!({"effect": uid, "warning": effectcraft_effects::warning(g, &mut |pr| ctx.value(layer, pr))}))
|
||||
}
|
||||
|
||||
@@ -160,7 +169,16 @@ fn pick_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.filter(|pr| pr.ui == effectcraft_project::ParamUi::Color)
|
||||
.ok_or_else(|| bad(cmd, "not a colour parameter of this effect"))?;
|
||||
let t = super::time_p(s, p, Some(comp));
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
let ctx = effectcraft_render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: t,
|
||||
expr: s.expr.as_deref(),
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let mut r = effectcraft_render::Renderer::new(&s.project, &*s.footage, effectcraft_render::RenderOpts::default());
|
||||
r.expr = s.expr.as_deref();
|
||||
r.cache = Some(&s.layer_cache);
|
||||
@@ -288,7 +306,16 @@ impl CryptoProbe {
|
||||
let comp = r.project.comp(cid).ok_or("no composition")?;
|
||||
let layer = comp.layer(lid).ok_or("no such layer")?;
|
||||
let g = layer.effects().and_then(|fx| fx.groups().nth(pos)).ok_or("no such effect")?;
|
||||
let ctx = effectcraft_render::EvalCtx { project: r.project, comp_id: cid, comp, time, expr: r.expr, footage: None };
|
||||
let ctx = effectcraft_render::EvalCtx {
|
||||
project: r.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time,
|
||||
expr: r.expr,
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
|
||||
let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset");
|
||||
let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?;
|
||||
@@ -351,7 +378,16 @@ fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick
|
||||
let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?;
|
||||
let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?;
|
||||
let time = super::time_p(s, p, Some(comp));
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None };
|
||||
let ctx = effectcraft_render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time,
|
||||
expr: s.expr.as_deref(),
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let frame = probe_frame(&ctx, layer, pos);
|
||||
let o = s.render_opts(Default::default());
|
||||
let prefs = (o.nested_switches, o.draft_shadows);
|
||||
@@ -503,7 +539,16 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Hit> {
|
||||
let Some(hash) = b.aux.index_at(ax, ay).and_then(|i| b.aux.crypto_id_at(&b.layer, i)) else { return Ok(Hit::Nothing) };
|
||||
let (cid, lid, ..) = key.at;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None };
|
||||
let ctx = effectcraft_render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time,
|
||||
expr: s.expr.as_deref(),
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string();
|
||||
let driven = prop.expr.as_ref().is_some_and(|e| e.enabled);
|
||||
let (aux, layer_name) = (b.aux.clone(), b.layer.clone());
|
||||
@@ -700,7 +745,7 @@ fn effects_p(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(LayerId, Uid)>> {
|
||||
Ok(sel)
|
||||
}
|
||||
|
||||
fn effect_time_error(cmd: &str) -> EngineError {
|
||||
pub(crate) fn effect_time_error(cmd: &str) -> EngineError {
|
||||
bad(cmd, "an effect keyframe converts outside the supported time range")
|
||||
}
|
||||
|
||||
@@ -736,7 +781,7 @@ fn effect_time(layer: &Layer, time: Tick, to_comp: bool, cmd: &str) -> Result<Ti
|
||||
|
||||
/// Change the key time coordinate system throughout an effect, including nested controls.
|
||||
/// Ease speeds are per second in that coordinate system; reversed time swaps the key sides.
|
||||
fn retime_effect(group: &mut PropGroup, scale: f64, cmd: &str, mut time: impl FnMut(Tick) -> Result<Tick>) -> Result<()> {
|
||||
pub(crate) fn retime_effect(group: &mut PropGroup, scale: f64, cmd: &str, mut time: impl FnMut(Tick) -> Result<Tick>) -> Result<()> {
|
||||
let mut result = Ok(());
|
||||
group.walk_mut(&mut |pr| {
|
||||
if result.is_err() {
|
||||
@@ -804,6 +849,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.state.clipboard.clear();
|
||||
s.state.key_clipboard.clear();
|
||||
s.state.keyframe_text = None;
|
||||
s.state.contents_clipboard.clear();
|
||||
s.state.link_clipboard = None;
|
||||
s.state.clip_is_keys = false;
|
||||
|
||||
@@ -163,6 +163,8 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)>
|
||||
("easeIn(t, tMin, tMax, value1, value2)", "easeIn(t, tMin, tMax, value1, value2)"),
|
||||
("easeOut(t, value1, value2)", "easeOut(t, value1, value2)"),
|
||||
("easeOut(t, tMin, tMax, value1, value2)", "easeOut(t, tMin, tMax, value1, value2)"),
|
||||
("bezier(t, value1, value2, [x1, y1, x2, y2])", "bezier(t, value1, value2, [x1, y1, x2, y2])"),
|
||||
("bezier(t, tMin, tMax, value1, value2, [x1, y1, x2, y2])", "bezier(t, tMin, tMax, value1, value2, [x1, y1, x2, y2])"),
|
||||
],
|
||||
),
|
||||
(
|
||||
|
||||
@@ -87,7 +87,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project
|
||||
/// wouldn't read back.
|
||||
pub(crate) fn file_text(s: &Session, path: &str) -> Result<String> {
|
||||
let json = s.project.to_file_json_with_view(&s.saved_view())?;
|
||||
let compact = s.prefs.project.compact_project_files && !crate::xml_project::is_xml_path(path);
|
||||
let json = s.project.to_file_json_with_view(&s.saved_view(), compact)?;
|
||||
if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) }
|
||||
}
|
||||
|
||||
@@ -745,7 +746,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"File...",
|
||||
["File", "Import"],
|
||||
Some("Cmd+I"),
|
||||
"{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default: a numbered still brings its run, except dropped files (drag), which import as stills as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
|
||||
"{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default false: picked or dropped files import as stills, as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
|
||||
always,
|
||||
import_cmd
|
||||
),
|
||||
|
||||
@@ -1029,10 +1029,34 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("file.replaceFootage", "missing `path`"))?.to_string();
|
||||
let item = *items_p(s, p).first().ok_or_else(|| bad("file.replaceFootage", "select footage"))?;
|
||||
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
|
||||
// A numbered still brings its image sequence, as on import (`sequence: false` for one frame).
|
||||
let opts = SequenceOptions::from_params(p);
|
||||
// Replacing a still keeps it a still; replacing a sequence keeps bringing the new file's run.
|
||||
// `sequence`/`alphabetical` override that (#2073).
|
||||
let previous = match s.project.item(item).map(|it| &it.kind) {
|
||||
Some(ItemKind::Footage(f)) if f.kind == FootageKind::Sequence => (true, true, f.alphabetical),
|
||||
_ => (false, false, false),
|
||||
};
|
||||
// Only `sequence` decides whether the item keeps its kind; `alphabetical` is a sequence
|
||||
// modifier, not a request to make one.
|
||||
let inherited = b_p(p, "sequence").is_none();
|
||||
let mut opts = SequenceOptions::from_params(p);
|
||||
if inherited {
|
||||
(opts.sequence, opts.folders) = (previous.0, previous.1);
|
||||
if previous.0 && b_p(p, "alphabetical").is_none() {
|
||||
opts.alphabetical = previous.2;
|
||||
}
|
||||
}
|
||||
let src = crate::sequence::source_of(s.services.as_ref(), &path, opts);
|
||||
let f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let mut f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
// A single-file source comes back as a still; an inherited sequence kind keeps it a
|
||||
// one-frame sequence, so a later default replacement still brings numbered runs (#2073).
|
||||
if inherited && previous.0 && matches!(src, crate::sequence::Source::File(_)) && f.kind == FootageKind::Still {
|
||||
f.kind = FootageKind::Sequence;
|
||||
f.sequence = vec![f.path.clone()];
|
||||
f.alphabetical = opts.alphabetical;
|
||||
f.frame_rate = s.sequence_rate();
|
||||
f.native_rate = None;
|
||||
f.sync_sequence_duration();
|
||||
}
|
||||
let name = src.name();
|
||||
// Replacing a missing file relinks the other missing files that moved with it (as After Effects
|
||||
// does when you locate one).
|
||||
@@ -1217,7 +1241,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Main...",
|
||||
["File", "Interpret Footage"],
|
||||
Some("Cmd+Alt+G"),
|
||||
"{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start), logConversion? (Cineon Settings of DPX and Cineon files), cineonUnits?: 10bit|8bit|float, convertedBlack?, convertedWhite?, pointUnits?: 10bit|8bit|float (the units of convertedBlack and convertedWhite; default cineonUnits, else the footage's Units), tenBitBlack?, tenBitWhite? (0..1023), currentGamma? (0.1..10), highlightRolloff? (0..100)}",
|
||||
"{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start), logConversion? (Cineon Settings of DPX and Cineon files), cineonUnits?: 10bit|8bit|float, convertedBlack?, convertedWhite?, pointUnits?: 10bit|8bit|float (the units of convertedBlack and convertedWhite; default cineonUnits, else the footage's Units), tenBitBlack?, tenBitWhite? (0..1023), currentGamma? (0.1..10), highlightRolloff? (0..100)}",
|
||||
has_footage_selection,
|
||||
interpret
|
||||
),
|
||||
@@ -1235,7 +1259,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply Interpretation",
|
||||
["File", "Interpret Footage"],
|
||||
Some("Cmd+Alt+V"),
|
||||
"{items?}",
|
||||
"{items?|item?}",
|
||||
has_interpretation,
|
||||
apply_interpretation
|
||||
),
|
||||
@@ -1244,7 +1268,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"File...",
|
||||
["File", "Replace Footage"],
|
||||
Some("Cmd+H"),
|
||||
"{path, item?, sequence?: bool (default true: a numbered still brings its image sequence), alphabetical?: bool}",
|
||||
"{path, item?, sequence?: bool (a numbered still brings its image sequence; default: the item keeps its kind, so a still stays a still and a sequence stays a sequence), alphabetical?: bool}",
|
||||
has_footage_selection,
|
||||
replace_footage
|
||||
),
|
||||
@@ -1266,7 +1290,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_footage_selection,
|
||||
replace_with_solid
|
||||
),
|
||||
cmd!("file.reloadFootage", "Reload Footage", ["File"], Some("Cmd+Alt+L"), "{items?}", has_footage_selection, reload),
|
||||
cmd!("file.reloadFootage", "Reload Footage", ["File"], Some("Cmd+Alt+L"), "{items?|item?}", has_footage_selection, reload),
|
||||
cmd!("file.revealInFinder", "Reveal in Finder", ["File"], None, "{item?}", has_footage_selection, reveal_in_finder),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -78,6 +78,12 @@ fn set_to_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "camera.setFocusToLayer";
|
||||
let (cid, cam, target) = refs(s, p, c, true)?;
|
||||
let d = focus_depth(s, cid, cam, target.ok_or_else(|| bad(c, "no layer"))?).ok_or_else(|| bad(c, "can't evaluate the layers"))?;
|
||||
// Only a finite depth can be stored: JSON has no infinity or NaN, so the project could no
|
||||
// longer be saved. Extreme but finite positions can still produce one, and `d <= 0.0` is
|
||||
// false for NaN.
|
||||
if !d.is_finite() {
|
||||
return Err(bad(c, "can't measure the distance to the layer (the camera or layer position is out of range)"));
|
||||
}
|
||||
if d <= 0.0 {
|
||||
return Err(bad(c, "the layer is behind the camera"));
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Track Matte",
|
||||
[],
|
||||
None,
|
||||
"{layer?, matte: layer|null, kind?: alpha|alphaInverted|luma|lumaInverted}",
|
||||
"{layers? | layer?, matte: layer|null, kind?: alpha|alphaInverted|luma|lumaInverted|preserve}",
|
||||
has_layers,
|
||||
track_matte
|
||||
),
|
||||
|
||||
@@ -161,7 +161,7 @@ pub(super) fn text_range_p(p: &Value) -> Option<std::ops::Range<usize>> {
|
||||
/// It is left justified unless `justify` is given. The layer is named by `name`, else the first 40
|
||||
/// characters of the first text line, else "Text". `edit: true` also opens a new editing
|
||||
/// session with anchor `0` and the caret after the text, selecting all initial text so the next
|
||||
/// insertion replaces it. The session has `created: true`, so leaving it empty removes the layer.
|
||||
/// insertion replaces it. The session has `created: true`; leaving it empty keeps the layer, as After Effects does.
|
||||
pub(super) fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Comp, EngineError, ItemId, KV, Layer, LayerId, PropGroup, Result, Session, Tick, Value, b_p, bad, layers_p, resolve_layer};
|
||||
use super::{Comp, EngineError, ItemId, KV, Layer, LayerId, PropGroup, Result, Session, Tick, Value, b_p, bad, layers_p, resolve_layer, unlocked};
|
||||
|
||||
/// Re-express a layer in its new parent space without moving its anchor.
|
||||
pub(crate) struct ParentFix {
|
||||
@@ -9,10 +9,20 @@ pub(crate) struct ParentFix {
|
||||
rotation: f64,
|
||||
/// Multiply Scale.
|
||||
scale: [f64; 2],
|
||||
/// 3D AV layers retain their world-space anchor, including Z.
|
||||
/// 3D AV layers retain their world-space anchor, including Z. Cameras and lights also
|
||||
/// re-express their Point of Interest.
|
||||
position_3d: Option<effectcraft_geom::Mat4>,
|
||||
/// A uniform relative parent transform can preserve static Orientation and Scale.
|
||||
pose_3d: Option<([f64; 3], f64)>,
|
||||
/// A uniform relative parent transform re-expresses static Orientation and Scale.
|
||||
pose_3d: Option<Pose3d>,
|
||||
}
|
||||
|
||||
/// A uniform relative parent transform, as it applies to a 3D layer's Orientation and Scale.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Pose3d {
|
||||
/// Rotation that Orientation is re-expressed under: `Orientation' = correction * Orientation`.
|
||||
correction: effectcraft_geom::Mat4,
|
||||
/// Uniform scale factor, multiplied into Scale (negative for a mirrored parent).
|
||||
factor: f64,
|
||||
}
|
||||
|
||||
impl ParentFix {
|
||||
@@ -22,7 +32,7 @@ impl ParentFix {
|
||||
if let Some(m) = self.position_3d {
|
||||
// Spatial Position speed measures distance along its path. Its parent-space
|
||||
// unit changes even when the child's Orientation cannot be re-expressed.
|
||||
let position_speed = uniform_parent_pose(m, [0.0; 3]).map(|(_, factor)| factor.abs());
|
||||
let position_speed = uniform_rotation(m).map(|(_, factor)| factor.abs());
|
||||
let map_position = |v: &KV| {
|
||||
let q = m.apply(effectcraft_geom::Vec3::from(v.as_vec3()));
|
||||
KV::Vec3([q.x, q.y, q.z])
|
||||
@@ -44,7 +54,8 @@ impl ParentFix {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(pr) = tr.get_mut("position") {
|
||||
// Position and a camera's or light's Point of Interest are both points in the parent's space.
|
||||
let map_point = |pr: &mut effectcraft_project::Property| {
|
||||
pr.value = map_position(&pr.value);
|
||||
for key in &mut pr.keys {
|
||||
key.value = map_position(&key.value);
|
||||
@@ -64,34 +75,19 @@ impl ParentFix {
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(pr) = tr.get_mut("position") {
|
||||
map_point(pr);
|
||||
}
|
||||
// Cameras and lights aim with Point of Interest: move it by the same parent-space
|
||||
// change, so a two-node rig keeps its world frame (#1010, #1020).
|
||||
if let Some(pr) = tr.get_mut("poi") {
|
||||
pr.value = map_position(&pr.value);
|
||||
for key in &mut pr.keys {
|
||||
key.value = map_position(&key.value);
|
||||
for tangent in [&mut key.spatial_in, &mut key.spatial_out] {
|
||||
let v = m.apply_vec(effectcraft_geom::Vec3::from(*tangent));
|
||||
*tangent = [v.x, v.y, v.z];
|
||||
}
|
||||
}
|
||||
if let Some(factor) = position_speed
|
||||
.filter(|_| pr.spatial)
|
||||
.filter(|factor| pr.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * *factor).is_finite()))
|
||||
{
|
||||
for key in &mut pr.keys {
|
||||
for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) {
|
||||
ease.speed *= factor;
|
||||
}
|
||||
}
|
||||
}
|
||||
map_point(pr);
|
||||
}
|
||||
if let Some((orientation, factor)) = self.pose_3d {
|
||||
if let Some(pose) = self.pose_3d {
|
||||
if let Some(pr) = tr.get_mut("orientation") {
|
||||
pr.value = KV::Vec3(orientation);
|
||||
reexpress_orientation(pose.correction, pr);
|
||||
}
|
||||
if let Some(pr) = tr.get_mut("scale") {
|
||||
let factor = pose.factor;
|
||||
let scaled = |value: &KV| KV::Vec3(value.as_vec3().map(|v| v * factor));
|
||||
pr.value = scaled(&pr.value);
|
||||
for key in &mut pr.keys {
|
||||
@@ -154,13 +150,10 @@ impl ParentFix {
|
||||
}
|
||||
}
|
||||
|
||||
/// A uniform factor commutes with authored axis Rotation and Scale; prepend its orthogonal
|
||||
/// rotation to static Orientation using the existing Rz * Ry * Rx order.
|
||||
fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Option<([f64; 3], f64)> {
|
||||
use effectcraft_geom::{Mat4, Vec3, vec3};
|
||||
if !orientation.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
/// The rotation and signed scale factor of a uniform parent transform's linear part (negative for a
|
||||
/// mirrored parent). `None` for a non-uniform or degenerate transform.
|
||||
fn uniform_rotation(m: effectcraft_geom::Mat4) -> Option<(effectcraft_geom::Mat4, f64)> {
|
||||
use effectcraft_geom::{Mat4, vec3};
|
||||
let a = m.0;
|
||||
let columns = [vec3(a[0][0], a[1][0], a[2][0]), vec3(a[0][1], a[1][1], a[2][1]), vec3(a[0][2], a[1][2], a[2][2])];
|
||||
let magnitude = columns[0].x.hypot(columns[0].y).hypot(columns[0].z);
|
||||
@@ -179,15 +172,188 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti
|
||||
rotation.0[row][col] = a[row][col] / factor;
|
||||
}
|
||||
}
|
||||
if (0..3).all(|row| (0..3).all(|col| (rotation.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) {
|
||||
return Some((orientation, factor));
|
||||
}
|
||||
let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0;
|
||||
Some((rotation, factor))
|
||||
}
|
||||
|
||||
/// The turn (degrees) of a rotation about Z alone, or `None` for any other rotation.
|
||||
fn z_only_turn(correction: effectcraft_geom::Mat4) -> Option<f64> {
|
||||
let m = correction.0;
|
||||
let about_z = [m[0][2], m[1][2], m[2][0], m[2][1]].iter().all(|v| v.abs() < 1e-9) && (m[2][2] - 1.0).abs() < 1e-9;
|
||||
about_z.then(|| m[1][0].atan2(m[0][0]).to_degrees())
|
||||
}
|
||||
|
||||
/// ZYX Euler angles in degrees of a row-major rotation (`Rz * Ry * Rx`), safe at gimbal lock.
|
||||
fn euler_zyx(r: [[f64; 4]; 4]) -> [f64; 3] {
|
||||
// atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact.
|
||||
let cy = r[0][0].hypot(r[1][0]);
|
||||
let y = (-r[2][0]).atan2(cy);
|
||||
let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) };
|
||||
Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor))
|
||||
[x.to_degrees(), y.to_degrees(), z.to_degrees()]
|
||||
}
|
||||
|
||||
/// The equivalent ZYX Euler triple nearest `reference`: each axis moved by whole turns, or the
|
||||
/// `(x + 180, 180 - y, z + 180)` form when that is nearer.
|
||||
fn nearest_euler(euler: [f64; 3], reference: [f64; 3]) -> [f64; 3] {
|
||||
let near = |v: [f64; 3]| -> [f64; 3] { std::array::from_fn(|i| v[i] + ((reference[i] - v[i]) / 360.0).round() * 360.0) };
|
||||
let natural = near(euler);
|
||||
let flipped = near([euler[0] + 180.0, 180.0 - euler[1], euler[2] + 180.0]);
|
||||
let distance = |v: [f64; 3]| v.iter().zip(reference.iter()).map(|(a, b)| (a - b).powi(2)).sum::<f64>();
|
||||
if distance(flipped) < distance(natural) { flipped } else { natural }
|
||||
}
|
||||
|
||||
/// The Euler Orientation of `correction * Orientation(o)`, in the form nearest `reference`.
|
||||
fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [f64; 3]) -> [f64; 3] {
|
||||
let world = correction * effectcraft_geom::Mat4::orientation(effectcraft_geom::Vec3::from(o));
|
||||
nearest_euler(euler_zyx(world.0), reference)
|
||||
}
|
||||
|
||||
/// Largest angle (degrees) a baked Orientation may drift from the exact rotation between its keys.
|
||||
const BAKE_TOLERANCE_DEGREES: f64 = 0.05;
|
||||
/// How many times a segment may be halved while baking, and the most keys a property may end up with.
|
||||
const BAKE_MAX_DEPTH: u32 = 10;
|
||||
const BAKE_MAX_KEYS: usize = 4096;
|
||||
|
||||
/// The angle (degrees) between two rotations.
|
||||
fn rotation_angle(a: effectcraft_geom::Mat4, b: effectcraft_geom::Mat4) -> f64 {
|
||||
let trace: f64 = (0..3).flat_map(|i| (0..3).map(move |j| (i, j))).map(|(i, j)| a.0[i][j] * b.0[i][j]).sum();
|
||||
((trace - 1.0) / 2.0).clamp(-1.0, 1.0).acos().to_degrees()
|
||||
}
|
||||
|
||||
/// Adds keys between `(ta, ea)` and `(tb, eb)` (already converted) wherever the straight Euler blend
|
||||
/// between them drifts from `correction * Orientation(authored)` by more than the tolerance. `out`
|
||||
/// never grows past `budget` keys: what the whole property may still add, not what one segment may.
|
||||
fn bake_between(
|
||||
correction: effectcraft_geom::Mat4,
|
||||
authored: &effectcraft_project::Property,
|
||||
(ta, ea): (Tick, [f64; 3]),
|
||||
(tb, eb): (Tick, [f64; 3]),
|
||||
depth: u32,
|
||||
budget: usize,
|
||||
out: &mut Vec<effectcraft_keyframe::Keyframe>,
|
||||
) {
|
||||
use effectcraft_geom::{Mat4, Vec3};
|
||||
if depth == 0 || tb.0 - ta.0 < 2 || out.len() >= budget {
|
||||
return;
|
||||
}
|
||||
let tm = Tick(ta.0 + (tb.0 - ta.0) / 2);
|
||||
let blend = [(ea[0] + eb[0]) / 2.0, (ea[1] + eb[1]) / 2.0, (ea[2] + eb[2]) / 2.0];
|
||||
let exact = correction * Mat4::orientation(Vec3::from(authored.value_at(tm).as_vec3()));
|
||||
// Symmetric easing crosses the straight blend at its midpoint. Check both sides too before
|
||||
// accepting a segment whose original easing will be replaced with a linear blend.
|
||||
let quarter = (tb.0 - ta.0) / 4;
|
||||
if [Tick(ta.0 + quarter), tm, Tick(tb.0 - quarter)].into_iter().all(|t| {
|
||||
let u = (t.0 - ta.0) as f64 / (tb.0 - ta.0) as f64;
|
||||
let linear = std::array::from_fn(|i| ea[i] + (eb[i] - ea[i]) * u);
|
||||
let target = correction * Mat4::orientation(Vec3::from(authored.value_at(t).as_vec3()));
|
||||
rotation_angle(Mat4::orientation(Vec3::from(linear)), target) <= BAKE_TOLERANCE_DEGREES
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
let em = nearest_euler(euler_zyx(exact.0), blend);
|
||||
bake_between(correction, authored, (ta, ea), (tm, em), depth - 1, budget, out);
|
||||
if out.len() >= budget {
|
||||
return;
|
||||
}
|
||||
out.push(effectcraft_keyframe::Keyframe::new(tm, KV::Vec3(em)));
|
||||
bake_between(correction, authored, (tm, em), (tb, eb), depth - 1, budget, out);
|
||||
}
|
||||
|
||||
/// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the
|
||||
/// value and every key. For any other correction a static value is converted directly; keys are
|
||||
/// converted, and keys are added between them wherever the Euler blend between two keys would drift
|
||||
/// from the exact rotation (the segments become linear, with the original easing baked in).
|
||||
fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) {
|
||||
if let Some(turn) = z_only_turn(correction) {
|
||||
// No turn (an identity correction) leaves every authored value as it is.
|
||||
if turn.abs() <= 1e-12 {
|
||||
return;
|
||||
}
|
||||
let shift = |value: &KV| {
|
||||
let [x, y, z] = value.as_vec3();
|
||||
KV::Vec3([x, y, z + turn])
|
||||
};
|
||||
pr.value = shift(&pr.value);
|
||||
for key in &mut pr.keys {
|
||||
key.value = shift(&key.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let value = pr.value.as_vec3();
|
||||
pr.value = KV::Vec3(reexpress_euler(correction, value, value));
|
||||
if pr.keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
let authored = pr.clone();
|
||||
// Each key keeps the authored change from the previous key on top of its converted value.
|
||||
let mut converted: Vec<[f64; 3]> = vec![];
|
||||
let mut prev: Option<([f64; 3], [f64; 3])> = None;
|
||||
for key in &authored.keys {
|
||||
let o = key.value.as_vec3();
|
||||
let reference = match prev {
|
||||
Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]],
|
||||
None => o,
|
||||
};
|
||||
let n = reexpress_euler(correction, o, reference);
|
||||
converted.push(n);
|
||||
prev = Some((o, n));
|
||||
}
|
||||
use effectcraft_keyframe::Interp;
|
||||
let mut keys: Vec<effectcraft_keyframe::Keyframe> = vec![];
|
||||
let mut after_baked_segment = false;
|
||||
for (i, key) in authored.keys.iter().enumerate() {
|
||||
let mut k = key.clone();
|
||||
k.value = KV::Vec3(converted[i]);
|
||||
if after_baked_segment {
|
||||
k.in_interp = Interp::Linear;
|
||||
k.in_ease.clear();
|
||||
k.auto_bezier = false;
|
||||
k.continuous = false;
|
||||
}
|
||||
after_baked_segment = false;
|
||||
let segment = authored.keys.get(i + 1).filter(|_| key.out_interp != Interp::Hold);
|
||||
let mut between = vec![];
|
||||
if let Some(next) = segment {
|
||||
// What the whole property may still add: the keys it will have already (the ones written
|
||||
// and the authored ones still to come) count against the cap.
|
||||
let budget = BAKE_MAX_KEYS.saturating_sub(keys.len() + authored.keys.len() - i);
|
||||
bake_between(correction, &authored, (key.time, converted[i]), (next.time, converted[i + 1]), BAKE_MAX_DEPTH, budget, &mut between);
|
||||
// With the cap reached the segment can't be checked or subdivided: it keeps its authored easing between
|
||||
// the converted keys, rather than becoming a straight blend that was never checked against the original.
|
||||
if between.len() >= budget {
|
||||
between.clear();
|
||||
// An eased segment, or one that gained keys, becomes a run of straight blends.
|
||||
} else if !between.is_empty() || key.out_interp != Interp::Linear || next.in_interp != Interp::Linear {
|
||||
k.out_interp = Interp::Linear;
|
||||
k.out_ease.clear();
|
||||
k.auto_bezier = false;
|
||||
k.continuous = false;
|
||||
after_baked_segment = true;
|
||||
}
|
||||
}
|
||||
keys.push(k);
|
||||
keys.extend(between);
|
||||
}
|
||||
pr.keys = keys;
|
||||
}
|
||||
|
||||
/// Correction for a two-node rig's Orientation: after a uniform parent change its aim is
|
||||
/// `rotation * aim`, so the roll is re-expressed against that aim. `None` for a degenerate aim.
|
||||
fn aim_correction(rotation: effectcraft_geom::Mat4, aim: [f64; 3]) -> Option<effectcraft_geom::Mat4> {
|
||||
use effectcraft_geom::vec3;
|
||||
use effectcraft_render::three_d::camera::basis_rotation;
|
||||
let [x, y, z] = aim;
|
||||
let length = (x * x + y * y + z * z).sqrt();
|
||||
if !length.is_finite() || length <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let dir = vec3(x / length, y / length, z / length);
|
||||
let turned = rotation.apply_vec(dir);
|
||||
// A view along the up axis has no roll to keep, and its basis is undefined.
|
||||
if 1.0 - dir.y * dir.y < 1e-12 || 1.0 - turned.y * turned.y < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let up = vec3(0.0, 1.0, 0.0);
|
||||
Some(basis_rotation(turned, up).transpose() * rotation * basis_rotation(dir, up))
|
||||
}
|
||||
|
||||
/// A parent change retains 2D transforms and the world-space anchor of 3D layers (Position,
|
||||
@@ -196,8 +362,16 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti
|
||||
/// time.
|
||||
pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option<LayerId>) -> Vec<ParentFix> {
|
||||
let Some(comp) = s.project.comp(cid) else { return vec![] };
|
||||
let ectx =
|
||||
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
|
||||
let ectx = effectcraft_render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: s.time(),
|
||||
expr: s.expr.as_deref(),
|
||||
footage: Some(s.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let space = |id: Option<LayerId>| -> effectcraft_geom::Mat3 {
|
||||
id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY)
|
||||
};
|
||||
@@ -235,31 +409,58 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op
|
||||
})
|
||||
};
|
||||
if let Some(inv) = space_3d(parent).inverse().filter(|m| m.0.iter().flatten().all(|v| v.is_finite())) {
|
||||
// Cameras and lights compensate like 3D layers (Position, and Point of Interest for
|
||||
// two-node rigs), so parenting keeps the world frame (#1010, #1020). They have no
|
||||
// Scale, so `pose_3d` below stays `None` and Orientation is kept as authored.
|
||||
for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && l.parent != parent) {
|
||||
let m = inv * space_3d(layer.parent);
|
||||
// A one-node camera or light has no Scale to carry a mirror, and its frame (forward and
|
||||
// down) would come out reversed: compensate none of it, so Position and Orientation stay
|
||||
// as authored instead of half-following the parent.
|
||||
let rig = layer.is_camera() || layer.is_light();
|
||||
if rig && !effectcraft_render::three_d::camera::is_two_node(layer) && uniform_rotation(m).is_some_and(|(_, factor)| factor <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
if m.0.iter().flatten().all(|v| v.is_finite()) {
|
||||
let pose_3d = layer.transform().and_then(|tr| {
|
||||
let orientation = tr.get("orientation")?;
|
||||
let scale = tr.get("scale")?;
|
||||
// Animated Euler Orientation and auto-orient cannot be re-expressed by
|
||||
// prepending a constant Euler value. Expression-driven values stay authored.
|
||||
if !orientation.keys.is_empty()
|
||||
|| orientation.expr.is_some()
|
||||
|| scale.expr.is_some()
|
||||
|| layer.auto_orient != effectcraft_project::AutoOrient::Off
|
||||
// Cameras and lights have no Scale; their Orientation is re-expressed alone.
|
||||
let scale = tr.get("scale");
|
||||
// Expression-driven values stay authored. Auto-orient replaces Orientation, except
|
||||
// a two-node rig, whose roll is re-expressed against its new aim.
|
||||
let two_node = (layer.is_camera() || layer.is_light()) && effectcraft_render::three_d::camera::is_two_node(layer);
|
||||
if orientation.expr.is_some()
|
||||
|| scale.is_some_and(|scale| scale.expr.is_some())
|
||||
|| (layer.auto_orient != effectcraft_project::AutoOrient::Off && !two_node)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let pose = uniform_parent_pose(m, orientation.value.as_vec3())?;
|
||||
let factor = pose.1;
|
||||
let values_finite = std::iter::once(&scale.value)
|
||||
.chain(scale.keys.iter().map(|key| &key.value))
|
||||
.all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite()));
|
||||
let speeds_finite = scale.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * factor).is_finite());
|
||||
(values_finite && speeds_finite).then_some(pose)
|
||||
let (rotation, factor) = uniform_rotation(m)?;
|
||||
let correction = if two_node {
|
||||
// A mirrored parent flips the aim, which this correction does not model.
|
||||
if factor <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (comp.width as f64, comp.height as f64);
|
||||
let pos = ectx.v3(layer, tr, "position", [w / 2.0, h / 2.0, -1000.0]);
|
||||
let poi = ectx.v3(layer, tr, "poi", [w / 2.0, h / 2.0, 0.0]);
|
||||
aim_correction(rotation, [poi[0] - pos[0], poi[1] - pos[1], poi[2] - pos[2]])?
|
||||
} else {
|
||||
rotation
|
||||
};
|
||||
let orientation_finite = std::iter::once(&orientation.value)
|
||||
.chain(orientation.keys.iter().map(|key| &key.value))
|
||||
.all(|value| value.as_vec3().iter().all(|v| v.is_finite()));
|
||||
if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation_finite {
|
||||
return None;
|
||||
}
|
||||
if let Some(scale) = scale {
|
||||
let values_finite = std::iter::once(&scale.value)
|
||||
.chain(scale.keys.iter().map(|key| &key.value))
|
||||
.all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite()));
|
||||
let speeds_finite = scale.keys.iter().all(|key| key.in_ease.iter().chain(&key.out_ease).all(|ease| (ease.speed * factor).is_finite()));
|
||||
if !values_finite || !speeds_finite {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Pose3d { correction, factor })
|
||||
});
|
||||
fixes.push(ParentFix { layer: layer.id, m: effectcraft_geom::Mat3::IDENTITY, rotation: 0.0, scale: [1.0; 2], position_3d: Some(m), pose_3d });
|
||||
}
|
||||
@@ -309,6 +510,8 @@ fn check_parent_chain(comp: &Comp, ids: &[LayerId], parent: Option<LayerId>, max
|
||||
|
||||
pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
// Locked layers keep their parent (several at once: the others are parented).
|
||||
let ids = unlocked(s, cid, ids, "layer.setParent")?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let parent = match p.get("parent") {
|
||||
None | Some(Value::Null) => None,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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,
|
||||
layers_p, merge_p, resolve_layer, str_p, unlocked,
|
||||
};
|
||||
|
||||
pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -181,33 +181,122 @@ pub(super) fn blend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// `layer.setTrackMatte`: `layers` (or `layer`, else the first selected layer; locked ones left
|
||||
/// as they are) use `matte` as their track matte (none when null), in one undo step. `kind` sets the mode; `preserve` keeps
|
||||
/// each layer's own (Alpha for a layer without a matte), so changing the matte of several layers
|
||||
/// at once keeps their modes.
|
||||
pub(super) fn track_matte(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "layer.setTrackMatte")?;
|
||||
let (cid, ids) = match p.get("layers") {
|
||||
Some(_) => layers_p(s, p)?,
|
||||
None => layer_p(s, p, "layer.setTrackMatte").map(|(c, l)| (c, vec![l]))?,
|
||||
};
|
||||
// Locked layers keep their matte (several at once: the others get it).
|
||||
let ids = unlocked(s, cid, ids, "layer.setTrackMatte")?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let matte = match p.get("matte") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setTrackMatte", "no such matte layer"))?),
|
||||
};
|
||||
let kind = str_p(p, "kind")
|
||||
.map(|k| match k.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
|
||||
if ids.is_empty() {
|
||||
return Err(bad("layer.setTrackMatte", "no layers"));
|
||||
}
|
||||
if matte.is_some_and(|m| ids.contains(&m)) {
|
||||
return Err(bad("layer.setTrackMatte", "a layer can't be its own matte"));
|
||||
}
|
||||
// None: each layer keeps its own mode.
|
||||
let kind = match str_p(p, "kind").map(|k| k.to_ascii_lowercase().replace([' ', '_', '-'], "")) {
|
||||
Some(k) if k == "preserve" => None,
|
||||
Some(k) => Some(match k.as_str() {
|
||||
"alphainverted" | "alphainv" => MatteKind::AlphaInverted,
|
||||
"luma" => MatteKind::Luma,
|
||||
"lumainverted" | "lumainv" => MatteKind::LumaInverted,
|
||||
_ => MatteKind::Alpha,
|
||||
})
|
||||
.unwrap_or(MatteKind::Alpha);
|
||||
}),
|
||||
None => Some(MatteKind::Alpha),
|
||||
};
|
||||
s.edit("Track Matte", None, |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
if let Some(m) = matte {
|
||||
if m == lid {
|
||||
return Err(bad("layer.setTrackMatte", "a layer can't be its own matte"));
|
||||
}
|
||||
if let Some(ml) = comp.layer_mut(m) {
|
||||
ml.switches.video = false;
|
||||
}
|
||||
if let Some(ml) = matte.and_then(|m| comp.layer_mut(m)) {
|
||||
ml.switches.video = false;
|
||||
}
|
||||
for lid in &ids {
|
||||
let l = comp.layer_mut(*lid).ok_or(EngineError::NoComp)?;
|
||||
let kind = kind.unwrap_or_else(|| l.track_matte.map(|m| m.kind).unwrap_or_default());
|
||||
l.track_matte = matte.map(|layer| TrackMatte { layer, kind });
|
||||
}
|
||||
comp.layer_mut(lid).ok_or(EngineError::NoComp)?.track_matte = matte.map(|layer| TrackMatte { layer, kind });
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod track_matte_batch_tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn matte_of(s: &Session, id: u64) -> Option<(u64, MatteKind)> {
|
||||
s.active_comp().unwrap().layer(LayerId(id)).unwrap().track_matte.map(|m| (m.layer.0, m.kind))
|
||||
}
|
||||
|
||||
/// #1260: several layers take one matte layer at once, in one undo step; `kind: "preserve"`
|
||||
/// keeps each one's mode; refused calls change nothing; one `layer` still works.
|
||||
#[test]
|
||||
fn several_layers_take_one_matte_and_keep_their_modes() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name":"Mattes","width":16,"height":16,"duration":1})).unwrap();
|
||||
let [a, b, c, x, y] = ["A", "B", "C", "X", "Y"].map(|n| s.execute("layer.newSolid", json!({"name": n})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.execute("layer.setTrackMatte", json!({"layer": a, "matte": x, "kind": "luma"})).unwrap();
|
||||
s.execute("layer.setTrackMatte", json!({"layer": b, "matte": x})).unwrap();
|
||||
assert_eq!((matte_of(&s, a), matte_of(&s, b)), (Some((x, MatteKind::Luma)), Some((x, MatteKind::Alpha))), "one layer, as before");
|
||||
let (before, undo) = (s.project.clone(), s.history.undo.len());
|
||||
s.execute("layer.setTrackMatte", json!({"layers": [a, b, c], "matte": y, "kind": "preserve"})).unwrap();
|
||||
assert_eq!(
|
||||
[matte_of(&s, a), matte_of(&s, b), matte_of(&s, c)],
|
||||
[Some((y, MatteKind::Luma)), Some((y, MatteKind::Alpha)), Some((y, MatteKind::Alpha))],
|
||||
"each keeps its mode (Alpha without one)"
|
||||
);
|
||||
assert!(!s.active_comp().unwrap().layer(LayerId(y)).unwrap().switches.video, "the matte is hidden");
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.project.same_content(&before));
|
||||
// Refused: a layer as its own matte, an unknown layer, no layers. Nothing changes.
|
||||
for p in [
|
||||
json!({"layers": [a, y], "matte": y}),
|
||||
json!({"layers": [a, 9999], "matte": y}),
|
||||
json!({"layers": [], "matte": y}),
|
||||
json!({"layers": [a], "matte": 9999}),
|
||||
] {
|
||||
let (project, revision) = (s.project.clone(), s.revision);
|
||||
assert!(s.execute("layer.setTrackMatte", p.clone()).is_err(), "{p}");
|
||||
assert!(Arc::ptr_eq(&project, &s.project) && s.revision == revision, "{p} changed nothing");
|
||||
}
|
||||
s.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": null})).unwrap();
|
||||
assert_eq!((matte_of(&s, a), matte_of(&s, b)), (None, None), "both lose it");
|
||||
}
|
||||
|
||||
/// Locked layers keep their parent and matte when they are among several (the others change),
|
||||
/// and alone they are an error that changes nothing.
|
||||
#[test]
|
||||
fn locked_layers_keep_their_parent_and_matte() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name":"Locks","width":16,"height":16,"duration":1})).unwrap();
|
||||
let [a, b, p, m] = ["A", "B", "P", "M"].map(|n| s.execute("layer.newSolid", json!({"name": n})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap();
|
||||
let layer = |s: &Session, id: u64| s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone();
|
||||
s.execute("layer.setParent", json!({"layers": [a, b], "parent": p})).unwrap();
|
||||
s.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": m, "kind": "preserve"})).unwrap();
|
||||
s.execute("layer.trackMatte", json!({"layers": [a, b], "op": "luma"})).unwrap();
|
||||
assert_eq!((layer(&s, a).parent, matte_of(&s, a)), (Some(LayerId(p)), Some((m, MatteKind::Luma))));
|
||||
assert_eq!((layer(&s, b).parent, matte_of(&s, b)), (None, None), "the locked layer is left as it was");
|
||||
for (cmd, p) in [
|
||||
("layer.setParent", json!({"layers": [b], "parent": p})),
|
||||
("layer.setTrackMatte", json!({"layers": [b], "matte": m})),
|
||||
("layer.trackMatte", json!({"layers": [b], "op": "alpha"})),
|
||||
] {
|
||||
let (project, revision) = (s.project.clone(), s.revision);
|
||||
let err = s.execute(cmd, p).unwrap_err();
|
||||
assert!(err.to_string().contains("is locked"), "{cmd}: {err}");
|
||||
assert!(Arc::ptr_eq(&project, &s.project) && s.revision == revision, "{cmd} changed nothing");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,9 @@ pub(super) fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
shift(l, d);
|
||||
}
|
||||
"trimInToTime" => l.in_point = t.min(l.out_point - fd),
|
||||
"trimOutToTime" => l.out_point = t.max(l.in_point + fd),
|
||||
// Alt+]: the Out edge lands one frame after the playhead, so its frame stays the
|
||||
// layer's last visible one (as in After Effects), never before the In point.
|
||||
"trimOutToTime" => l.out_point = t.max(l.in_point) + fd,
|
||||
_ => {
|
||||
if let Some(d) = delta {
|
||||
shift(l, d);
|
||||
|
||||
@@ -74,14 +74,39 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let op = str_p(p, "op").unwrap_or("reset").to_string();
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let t = s.time();
|
||||
let sizes: Vec<(LayerId, (u32, u32))> =
|
||||
comp.layers.iter().filter(|l| ids.contains(&l.id)).map(|l| (l.id, effectcraft_render::source_size(&s.project, l))).collect();
|
||||
// A shape or text layer has no source item, so source_size is (0, 0): the fit ops size it by
|
||||
// its painted bounds at the current time instead, and anchor at the bounds' centre — content
|
||||
// bounds live in layer space, where a shape is centred on the origin and text sits above its
|
||||
// baseline, so (w/2, h/2) would push it off-centre (#2198). Reset and the other ops keep the
|
||||
// source size.
|
||||
let ectx = effectcraft_render::EvalCtx {
|
||||
expr: s.expr.as_deref(),
|
||||
footage: Some(s.footage.as_ref()),
|
||||
..effectcraft_render::EvalCtx::new(&s.project, cid, &comp, t)
|
||||
};
|
||||
let fitting = matches!(op.as_str(), "fit" | "fitWidth" | "fitHeight");
|
||||
let sizes: Vec<(LayerId, [f64; 2], [f64; 2])> = comp
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| ids.contains(&l.id))
|
||||
.map(|l| {
|
||||
let (w, h) = effectcraft_render::source_size(&s.project, l);
|
||||
if fitting && (w == 0 || h == 0) {
|
||||
match effectcraft_render::content_bounds(&ectx, l).filter(|b| b.iter().all(|v| v.is_finite()) && b[2] > b[0] && b[3] > b[1]) {
|
||||
Some(b) => (l.id, [b[2] - b[0], b[3] - b[1]], [(b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0]),
|
||||
None => (l.id, [0.0; 2], [0.0; 2]),
|
||||
}
|
||||
} else {
|
||||
(l.id, [w as f64, h as f64], [w as f64 / 2.0, h as f64 / 2.0])
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
s.edit("Transform", None, |proj, _| {
|
||||
for (lid, (w, h)) in &sizes {
|
||||
for (lid, size, anchor) in &sizes {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let (cw, ch) = (comp.width as f64, comp.height as f64);
|
||||
let (w, h) = (*w as f64, *h as f64);
|
||||
let (w, h) = (size[0], size[1]);
|
||||
let Some(tr) = l.transform_mut() else { continue };
|
||||
let set = |tr: &mut PropGroup, m: &str, v: KV| {
|
||||
if let Some(pr) = tr.get_mut(m) {
|
||||
@@ -105,11 +130,15 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"fitHeight" => (ch / h, ch / h),
|
||||
_ => (cw / w, ch / h),
|
||||
};
|
||||
// A tiny shape's bounds can overflow the scale; refuse rather than write infinity.
|
||||
if !(sx * 100.0).is_finite() || !(sy * 100.0).is_finite() {
|
||||
return Err(EngineError::Other(format!("layer {} is too small to fit to the composition", lid.0)));
|
||||
}
|
||||
let z = |m: &str, d: f64| tr.get(m).map(|p| p.value_at(lt).as_vec3()[2]).unwrap_or(d);
|
||||
let (sz, az) = (z("scale", 100.0), z("anchor", 0.0));
|
||||
set(tr, "scale", KV::Vec3([sx * 100.0, sy * 100.0, sz]));
|
||||
update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]);
|
||||
set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, az]));
|
||||
set(tr, "anchor", KV::Vec3([anchor[0], anchor[1], az]));
|
||||
}
|
||||
"flipH" | "flipV" => {
|
||||
let cur = tr.get("scale").map(|p| p.value_at(lt).as_vec3()).unwrap_or([100.0; 3]);
|
||||
|
||||
@@ -609,6 +609,7 @@ fn delete_all_markers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn track_matte_op(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let ids = super::unlocked(s, cid, ids, "layer.trackMatte")?;
|
||||
let op = str_p(p, "op").ok_or_else(|| bad("layer.trackMatte", "missing `op`"))?.to_string();
|
||||
let kind = match op.as_str() {
|
||||
"alpha" => Some(MatteKind::Alpha),
|
||||
|
||||
@@ -185,7 +185,16 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result<Value> {
|
||||
return Err(bad("layer.freezeFrame", "freezing needs a footage or composition layer"));
|
||||
}
|
||||
// What each layer shows now (honouring an existing remap) — evaluated before the edit.
|
||||
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
|
||||
let ectx = effectcraft_render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: cti,
|
||||
expr: s.expr.as_deref(),
|
||||
footage: Some(s.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let now: Vec<(effectcraft_project::LayerId, f64)> = comp
|
||||
.layers
|
||||
.iter()
|
||||
|
||||
@@ -102,11 +102,39 @@ fn quote(s: &str) -> String {
|
||||
|
||||
/// The AE expression reference to property `uid` of `target` as seen from a property of `from`.
|
||||
pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, compact: bool) -> Option<String> {
|
||||
reference_with(comp, from, target, uid, compact, false)
|
||||
}
|
||||
|
||||
/// [`reference`], with the properties inside an effect selected by their index (`by_index`, an
|
||||
/// Alt pick whip in After Effects): `effect("Color Control")(1)` rather than `("Color")`. The
|
||||
/// effect keeps its name. An index is the property's place among its group's children, as an
|
||||
/// expression's `(1)` reads it: it follows a renamed parameter, not one moved to another place.
|
||||
pub(crate) fn reference_with(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, compact: bool, by_index: bool) -> Option<String> {
|
||||
let chain = target.props.node_chain(uid)?;
|
||||
// Each node's one-based index among its group's children, and whether it lies inside an
|
||||
// effect (below the effect itself): those are selected by index with `by_index`.
|
||||
let mut parent = &target.props;
|
||||
let mut depth_in_effects: Option<usize> = None;
|
||||
let mut by_number: Vec<Option<usize>> = vec![];
|
||||
for n in &chain {
|
||||
let at = parent.children.iter().position(|c| c.uid() == n.uid()).map(|i| i + 1);
|
||||
by_number.push(at.filter(|_| by_index && depth_in_effects.is_some_and(|d| d >= 1)));
|
||||
depth_in_effects = match depth_in_effects {
|
||||
Some(d) => Some(d + 1),
|
||||
None => (n.match_id() == "effects").then_some(0),
|
||||
};
|
||||
if let Node::Group(g) = n {
|
||||
parent = g;
|
||||
}
|
||||
}
|
||||
let selector = |i: usize, label: &str| match by_number.get(i).copied().flatten() {
|
||||
Some(at) => format!("({at})"),
|
||||
None => format!("({})", quote(label)),
|
||||
};
|
||||
let mut parts: Vec<String> = vec![];
|
||||
let mut pending: Option<&str> = None;
|
||||
let mut in_effect = false;
|
||||
for n in chain {
|
||||
for (i, n) in chain.iter().copied().enumerate() {
|
||||
if let Some(f) = pending.take() {
|
||||
parts.push(format!("{f}({})", quote(n.name())));
|
||||
in_effect |= f == "effect";
|
||||
@@ -119,7 +147,7 @@ pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, com
|
||||
(Node::Group(_), "transform") if !in_effect => parts.push("transform".into()),
|
||||
(Node::Group(_), "text") if !in_effect => parts.push("text".into()),
|
||||
(Node::Prop(_), "sourceText") => parts.push("sourceText".into()),
|
||||
_ if in_effect => parts.push(format!("({})", quote(n.name()))),
|
||||
_ if in_effect => parts.push(selector(i, n.name())),
|
||||
_ => parts.push(camel(n.name())),
|
||||
}
|
||||
}
|
||||
@@ -127,10 +155,9 @@ pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, com
|
||||
let _ = comp;
|
||||
if !compact {
|
||||
// Match names: language-independent, like After Effects with Compact English off.
|
||||
let chain = target.props.node_chain(uid)?;
|
||||
let mut out = if from.id == target.id { "thisLayer".to_string() } else { format!("thisComp.layer({})", quote(&target.name)) };
|
||||
for n in chain {
|
||||
out.push_str(&format!("({})", quote(n.match_id())));
|
||||
for (i, n) in chain.iter().copied().enumerate() {
|
||||
out.push_str(&selector(i, n.match_id()));
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
@@ -161,7 +188,9 @@ fn pick_whip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let from = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let target = comp.layer(tl).ok_or(EngineError::NoComp)?;
|
||||
let r = reference(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?;
|
||||
let by_index = b_p(p, "byIndex").unwrap_or(false);
|
||||
let r = reference_with(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact, by_index)
|
||||
.ok_or_else(|| bad("prop.pickWhip", "no such target property"))?;
|
||||
let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?);
|
||||
let (dm, dt) = (dims(from, me), dims(target, them));
|
||||
// Dropped on one of the target's values: that dimension (`position[0]`), as in After Effects.
|
||||
@@ -206,7 +235,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Pick Whip (Link Property)",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, target: {layer, path|prop, dimension?}, expression?, range?: [start, end]} → sets an AE reference expression (`dimension`: one of the target's values, `position[0]`; with `expression`: inserts it there, replacing the characters in `range`, default the end)",
|
||||
"{layer?, path|prop, target: {layer, path|prop, dimension?}, expression?, range?: [start, end], byIndex?: bool} → sets an AE reference expression (`dimension`: one of the target's values, `position[0]`; with `expression`: inserts it there, replacing the characters in `range`, default the end; `byIndex`: an effect's properties by their index, `effect(\"Color Control\")(1)`, as an Alt pick whip does)",
|
||||
has_layers,
|
||||
pick_whip
|
||||
),
|
||||
|
||||
@@ -296,8 +296,9 @@ fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers onto the
|
||||
/// layer (layer time is the nested comp's time), skipping ones already there.
|
||||
/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers, or the
|
||||
/// markers a footage file carries (a WAV's cue points), onto the layer (layer time is the source's
|
||||
/// time), skipping ones already there.
|
||||
fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let src: Vec<(LayerId, Vec<Marker>)> = {
|
||||
@@ -307,13 +308,17 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let l = comp.layer(*id)?;
|
||||
match &l.source {
|
||||
LayerSource::Comp { item } => Some((*id, s.project.comp(*item)?.markers.clone())),
|
||||
LayerSource::Footage { item } => match &s.project.item(*item)?.kind {
|
||||
effectcraft_project::ItemKind::Footage(f) => Some((*id, f.markers.clone())),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if src.is_empty() {
|
||||
return Err(bad("layer.updateMarkersFromSource", "select a precomposition layer"));
|
||||
return Err(bad("layer.updateMarkersFromSource", "select a precomposition or footage layer"));
|
||||
}
|
||||
let n = s.edit("Update Markers From Source", None, |proj, _| {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
@@ -323,8 +328,11 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if l.markers_locked {
|
||||
continue;
|
||||
}
|
||||
// One set of the times already on the layer: a file with thousands of markers must not
|
||||
// scan the layer's list for each of them.
|
||||
let mut times: std::collections::HashSet<_> = l.markers.iter().map(|x| x.time).collect();
|
||||
for m in ms {
|
||||
if !l.markers.iter().any(|x| x.time == m.time) {
|
||||
if times.insert(m.time) {
|
||||
l.markers.push(m.clone());
|
||||
n += 1;
|
||||
}
|
||||
@@ -339,10 +347,10 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn has_precomp_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
has_layers(s)?;
|
||||
let c = s.active_comp().ok_or("no composition")?;
|
||||
if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. })) {
|
||||
if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. } | LayerSource::Footage { .. })) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("select a precomposition layer".into())
|
||||
Err("select a precomposition or footage layer".into())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -289,6 +289,7 @@ fn extract_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
clip.push(crate::KeyClip { layer: lid, path, keys });
|
||||
}
|
||||
s.state.key_clipboard = clip;
|
||||
s.state.keyframe_text = None;
|
||||
s.state.clip_is_keys = true;
|
||||
// The same data as text (tab-separated, one row per frame) for other applications.
|
||||
let mut text = String::from("EffectCraft Face Measurements\n\nFrame\tTime");
|
||||
|
||||
@@ -78,7 +78,16 @@ fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result<P
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let pr = layer.props.find(uid).ok_or_else(|| bad(cmd, "no such property"))?;
|
||||
let t = s.time();
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
|
||||
let ctx = effectcraft_render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: t,
|
||||
expr: s.expr.as_deref(),
|
||||
footage: Some(s.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let KV::Path(path) = ctx.value(layer, pr) else { return Err(bad(cmd, "that property is not a path")) };
|
||||
if path.vertices.is_empty() {
|
||||
return Err(bad(cmd, "the path has no vertices"));
|
||||
@@ -147,8 +156,16 @@ fn pin_targets(s: &Session, p: &Value, cmd: &str) -> Result<Option<(ItemId, Vec<
|
||||
};
|
||||
let cid = pins.first().map(|x| x.0).ok_or_else(|| bad(cmd, "no puppet pins"))?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let ctx =
|
||||
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
|
||||
let ctx = effectcraft_render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: s.time(),
|
||||
expr: s.expr.as_deref(),
|
||||
footage: Some(s.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let mut out = vec![];
|
||||
for (_, lid, uid) in pins {
|
||||
let Some(layer) = comp.layer(lid) else { continue };
|
||||
|
||||
@@ -8,7 +8,7 @@ use effectcraft_project::{FeatherFalloff, GroupKind, ItemId, LayerId, LayerSourc
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::layer_menu::target_masks;
|
||||
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_layers, layer_mut, layer_p, str_p};
|
||||
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_layers, layer_mut, layer_p, merge_p, str_p};
|
||||
use crate::{EngineError, Result, Session, VertexRef, cmd};
|
||||
|
||||
// ---------------------------------------------------------------- RotoBezier
|
||||
@@ -308,7 +308,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let y = v[1] * scale[1];
|
||||
[x * c - y * sn, x * sn + y * c]
|
||||
};
|
||||
let n = s.edit("Free Transform Points", None, |proj, _| {
|
||||
let n = s.edit("Free Transform Points", merge_p(p), |proj, _| {
|
||||
let mut n = 0;
|
||||
for (lid, uid) in &targets {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
@@ -542,7 +542,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Free Transform Points",
|
||||
["Layer", "Mask and Shape Path"],
|
||||
None,
|
||||
"{layer?, mask?, scale?: % | [x%, y%], rotation?: deg, offset?: [dx, dy], anchor?: [x, y]}",
|
||||
"{layer?, mask?, scale?: % | [x%, y%], rotation?: deg, offset?: [dx, dy], anchor?: [x, y], merge?: string}",
|
||||
has_layers,
|
||||
free_transform
|
||||
),
|
||||
|
||||
@@ -1285,8 +1285,16 @@ fn convert_one_expr(s: &mut Session, cid: ItemId, lid: LayerId, uid: Uid) -> Res
|
||||
let fd = comp.frame_duration();
|
||||
let mut t = layer.in_point;
|
||||
while t < layer.out_point {
|
||||
let ctx =
|
||||
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
|
||||
let ctx = effectcraft_render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp: &comp,
|
||||
time: t,
|
||||
expr: s.expr.as_deref(),
|
||||
footage: Some(s.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr)));
|
||||
t += fd;
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Proxy...",
|
||||
["File", "Interpret Footage"],
|
||||
None,
|
||||
"{items?|item?, alpha?, matteColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, logConversion? (Cineon Settings of DPX and Cineon proxies, as file.interpretFootage), cineonUnits?, convertedBlack?, convertedWhite?, pointUnits?, tenBitBlack?, tenBitWhite?, currentGamma?, highlightRolloff?}",
|
||||
"{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage), logConversion?, cineonUnits?, convertedBlack?, convertedWhite?, pointUnits?, tenBitBlack?, tenBitWhite?, currentGamma?, highlightRolloff? (Cineon Settings of DPX and Cineon proxies)}",
|
||||
has_proxy,
|
||||
interpret_proxy
|
||||
),
|
||||
|
||||
@@ -38,7 +38,8 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick)
|
||||
let fx = layer.effects()?;
|
||||
let index = fx.groups().position(|g| g.uid == fx_uid)?;
|
||||
let g = fx.groups().nth(index)?;
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
let ctx =
|
||||
EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] };
|
||||
let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default());
|
||||
r.expr = s.expr.as_deref();
|
||||
r.cache = Some(&s.layer_cache);
|
||||
|
||||
@@ -203,7 +203,7 @@ 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";
|
||||
const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff";
|
||||
|
||||
/// `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<()> {
|
||||
@@ -619,7 +619,7 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (w, h) = item.settings.output_size(comp);
|
||||
let mut crop_only = om.clone();
|
||||
crop_only.resize.enabled = false;
|
||||
let (cw, ch) = crop_only.frame_size(w, h, item.settings.resolution.clamp(0.01, 4.0));
|
||||
let (cw, ch) = crop_only.frame_size(w, h, item.settings.effective_scale(comp));
|
||||
om.resize.width = ((om.resize.height as f64 * cw as f64 / ch as f64).round() as u32).clamp(1, 30_000);
|
||||
}
|
||||
let post = match str_p(p, "postRenderAction") {
|
||||
@@ -1017,7 +1017,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add to Render Queue",
|
||||
["Composition"],
|
||||
["Cmd+M", "Cmd+Shift+/"],
|
||||
"{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
|
||||
"{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
|
||||
can_add,
|
||||
add
|
||||
),
|
||||
@@ -1037,7 +1037,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Output Module Settings...",
|
||||
[],
|
||||
None,
|
||||
"{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
|
||||
"{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
|
||||
has_items,
|
||||
set_output_module
|
||||
),
|
||||
|
||||
@@ -48,7 +48,16 @@ impl Env {
|
||||
Env { time: s.time_of(cid), expr: s.expr.clone(), footage: s.footage.clone() }
|
||||
}
|
||||
fn ctx<'a>(&'a self, proj: &'a Project, cid: ItemId, comp: &'a Comp) -> EvalCtx<'a> {
|
||||
EvalCtx { project: proj, comp_id: cid, comp, time: self.time, expr: self.expr.as_deref(), footage: Some(self.footage.as_ref()) }
|
||||
EvalCtx {
|
||||
project: proj,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: self.time,
|
||||
expr: self.expr.as_deref(),
|
||||
footage: Some(self.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! settings on the desktop, so dropping `.jsx` files in those folders works too. File ▸ Scripts
|
||||
//! lists them with the bundled sample scripts and extensions ([`BUNDLED`]) and runs them by name; ScriptUI
|
||||
//! panels appear at the bottom of the Window menu and open as dockable panels
|
||||
//! (`window.scriptPanel`), running with `this` = the panel as in After Effects.
|
||||
//! (`window.scriptPanel`), running with `this` = the panel as in After Effects. A subfolder of
|
||||
//! either folder (one level) is a submenu named after it; its scripts are named `Folder/File.jsx`.
|
||||
//! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control
|
||||
//! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]).
|
||||
//! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by
|
||||
@@ -22,6 +23,11 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui};
|
||||
/// The settings-store folders of installed scripts.
|
||||
pub const SCRIPTS_DIR: &str = "Scripts";
|
||||
pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels";
|
||||
/// [`PANELS_DIR`]'s name in [`SCRIPTS_DIR`]: it holds the panels, so it is not a folder of scripts.
|
||||
const PANELS_FOLDER: &str = "ScriptUI Panels";
|
||||
/// Subfolders of Scripts that hold scripts the app runs itself (panels; launch and quit, as in
|
||||
/// After Effects): never listed as File ▸ Scripts submenus.
|
||||
const RESERVED_FOLDERS: [&str; 3] = [PANELS_FOLDER, "Startup", "Shutdown"];
|
||||
/// The ScriptUI panels open when EffectCraft last quit (settings store), reopened at launch.
|
||||
pub const OPEN_PANELS_FILE: &str = "scriptui_panels.json";
|
||||
/// Scripts' `app.settings` in the settings store: `{section: {key: value}}`, as After Effects
|
||||
@@ -111,7 +117,7 @@ pub const BUNDLED: &[(&str, bool, &str, &str)] = &[
|
||||
/// A script File ▸ Scripts (or the Window menu, for panels) offers.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct ScriptEntry {
|
||||
/// File name (`Rename Layers.jsx`): what the menus show and commands take.
|
||||
/// File name (`Rename Layers.jsx`), or `Folder/File.jsx` in a subfolder: what commands take.
|
||||
pub name: String,
|
||||
/// In the ScriptUI Panels folder: opens as a dockable panel.
|
||||
pub panel: bool,
|
||||
@@ -119,16 +125,23 @@ pub struct ScriptEntry {
|
||||
pub source: &'static str,
|
||||
}
|
||||
|
||||
/// Whether `name` ends in `.jsx` or `.js` (any letter case).
|
||||
fn is_script(name: &str) -> bool {
|
||||
let l = name.to_ascii_lowercase();
|
||||
l.ends_with(".jsx") || l.ends_with(".js")
|
||||
}
|
||||
|
||||
/// Whether `name` can name an installed script: a bare file name, never a path (names are joined
|
||||
/// onto the Scripts folders of the settings store, which takes them as relative paths), and on
|
||||
/// Windows none of the names [`windows_unsafe_name`] lists.
|
||||
/// Whether `name` can name an installed script: a file name, or `Folder/File` for one in a
|
||||
/// subfolder of a Scripts folder (one level, and not one of [`RESERVED_FOLDERS`]: `ScriptUI
|
||||
/// Panels/…`, `Startup/…`, `Shutdown/…`). Each part is a bare name, never a path (names are joined onto the Scripts folders of
|
||||
/// the settings store, which takes them as relative paths), and on Windows none of the names
|
||||
/// [`windows_unsafe_name`] lists.
|
||||
fn is_script_name(name: &str) -> bool {
|
||||
is_bare_name(name) && !(cfg!(windows) && windows_unsafe_name(name))
|
||||
let part = |n: &str| is_bare_name(n) && !(cfg!(windows) && windows_unsafe_name(n));
|
||||
match name.split_once('/') {
|
||||
Some((folder, file)) => part(folder) && part(file) && !RESERVED_FOLDERS.iter().any(|r| folder.eq_ignore_ascii_case(r)),
|
||||
None => part(name),
|
||||
}
|
||||
}
|
||||
|
||||
/// One path component on every platform: not empty, `.` or `..`, and without `/`, `\` or NUL.
|
||||
@@ -155,8 +168,13 @@ pub fn scripts(s: &Session) -> Vec<ScriptEntry> {
|
||||
let mut v: Vec<ScriptEntry> = vec![];
|
||||
if let Some(cfg) = &s.config {
|
||||
for (dir, panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] {
|
||||
for n in cfg.list(dir).into_iter().filter(|n| is_script(n) && is_script_name(n)) {
|
||||
v.push(ScriptEntry { name: n, panel, source: "installed" });
|
||||
// The folder itself, then each subfolder, whose scripts are named `Folder/File.jsx`.
|
||||
let subfolders = cfg.list_dirs(dir).into_iter().map(|f| (format!("{dir}/{f}"), format!("{f}/")));
|
||||
for (folder, prefix) in std::iter::once((dir.to_string(), String::new())).chain(subfolders) {
|
||||
let names = cfg.list(&folder).into_iter().map(|n| format!("{prefix}{n}"));
|
||||
for n in names.filter(|n| is_script(n) && is_script_name(n)) {
|
||||
v.push(ScriptEntry { name: n, panel, source: "installed" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,12 +234,19 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!(scripts(s)))
|
||||
}
|
||||
|
||||
/// `file.uninstallScript {name}`: delete an installed script or panel at the top of the Scripts
|
||||
/// folders.
|
||||
fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "file.uninstallScript";
|
||||
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name`"))?;
|
||||
if !is_script(name) || !is_script_name(name) {
|
||||
return Err(bad(c, format!("`{name}` is not a script file name")));
|
||||
}
|
||||
// A subfolder can be a link to files outside the settings folder (an extension's own
|
||||
// folder), so only what Install puts at the top is removed, never a file through a folder.
|
||||
if name.contains('/') {
|
||||
return Err(bad(c, format!("`{name}` is in a subfolder: delete it there yourself (only scripts at the top of the Scripts folders are uninstalled)")));
|
||||
}
|
||||
let cfg = s.config.clone().ok_or_else(|| bad(c, "no settings folder"))?;
|
||||
let mut removed = false;
|
||||
for dir in [SCRIPTS_DIR, PANELS_DIR] {
|
||||
@@ -316,13 +341,15 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "window.scriptPanel";
|
||||
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name` (a script in the ScriptUI Panels folder)"))?.to_string();
|
||||
let title = name.trim_end_matches(".jsx").trim_end_matches(".js").to_string();
|
||||
let file = name.rsplit_once('/').map_or(name.as_str(), |(_, f)| f);
|
||||
let title = file.trim_end_matches(".jsx").trim_end_matches(".js").to_string();
|
||||
if let Some(w) = s.script_ui.windows.iter().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name) {
|
||||
let id = w.id;
|
||||
s.events.push(Event::Frontend { command: c.into(), params: json!({"window": id}) });
|
||||
return Ok(json!({"window": id, "open": true}));
|
||||
}
|
||||
let (_, code) = script_code(s, &name, Some(true)).or_else(|| script_code(s, &name, None)).ok_or_else(|| bad(c, format!("no ScriptUI panel `{name}`")))?;
|
||||
// Only panels: a script opened as one would be remembered, then forgotten at the next launch.
|
||||
let (_, code) = script_code(s, &name, Some(true)).ok_or_else(|| bad(c, format!("no ScriptUI panel `{name}`")))?;
|
||||
let out = super::file_more::run_js_named(s, &panel_wrapper(&title, &code), &name)?;
|
||||
let id = s.script_ui.windows.iter().rev().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name).map(|w| w.id);
|
||||
if let Some(id) = id {
|
||||
@@ -338,6 +365,7 @@ fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"window": id, "result": out}))
|
||||
}
|
||||
|
||||
/// The commands of File ▸ Scripts, ScriptUI panels and windows, and script settings.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -358,14 +386,22 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
|s, p| install(s, p, true)
|
||||
),
|
||||
cmd!("file.uninstallScript", "Uninstall Script", [], None, "{name}", always, uninstall),
|
||||
crate::query!("file.scripts.list", "List Scripts", "{} → [{name, panel, source: installed|sample|extension}]", list),
|
||||
cmd!(
|
||||
"file.uninstallScript",
|
||||
"Uninstall Script",
|
||||
[],
|
||||
None,
|
||||
"{name (a script at the top of a Scripts folder, not `Folder/File.jsx`)}",
|
||||
always,
|
||||
uninstall
|
||||
),
|
||||
crate::query!("file.scripts.list", "List Scripts", "{} → [{name (`Folder/File.jsx` in a subfolder), panel, source: installed|sample|extension}]", list),
|
||||
cmd!(
|
||||
"window.scriptPanel",
|
||||
"ScriptUI Panel",
|
||||
[],
|
||||
None,
|
||||
"{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`)} → opens it as a dockable panel",
|
||||
"{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`, or `Folder/Panel.jsx` in a subfolder)} → opens it as a dockable panel",
|
||||
always,
|
||||
open_panel
|
||||
),
|
||||
@@ -504,13 +540,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundled and ordinary script file names are accepted; paths are not.
|
||||
#[test]
|
||||
fn script_names_are_bare_file_names() {
|
||||
let more = ["..jsx", "a..b.js", "Äpfel ✓.jsx", "console.jsx", "Concert.jsx", "NULL.jsx", "COM10.jsx", "LPT.jsx"];
|
||||
for name in BUNDLED.iter().map(|b| b.0).chain(more) {
|
||||
assert!(is_script(name) && is_script_name(name), "{name}");
|
||||
}
|
||||
for name in PATHS {
|
||||
// `a/b.jsx` is a script in a folder (see below).
|
||||
for name in PATHS.into_iter().filter(|n| *n != "a/b.jsx") {
|
||||
assert!(!is_script_name(name), "{name:?}");
|
||||
}
|
||||
for name in WINDOWS_ONLY {
|
||||
@@ -528,6 +566,141 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Folder/File.jsx` is accepted; deeper, upward, absolute and `\` names are not.
|
||||
#[test]
|
||||
fn script_names_may_have_one_folder() {
|
||||
for name in ["Motion Kit/Animate.jsx", "a/b.jsx", "Äpfel/✓.js", "..jsx/x.jsx"] {
|
||||
assert!(is_script_name(name), "{name:?}");
|
||||
}
|
||||
// Deeper, up, absolute, an empty part, a `\` path, and the panels' own folder in `Scripts`.
|
||||
let refused = ["a/b/c.jsx", "../x.jsx", "a/../x.jsx", "./x.jsx", "a/..", "/x.jsx", "a//x.jsx", "a/", "a\\b.jsx", "a/b\\c.jsx", "a\0/x.jsx"];
|
||||
for name in refused.into_iter().chain(["ScriptUI Panels/x.jsx", "scriptui panels/x.jsx"]) {
|
||||
assert!(!is_script_name(name), "{name:?}");
|
||||
}
|
||||
for name in ["In:Out/x.jsx", "con/x.jsx", "x/COM1.jsx", "x./y.jsx"] {
|
||||
assert_eq!(is_script_name(name), !cfg!(windows), "{name:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `code` to `rel` in the settings folder of [`session_in_tmp`]'s `root`.
|
||||
fn put(root: &std::path::Path, rel: &str, code: &str) {
|
||||
let p = root.join("settings").join(rel);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(p, code).unwrap();
|
||||
}
|
||||
|
||||
/// Every script command takes `Folder/File.jsx` names and refuses names outside one folder level.
|
||||
#[test]
|
||||
fn scripts_in_a_subfolder_are_listed_run_and_opened_as_folder_and_file() {
|
||||
let (mut s, root) = session_in_tmp("folders");
|
||||
put(&root, "Scripts/Tools/Hello.jsx", "hello");
|
||||
put(&root, "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx", "animate");
|
||||
put(&root, "Scripts/ScriptUI Panels/Top.jsx", "top");
|
||||
// Two folders deep: neither listed nor run.
|
||||
put(&root, "Scripts/a/b/c.jsx", "too deep");
|
||||
put(&root, "Scripts/ScriptUI Panels/Motion Kit/sub/Deep.jsx", "too deep");
|
||||
let list = s.execute("file.scripts.list", json!({})).unwrap();
|
||||
let installed: Vec<(&str, bool)> =
|
||||
list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").map(|e| (e["name"].as_str().unwrap(), e["panel"] == true)).collect();
|
||||
assert_eq!(installed, [("Tools/Hello.jsx", false), ("Motion Kit/Animate.jsx", true), ("Top.jsx", true)]);
|
||||
assert_eq!(s.execute("file.runScript", json!({"name": "Tools/Hello.jsx"})).unwrap()["ran"], "hello");
|
||||
// A panel in a folder is titled after its file.
|
||||
let r = s.execute("window.scriptPanel", json!({"name": "Motion Kit/Animate.jsx"})).unwrap();
|
||||
let ran = r["result"]["ran"].as_str().unwrap_or_default();
|
||||
assert!(ran.contains("animate") && ran.contains(r#"__uiDockPanel("Animate")"#), "{r}");
|
||||
assert_eq!(script_code(&s, "Motion Kit/Animate.jsx", Some(true)), Some((true, "animate".to_string())));
|
||||
// A script that is not a panel doesn't open as one.
|
||||
for name in ["Tools/Hello.jsx", "Rename Layers.jsx"] {
|
||||
assert!(bad_params(&s.execute("window.scriptPanel", json!({"name": name}))), "{name}");
|
||||
}
|
||||
for name in [
|
||||
"../x.jsx",
|
||||
"a/b/c.jsx",
|
||||
"a\\b.jsx",
|
||||
"Motion Kit/sub/Deep.jsx",
|
||||
"ScriptUI Panels/Top.jsx",
|
||||
"Startup/Run.jsx",
|
||||
"shutdown/Run.jsx",
|
||||
"Tools/../../../outside.jsx",
|
||||
] {
|
||||
assert_eq!(script_code(&s, name, None), None, "{name:?}");
|
||||
for c in ["file.runScript", "window.scriptPanel", "file.uninstallScript"] {
|
||||
let r = s.execute(c, json!({"name": name}));
|
||||
assert!(bad_params(&r), "{c} `{name}`: {r:?}");
|
||||
}
|
||||
}
|
||||
assert!(["Scripts/a/b/c.jsx", "Scripts/ScriptUI Panels/Top.jsx"].iter().all(|p| root.join("settings").join(p).is_file()));
|
||||
assert!(root.join("outside.jsx").is_file());
|
||||
// Uninstall removes only scripts at the top; one in a folder is deleted there by hand.
|
||||
for name in ["Tools/Hello.jsx", "Motion Kit/Animate.jsx"] {
|
||||
assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": name}))), "{name}");
|
||||
}
|
||||
assert!(["Scripts/Tools/Hello.jsx", "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx"].iter().all(|p| root.join("settings").join(p).is_file()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A subfolder can be a link to an extension's own folder, outside the settings folder: its
|
||||
/// scripts are listed and run from there, but uninstall never deletes through it.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn uninstall_never_deletes_through_a_linked_subfolder() {
|
||||
let (mut s, root) = session_in_tmp("linked");
|
||||
std::fs::create_dir_all(root.join("extension")).unwrap();
|
||||
std::fs::write(root.join("extension").join("Animate.jsx"), "animate").unwrap();
|
||||
std::os::unix::fs::symlink(root.join("extension"), root.join("settings").join(PANELS_DIR).join("Motion Kit")).unwrap();
|
||||
let list = s.execute("file.scripts.list", json!({})).unwrap();
|
||||
assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Motion Kit/Animate.jsx" && e["panel"] == true), "{list}");
|
||||
assert_eq!(script_code(&s, "Motion Kit/Animate.jsx", Some(true)), Some((true, "animate".to_string())));
|
||||
let r = s.execute("file.uninstallScript", json!({"name": "Motion Kit/Animate.jsx"}));
|
||||
assert!(bad_params(&r), "{r:?}");
|
||||
assert!(root.join("extension").join("Animate.jsx").is_file(), "the extension's own file was deleted");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Window ▸ Motion Kit ▸ Animate.jsx: a subfolder is a submenu named after it, where the folder
|
||||
/// sorts; the entries outside folders stay as they were.
|
||||
#[test]
|
||||
fn a_subfolder_is_a_submenu() {
|
||||
use crate::menus::{MenuNode, dynamic, dynamic_nodes};
|
||||
let (s, root) = session_in_tmp("submenus");
|
||||
put(&root, "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx", "");
|
||||
put(&root, "Scripts/ScriptUI Panels/Motion Kit/Rig.jsx", "");
|
||||
put(&root, "Scripts/ScriptUI Panels/Top.jsx", "");
|
||||
put(&root, "Scripts/Tools/Hello.jsx", "");
|
||||
let cx = Default::default();
|
||||
let item =
|
||||
|label: &str, command: &str, name: &str| json!({"type": "item", "label": label, "command": command, "params": {"name": name}, "shortcut": null});
|
||||
let menus = [
|
||||
(
|
||||
"scriptPanels",
|
||||
json!({"type": "submenu", "label": "Motion Kit", "children": [
|
||||
item("Animate.jsx", "window.scriptPanel", "Motion Kit/Animate.jsx"),
|
||||
item("Rig.jsx", "window.scriptPanel", "Motion Kit/Rig.jsx"),
|
||||
]}),
|
||||
),
|
||||
("scripts", json!({"type": "submenu", "label": "Tools", "children": [item("Hello.jsx", "file.runScript", "Tools/Hello.jsx")]})),
|
||||
];
|
||||
for (menu, folder) in menus {
|
||||
let (nodes, _) = dynamic_nodes(&s, menu, &cx);
|
||||
let (entries, _) = dynamic(&s, menu, &cx);
|
||||
let items: Vec<_> = nodes.iter().filter_map(|n| if let MenuNode::Item(e) = n { Some(e) } else { None }).collect();
|
||||
assert_eq!(items, entries.iter().filter(|e| !e.label.contains('/')).collect::<Vec<_>>(), "{menu}");
|
||||
let folders: Vec<Value> = nodes.iter().filter(|n| matches!(n, MenuNode::Submenu { .. })).map(|n| serde_json::to_value(n).unwrap()).collect();
|
||||
assert_eq!(folders, [folder], "{menu}");
|
||||
}
|
||||
let (panels, _) = dynamic_nodes(&s, "scriptPanels", &cx);
|
||||
let labels: Vec<&str> = panels
|
||||
.iter()
|
||||
.filter_map(|n| match n {
|
||||
MenuNode::Item(e) => Some(e.label.as_str()),
|
||||
MenuNode::Submenu { label, .. } => Some(label),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(labels, ["Ease Presets.jsx", "Layer Tools.jsx", "Motion Kit", "Top.jsx"]);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_takes_a_script_name_not_a_path() {
|
||||
let (mut s, root) = session_in_tmp("uninstall");
|
||||
|
||||
@@ -180,16 +180,17 @@ fn rgba(c: [f32; 3]) -> [f64; 4] {
|
||||
[c[0] as f64, c[1] as f64, c[2] as f64, 1.0]
|
||||
}
|
||||
|
||||
/// The Rounded Rectangle tool's corner roundness for `size`.
|
||||
fn roundness(size: [f64; 2]) -> f64 {
|
||||
size[0].min(size[1]) * 0.15
|
||||
/// The Rounded Rectangle tool's corner roundness: a fixed default, as in After Effects
|
||||
/// (it does not grow with the drawn size).
|
||||
fn roundness() -> f64 {
|
||||
20.0
|
||||
}
|
||||
|
||||
/// The Polygon and Star tools' Polystar for `size`: (star, points, outer radius, inner radius).
|
||||
fn polystar_of(kind: &str, size: [f64; 2]) -> Option<(bool, f64, f64, f64)> {
|
||||
match kind {
|
||||
"star" => Some((true, 5.0, size[0] / 2.0, size[0] / 4.0)),
|
||||
"polygon" => Some((false, 6.0, size[0] / 2.0, 0.0)),
|
||||
"polygon" => Some((false, 5.0, size[0] / 2.0, 0.0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -199,7 +200,7 @@ fn polystar_of(kind: &str, size: [f64; 2]) -> Option<(bool, f64, f64, f64)> {
|
||||
pub(crate) fn shape_group(ids: &mut Ids, kind: &str, size: [f64; 2], paint: &ShapeTool) -> Option<PropGroup> {
|
||||
let (path, gname) = match kind {
|
||||
"rect" | "rectangle" => (build::shape_rect(ids, size, [0.0, 0.0], 0.0), "Rectangle 1"),
|
||||
"rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], roundness(size)), "Rectangle 1"),
|
||||
"rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], roundness()), "Rectangle 1"),
|
||||
"ellipse" => (build::shape_ellipse(ids, size, [0.0, 0.0]), "Ellipse 1"),
|
||||
k => {
|
||||
let (star, points, outer, inner) = polystar_of(k, size)?;
|
||||
@@ -218,7 +219,7 @@ pub fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option<ShapePa
|
||||
let bez = match kind {
|
||||
"rect" | "rectangle" => return Some(ShapePath::rect(centre, size[0], size[1])),
|
||||
"ellipse" => return Some(ShapePath::ellipse(centre, size[0], size[1])),
|
||||
"rounded" | "roundedRect" => effectcraft_path::rect(size, centre, roundness(size)),
|
||||
"rounded" | "roundedRect" => effectcraft_path::rect(size, centre, roundness()),
|
||||
k => {
|
||||
let (star, points, outer, inner) = polystar_of(k, size)?;
|
||||
effectcraft_path::polystar(effectcraft_path::Polystar {
|
||||
|
||||
@@ -142,6 +142,7 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?;
|
||||
let auid = animator_uid(s, &layer, p, C)?;
|
||||
let mut index = 0;
|
||||
s.edit("Add Text Animator Property", None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
let mut ids = Ids(&mut next);
|
||||
@@ -151,10 +152,13 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for k in &kinds {
|
||||
add_props(&mut ids, anim, k, three);
|
||||
}
|
||||
// The requested property's 1-based position in the Properties group. A companion (such as
|
||||
// Tracking Type) is added before it, so the first new child is not always the one asked for.
|
||||
index = kinds.first().and_then(|k| anim.sub("properties")?.children.iter().position(|n| n.match_id() == k.as_str())).map_or(0, |i| i + 1);
|
||||
proj.next_id = next;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"animator": auid}))
|
||||
Ok(json!({"animator": auid, "index": index}))
|
||||
}
|
||||
|
||||
pub(crate) fn add_selector(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -71,7 +71,8 @@ pub struct TextEdit {
|
||||
/// Character panel changes made with only a caret: the style the next typed text takes.
|
||||
#[serde(default)]
|
||||
pub pending: Option<CharStyle>,
|
||||
/// The layer was made by a Type tool click; leaving it empty deletes it.
|
||||
/// The layer was made by a Type tool click. Kept when left empty, as After Effects does, so this
|
||||
/// only records how the edit began.
|
||||
#[serde(default)]
|
||||
pub created: bool,
|
||||
/// Line position kept by repeated up / down moves (layout space).
|
||||
@@ -372,18 +373,14 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// `text.endEdit`: stop editing (a Type-tool layer left empty is deleted).
|
||||
/// `text.endEdit`: stop editing.
|
||||
///
|
||||
/// A Type-tool layer the user clicked away from without typing is **kept**: After Effects leaves it in
|
||||
/// the timeline as `<空白のテキストレイヤー>`, and the layer came from the user's click rather than from a
|
||||
/// mistake to clean up. Deleting it took away something they could still type into.
|
||||
pub(crate) fn end_edit(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some(e) = s.state.text_edit.take() else { return Ok(Value::Null) };
|
||||
if e.created && layer_doc(s, e.layer).is_some_and(|d| d.text.is_empty()) {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
s.edit("Delete Empty Text Layer", None, |proj, st| {
|
||||
if let Some(c) = proj.comp_mut(cid) {
|
||||
c.layers.retain(|l| l.id != e.layer);
|
||||
}
|
||||
st.selected_layers.retain(|l| *l != e.layer);
|
||||
Ok(())
|
||||
})?;
|
||||
if s.state.text_edit.take().is_none() {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
s.bump();
|
||||
Ok(Value::Null)
|
||||
|
||||
@@ -413,7 +413,8 @@ fn get_3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (w, h) = (comp.width as f64, comp.height as f64);
|
||||
let vs = s.state.views3d.get(&cid).cloned().unwrap_or_default();
|
||||
let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO);
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
let ctx =
|
||||
EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] };
|
||||
let cam = vs.override_camera(w, h).unwrap_or_else(|| camera::active_camera(&ctx));
|
||||
let f = cam.forward();
|
||||
let lights: Vec<Value> = comp
|
||||
@@ -555,7 +556,8 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// through each layer's world matrix; cameras and lights contribute their position).
|
||||
fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec<Vec3> {
|
||||
let Some(comp) = s.project.comp(cid) else { return vec![] };
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
let ctx =
|
||||
EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] };
|
||||
let mut out = vec![];
|
||||
for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) {
|
||||
let m = ctx.world_matrix(l);
|
||||
|
||||
@@ -86,6 +86,10 @@ pub struct EditorState {
|
||||
/// Paste sets the same properties of the selected layers.
|
||||
#[serde(skip)]
|
||||
pub value_clipboard: Vec<(String, effectcraft_keyframe::Value)>,
|
||||
/// The copied keyframes and values as After Effects keyframe text (one layer's Transform
|
||||
/// properties), for the system clipboard.
|
||||
#[serde(skip)]
|
||||
pub keyframe_text: Option<String>,
|
||||
/// Copied effect instances, with comp-time key offsets from the earliest copied key.
|
||||
/// Paste adds them to the selected layers at the destination comp's CTI.
|
||||
#[serde(skip)]
|
||||
|
||||
@@ -81,6 +81,8 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage {
|
||||
nf.codec = probed.codec;
|
||||
nf.has_video = probed.has_video;
|
||||
nf.has_audio = probed.has_audio;
|
||||
// The markers the file carries now (a WAV's cue points), for Update Markers From Source.
|
||||
nf.markers = probed.markers;
|
||||
if matches!(f.kind, FootageKind::Video | FootageKind::Audio) {
|
||||
nf.duration = probed.duration;
|
||||
// Interpret Footage ▸ Conform to frame rate stays; the file's own rate is refreshed.
|
||||
|
||||
@@ -45,14 +45,13 @@ impl SequenceOptions {
|
||||
/// `file.import`'s options. Dropped files (`drag: true`) import as stills, as in After
|
||||
/// Effects, while a folder's numbered stills import as sequences unless `sequence: false`
|
||||
/// (Alt-drag of a folder). The Import dialog passes its `"<format> Sequence"` checkbox (off
|
||||
/// unless ticked) as `sequence`. Without `sequence` or `drag` (scripts, agents) a numbered
|
||||
/// still brings its run, as it always has.
|
||||
/// unless ticked) as `sequence`. Without `sequence` (scripts, agents) a numbered still imports
|
||||
/// as itself, as `ImportOptions.sequence` defaults to false in After Effects (#2018).
|
||||
pub fn for_import(p: &serde_json::Value) -> SequenceOptions {
|
||||
let flag = |k: &str| p.get(k).and_then(serde_json::Value::as_bool);
|
||||
let (sequence, alphabetical) = (flag("sequence"), flag("alphabetical").unwrap_or(false));
|
||||
let dropped = flag("drag").unwrap_or(false);
|
||||
// Force Alphabetical Order is part of the Sequence option.
|
||||
SequenceOptions { sequence: sequence.unwrap_or(alphabetical || !dropped), folders: sequence.unwrap_or(true), alphabetical }
|
||||
SequenceOptions { sequence: sequence.unwrap_or(alphabetical), folders: sequence.unwrap_or(true), alphabetical }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -645,6 +645,21 @@ fn base_name(path: &str) -> String {
|
||||
path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or("footage").to_string()
|
||||
}
|
||||
|
||||
/// A template entry's name, made safe to write inside a folder: a template is a file like any
|
||||
/// other and can name its entries anything. Nothing a platform reads as a separator, a drive or
|
||||
/// the folder above survives, and a name that leaves nothing usable becomes `footage` (#2123).
|
||||
fn safe_file_name(name: &str) -> String {
|
||||
let name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect();
|
||||
// `.` and `..`, and names of dots and spaces alone (Windows drops the trailing ones).
|
||||
if name.trim_matches(['.', ' ']).is_empty() { "footage".to_string() } else { name }
|
||||
}
|
||||
|
||||
/// What two entry names must differ by to be sure of two files: a filesystem may ignore case, and
|
||||
/// Win32 ignores trailing dots and spaces.
|
||||
fn same_file_key(name: &str) -> String {
|
||||
name.trim_end_matches(['.', ' ']).to_lowercase()
|
||||
}
|
||||
|
||||
/// What [`embed_footage`] did.
|
||||
#[derive(Default)]
|
||||
struct Embedded {
|
||||
@@ -738,9 +753,21 @@ fn extract_footage(s: &Session, proj: &mut Project, entries: &[(String, Vec<u8>)
|
||||
let mut used = std::collections::HashSet::new();
|
||||
for (k, v) in entries.iter().filter(|(k, _)| k.starts_with("media/")) {
|
||||
// `media/<item>/<file>` → one folder; a name another item took gets the item's id.
|
||||
let file = base_name(k);
|
||||
let item = k.trim_start_matches("media/").split('/').next().unwrap_or("0");
|
||||
let name = if used.insert(file.clone()) { file } else { format!("{item}-{file}") };
|
||||
let file = safe_file_name(&base_name(k));
|
||||
// The item's id, and only that: it is written as one ([`embed_footage`]), but a template
|
||||
// from elsewhere can hold anything there, and `..\` in it walks out of `dir` on Windows.
|
||||
let item = k.trim_start_matches("media/").split('/').next().filter(|i| i.bytes().all(|b| b.is_ascii_digit())).unwrap_or("0");
|
||||
// A name another entry has taken gets the item's id, and a number if that is taken too:
|
||||
// the names an entry can ask for are not distinct, and two files must not become one.
|
||||
// They are compared the way a filesystem might: `CLIP.png` is `clip.png` on a
|
||||
// case-insensitive volume (Windows and macOS as they come), and Win32 drops a trailing
|
||||
// dot or space, so `a.png.` is `a.png` there. The name written stays as it is.
|
||||
let mut name = file.clone();
|
||||
let mut n = 0usize;
|
||||
while !used.insert(same_file_key(&name)) {
|
||||
n += 1;
|
||||
name = if n == 1 { format!("{item}-{file}") } else { format!("{item}-{n}-{file}") };
|
||||
}
|
||||
let out = format!("{dir}/{name}");
|
||||
s.services.store_file(&out, v).map_err(|e| EngineError::Other(format!("cannot write {out}: {e}")))?;
|
||||
if let Some(imp) = &s.importer {
|
||||
@@ -970,6 +997,57 @@ mod tests {
|
||||
assert_eq!(l.iter().find(|t| t.id == "lower-third").unwrap().controls, ["Name", "Title", "Bar Color"]);
|
||||
}
|
||||
|
||||
/// #2123: a template holds its footage as `media/<item id>/<file>` entries, but a template
|
||||
/// from anywhere else can name them anything — and `..\\`, which only this platform reads as
|
||||
/// a plain character, is a separator on Windows, so a second entry with a base name already
|
||||
/// taken wrote `<dir>/..\\..\\..\\Startup\\x-evil.bat`. Whatever an entry is called, the file
|
||||
/// lands in the footage folder.
|
||||
#[test]
|
||||
fn extracted_footage_stays_in_its_folder() {
|
||||
let s = Session::default();
|
||||
let mut proj = effectcraft_project::Project::default();
|
||||
let dir = std::env::temp_dir().join(format!("ec-templates-media-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let ds = dir.to_string_lossy().into_owned();
|
||||
let entry = |k: &str| (k.to_string(), b"x".to_vec());
|
||||
let entries = [
|
||||
entry("media/0/clip.mp4"),
|
||||
// The base name is taken, so this one's "item id" is used — and it is not one.
|
||||
entry("media/..\\..\\..\\Startup\\x/clip.mp4"),
|
||||
entry("media/1/C:evil.bat"),
|
||||
// Three that collide once the first is made safe: each still gets its own file.
|
||||
entry("media/1/a:b.png"),
|
||||
entry("media/2/a_b.png"),
|
||||
entry("media/3/2-a_b.png"),
|
||||
// The same file on a case-insensitive volume, and on Windows, which drops the dot.
|
||||
entry("media/4/clip.png"),
|
||||
entry("media/5/CLIP.png"),
|
||||
entry("media/6/a.png"),
|
||||
entry("media/7/a.png."),
|
||||
];
|
||||
let written = extract_footage(&s, &mut proj, &entries, &ds).unwrap();
|
||||
assert_eq!(written.len(), entries.len());
|
||||
for w in &written {
|
||||
let name = w.strip_prefix(&format!("{ds}/")).unwrap_or_else(|| panic!("{w} is outside {ds}"));
|
||||
assert!(!name.contains(['/', '\\', ':']), "{w}");
|
||||
assert!(!name.trim_matches(['.', ' ']).is_empty(), "{w}");
|
||||
}
|
||||
// One file each, all of them in the folder, none overwritten by the next.
|
||||
assert_eq!(written.iter().collect::<std::collections::BTreeSet<_>>().len(), entries.len(), "{written:?}");
|
||||
assert_eq!(std::fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0), entries.len());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// And the names that would leave nothing to write.
|
||||
for n in ["..", ".", " .. ", "...", ""] {
|
||||
assert_eq!(safe_file_name(n), "footage", "{n:?}");
|
||||
}
|
||||
assert_eq!(safe_file_name("clip.mp4"), "clip.mp4");
|
||||
assert_eq!(safe_file_name(".hidden.mp4"), ".hidden.mp4");
|
||||
assert_eq!(safe_file_name("a/b\\c:d"), "a_b_c_d");
|
||||
// Names a filesystem may not tell apart (only the case half of this shows on macOS).
|
||||
assert_eq!(same_file_key("A.PNG. "), same_file_key("a.png"));
|
||||
assert_ne!(same_file_key("a.png"), same_file_key("b.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_opens_an_untitled_copy() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
pub(crate) mod keyframe_text;
|
||||
pub(crate) mod keyframe_text_effects;
|
||||
pub mod psd_import;
|
||||
pub mod vector;
|
||||
pub mod xml_project;
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Copied keyframes as After Effects keyframe text, for the system clipboard (#1908). Other
|
||||
//! applications read this tab-separated format (AE2Blend brings it into Blender). Like After
|
||||
//! Effects it covers one layer: its copied properties, keyframed ones as one row per key (frame
|
||||
//! number in comp time, then the value) and static ones as a single row with no frame. Easing
|
||||
//! and tangents aren't part of the format; EffectCraft's own paste keeps them.
|
||||
|
||||
use effectcraft_project::{AutoOrient, Comp, GroupKind, ItemKind, Layer, LayerId, LayerSource, Node, ParamUi, Project, PropGroup, Property, Uid};
|
||||
|
||||
use crate::{KeyRef, keyframe_text_effects};
|
||||
|
||||
/// Transform properties with the unit their columns are labelled with and, for vectors, the
|
||||
/// third component a 2D layer shows. X / Y / Z Position (Separate Dimensions) have no unit.
|
||||
const TRANSFORM: &[(&str, &str, Option<f64>)] = &[
|
||||
("poi", "pixels", Some(0.0)),
|
||||
("anchor", "pixels", Some(0.0)),
|
||||
("position", "pixels", Some(0.0)),
|
||||
("positionX", "", None),
|
||||
("positionY", "", None),
|
||||
("positionZ", "", None),
|
||||
("scale", "percent", Some(100.0)),
|
||||
("orientation", "degrees", None),
|
||||
("rotationX", "degrees", None),
|
||||
("rotationY", "degrees", None),
|
||||
("rotation", "degrees", None),
|
||||
("opacity", "percent", None),
|
||||
];
|
||||
|
||||
/// How a copied property is written: its block's header line, column labels and values.
|
||||
struct Channel {
|
||||
header: String,
|
||||
labels: Vec<String>,
|
||||
values: Values,
|
||||
}
|
||||
|
||||
/// How a property's value becomes the numbers in a row.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Values {
|
||||
Plain,
|
||||
/// A 2D layer's Transform vector: the third component is the one the layer shows (it may
|
||||
/// keep another from pasted 3D keys).
|
||||
Flat(f64),
|
||||
/// RGBA 0–1 as alpha, red, green, blue 0–255.
|
||||
Argb,
|
||||
/// A popup's 0-based choice as After Effects' 1-based one.
|
||||
OneBased,
|
||||
Scale(f64),
|
||||
}
|
||||
|
||||
impl Values {
|
||||
fn row(self, mut v: Vec<f64>) -> Vec<f64> {
|
||||
match (self, v.as_slice()) {
|
||||
(Values::Flat(z), _) => {
|
||||
if let Some(c) = v.get_mut(2) {
|
||||
*c = z;
|
||||
}
|
||||
v
|
||||
}
|
||||
(Values::Argb, [r, g, b, a]) => [a, r, g, b].map(|c| c * 255.0).to_vec(),
|
||||
(Values::OneBased, _) => v.into_iter().map(|c| c + 1.0).collect(),
|
||||
(Values::Scale(s), _) => v.into_iter().map(|c| c * s).collect(),
|
||||
_ => v,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The text for copied keys (layer times) and unanimated properties. `None` unless they all come
|
||||
/// from one layer and at least one property can be written.
|
||||
pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[(LayerId, Uid)]) -> Option<String> {
|
||||
let lid = keys.first().map(|k| k.layer).or_else(|| statics.first().map(|s| s.0))?;
|
||||
if keys.iter().any(|k| k.layer != lid) || statics.iter().any(|s| s.0 != lid) {
|
||||
return None;
|
||||
}
|
||||
let layer = comp.layer(lid)?;
|
||||
let fps = comp.frame_rate.as_f64();
|
||||
let mut blocks = String::new();
|
||||
layer.props.walk("", &mut |_, pr| {
|
||||
let mut times: Vec<_> = keys.iter().filter(|k| k.prop == pr.uid).map(|k| k.time).collect();
|
||||
times.sort();
|
||||
times.dedup();
|
||||
let rows: Vec<(String, Vec<f64>)> = if !times.is_empty() {
|
||||
let frame = |t| num(layer.comp_time(t).seconds() * fps);
|
||||
times.into_iter().filter_map(|t| Some((frame(t), pr.keys.iter().find(|k| k.time == t)?.value.components()))).collect()
|
||||
} else if statics.iter().any(|s| s.1 == pr.uid) {
|
||||
vec![(String::new(), pr.value.components())]
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let Some(ch) = channel(layer, pr).filter(|ch| !ch.labels.is_empty()) else { return };
|
||||
if rows.iter().any(|(_, v)| v.is_empty()) {
|
||||
return;
|
||||
}
|
||||
blocks.push_str(&format!("{}\n\tFrame\t{}\t\n", ch.header, ch.labels.join("\t")));
|
||||
for (frame, values) in rows {
|
||||
let values: Vec<String> = ch.values.row(values).into_iter().map(num).collect();
|
||||
blocks.push_str(&format!("\t{frame}\t{}\t\n", values.join("\t")));
|
||||
}
|
||||
blocks.push('\n');
|
||||
});
|
||||
if blocks.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// After Effects writes a text layer's source as 1 × 1. Other layers without a source of
|
||||
// their own (shapes, cameras) take the comp's size, and all of them its pixel aspect.
|
||||
let (w, h) = match (&layer.source, effectcraft_render::eval::source_size(project, layer)) {
|
||||
(LayerSource::Text, _) => (1, 1),
|
||||
(_, (0, 0)) => (comp.width, comp.height),
|
||||
(_, size) => size,
|
||||
};
|
||||
let par = match layer.source {
|
||||
LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match project.item(item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Footage(f)) => f.pixel_aspect,
|
||||
Some(ItemKind::Solid(s)) => s.pixel_aspect,
|
||||
Some(ItemKind::Comp(c)) => c.pixel_aspect,
|
||||
_ => comp.pixel_aspect,
|
||||
},
|
||||
_ => comp.pixel_aspect,
|
||||
};
|
||||
// The layout of current After Effects: a `Layer` line before the layer's properties (parsers
|
||||
// such as ae-keyframes reject properties without one), and a tab after every column.
|
||||
Some(format!(
|
||||
"Adobe After Effects 9.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t{}\n\tComp Pixel Aspect Ratio\t{}\n\nLayer\n{blocks}\nEnd of Keyframe Data\n",
|
||||
num(fps),
|
||||
num(par),
|
||||
num(comp.pixel_aspect)
|
||||
))
|
||||
}
|
||||
|
||||
/// How the layer's property `pr` is written, by where it sits in the layer: `None` for
|
||||
/// properties the text doesn't carry.
|
||||
fn channel(layer: &Layer, pr: &Property) -> Option<Channel> {
|
||||
let chain = layer.props.node_chain(pr.uid)?;
|
||||
match chain.first().and_then(|n| n.as_group()).map(|g| g.match_id.as_str()) {
|
||||
None if pr.match_id == "timeRemap" => Some(Channel { header: "Time Remap".into(), labels: labels("seconds", 1), values: Values::Plain }),
|
||||
Some("transform") => transform(layer, pr),
|
||||
Some("effects") => effect(&chain, pr),
|
||||
Some("masks" | "text" | "contents" | "cameraOptions" | "lightOptions" | "materialOptions") => nested(&chain, pr),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexed groups by match id, with the kind After Effects names them by: it writes them by kind
|
||||
/// and place (`Mask #2`, `Group #1`), not by name.
|
||||
const INSTANCES: &[(&str, &str)] = &[
|
||||
("mask", "Mask"),
|
||||
("animator", "Animator"),
|
||||
("rangeSelector", "Range Selector"),
|
||||
("wigglySelector", "Wiggly Selector"),
|
||||
("expressionSelector", "Expression Selector"),
|
||||
("group", "Group"),
|
||||
("rect", "Rectangle Path"),
|
||||
("ellipse", "Ellipse Path"),
|
||||
("star", "Polystar Path"),
|
||||
("path", "Path"),
|
||||
("fill", "Fill"),
|
||||
("stroke", "Stroke"),
|
||||
("gfill", "Gradient Fill"),
|
||||
("gstroke", "Gradient Stroke"),
|
||||
("trim", "Trim Paths"),
|
||||
("repeater", "Repeater"),
|
||||
("round", "Round Corners"),
|
||||
("offset", "Offset Paths"),
|
||||
("pucker", "Pucker & Bloat"),
|
||||
("twist", "Twist"),
|
||||
("zigzag", "Zig Zag"),
|
||||
("wiggle", "Wiggle Paths"),
|
||||
("merge", "Merge Paths"),
|
||||
];
|
||||
|
||||
/// A property under Masks, Text, Contents or Camera / Light / Material Options: the group's
|
||||
/// name, then each group down to the property. Instances go by kind and place, and a text
|
||||
/// selector's own properties carry their place too (`Start #1`).
|
||||
fn nested(chain: &[&Node], pr: &Property) -> Option<Channel> {
|
||||
let groups: Vec<&PropGroup> = chain.iter().filter_map(|n| n.as_group()).collect();
|
||||
let parent = groups.last()?;
|
||||
let (labels, values) = columns(&parent.match_id, &pr.match_id, pr, false)?;
|
||||
let mut header = groups.first()?.name.clone();
|
||||
for pair in groups.windows(2) {
|
||||
let [outer, g] = pair else { continue };
|
||||
match INSTANCES.iter().find(|(id, _)| *id == g.match_id).filter(|_| g.is_instance()) {
|
||||
Some((_, kind)) => header.push_str(&format!("\t{kind} #{}", place(outer, g.uid)?)),
|
||||
None => header.push_str(&format!("\t{}", g.name)),
|
||||
}
|
||||
}
|
||||
if parent.match_id.ends_with("Selector") {
|
||||
header.push_str(&format!("\t{} #{}", pr.name, place(parent, pr.uid)?));
|
||||
} else {
|
||||
header.push_str(&format!("\t{}", pr.name));
|
||||
}
|
||||
Some(Channel { header, labels, values })
|
||||
}
|
||||
|
||||
/// An effect property: `Effects`, the effect's name (not a custom one) and place on the layer,
|
||||
/// then the property's name and After Effects index. Properties in twirl-down groups are
|
||||
/// written without their group, as After Effects does.
|
||||
fn effect(chain: &[&Node], pr: &Property) -> Option<Channel> {
|
||||
let [Node::Group(effects), Node::Group(fx), ..] = chain else { return None };
|
||||
let GroupKind::Effect { effect } = &fx.kind else { return None };
|
||||
let name = effectcraft_effects::find(effect).map_or(fx.name.as_str(), |spec| spec.name);
|
||||
let path: String = chain.iter().skip(2).filter_map(|n| n.as_group()).map(|g| format!("{}/", g.match_id)).chain([pr.match_id.clone()]).collect();
|
||||
let (labels, values) = columns(effect, &path, pr, true)?;
|
||||
let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, effect_index(effect, fx, pr.uid)?);
|
||||
Some(Channel { header, labels, values })
|
||||
}
|
||||
|
||||
/// After Effects' index of an effect property: the effect's hidden input layer is #1, and a
|
||||
/// twirl-down group takes an index where it opens and another where it closes. The hidden
|
||||
/// source choice EffectCraft keeps beside a layer parameter has none. Where After Effects
|
||||
/// numbers a parameter otherwise ([`keyframe_text_effects::INDICES`]), counting goes on from it.
|
||||
fn effect_index(effect: &str, fx: &PropGroup, uid: Uid) -> Option<usize> {
|
||||
fn count(effect: &str, g: &PropGroup, prefix: &str, uid: Uid, n: &mut usize) -> bool {
|
||||
for c in &g.children {
|
||||
match c {
|
||||
Node::Prop(p) if is_layer_source(g, p) => {}
|
||||
Node::Prop(p) => {
|
||||
let path = format!("{prefix}{}", p.match_id);
|
||||
*n = keyframe_text_effects::INDICES.iter().find(|(e, q, _)| *e == effect && *q == path).map_or(*n + 1, |a| a.2);
|
||||
if p.uid == uid {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Node::Group(sub) => {
|
||||
*n += 1;
|
||||
if count(effect, sub, &format!("{prefix}{}/", sub.match_id), uid, n) {
|
||||
return true;
|
||||
}
|
||||
*n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
let mut n = 1;
|
||||
count(effect, fx, "", uid, &mut n).then_some(n)
|
||||
}
|
||||
|
||||
fn is_layer_source(g: &PropGroup, p: &Property) -> bool {
|
||||
p.ui == ParamUi::Hidden && g.props().any(|l| l.ui == ParamUi::Layer && effectcraft_effects::layer_source_id(&l.match_id) == p.match_id)
|
||||
}
|
||||
|
||||
/// 1-based position of a child in its group.
|
||||
fn place(group: &PropGroup, uid: Uid) -> Option<usize> {
|
||||
group.children.iter().position(|c| c.uid() == uid).map(|i| i + 1)
|
||||
}
|
||||
|
||||
/// How After Effects writes a property differently from what its editor suggests ([`by_ui`]).
|
||||
#[derive(Clone, Copy)]
|
||||
enum Exception {
|
||||
/// Not written.
|
||||
Skip,
|
||||
Unit(&'static str),
|
||||
Values(Values),
|
||||
}
|
||||
|
||||
/// Properties After Effects writes differently from what their editor suggests, by the effect
|
||||
/// or group they're directly in and their match id.
|
||||
const EXCEPTIONS: &[(&str, &str, Exception)] = &[
|
||||
// Path Options' Path picks a mask, which After Effects doesn't write.
|
||||
("pathOptions", "path", Exception::Skip),
|
||||
("mask", "feather", Exception::Unit("pixels")),
|
||||
("mask", "expansion", Exception::Unit("pixels")),
|
||||
("cameraOptions", "zoom", Exception::Unit("pixels")),
|
||||
("cameraOptions", "focusDistance", Exception::Unit("pixels")),
|
||||
("cameraOptions", "aperture", Exception::Unit("pixels")),
|
||||
("cameraOptions", "highlightGain", Exception::Unit("")),
|
||||
("cameraOptions", "highlightSaturation", Exception::Unit("")),
|
||||
// After Effects keeps the threshold as 0–1.
|
||||
("cameraOptions", "highlightThreshold", Exception::Values(Values::Scale(1.0 / 255.0))),
|
||||
("lightOptions", "shadowDiffusion", Exception::Unit("pixels")),
|
||||
// Off, On, Only: written from 0.
|
||||
("materialOptions", "castsShadows", Exception::Values(Values::Plain)),
|
||||
("materialOptions", "acceptsShadows", Exception::Values(Values::Plain)),
|
||||
];
|
||||
|
||||
/// Column labels and values for a property in `scope`, the effect (by id) or group (by match
|
||||
/// id) it's directly in, at `path` there (an effect property's groups, then its match id): [`by_ui`]
|
||||
/// unless [`EXCEPTIONS`] or, in effects, [`keyframe_text_effects::UNITS`] say otherwise.
|
||||
fn columns(scope: &str, path: &str, pr: &Property, in_effect: bool) -> Option<(Vec<String>, Values)> {
|
||||
let (by_editor, values) = by_ui(pr, in_effect)?;
|
||||
let exception = EXCEPTIONS.iter().find(|(s, id, _)| *s == scope && *id == path).map(|e| e.2);
|
||||
let effect_unit = || keyframe_text_effects::UNITS.iter().find(|(e, p, _)| in_effect && *e == scope && *p == path).map(|u| Exception::Unit(u.2));
|
||||
Some(match exception.or_else(effect_unit) {
|
||||
None => (by_editor, values),
|
||||
Some(Exception::Skip) => return None,
|
||||
Some(Exception::Unit(unit)) => (labels(unit, pr.value.dims()), values),
|
||||
Some(Exception::Values(values)) => (by_editor, values),
|
||||
})
|
||||
}
|
||||
|
||||
/// Column labels and values for a property, by how it's edited. After Effects labels most 0–100
|
||||
/// sliders (opacities) as percent, and ±100 ones too outside effects (text selector amounts),
|
||||
/// and other sliders, checkboxes and popups with no unit. [`keyframe_text_effects::UNITS`] lists
|
||||
/// the effect parameters After Effects labels otherwise.
|
||||
fn by_ui(pr: &Property, in_effect: bool) -> Option<(Vec<String>, Values)> {
|
||||
let dims = pr.value.dims();
|
||||
Some(match &pr.ui {
|
||||
ParamUi::Color => (["alpha ", "red ", "green ", "blue "].map(String::from).to_vec(), Values::Argb),
|
||||
ParamUi::Popup { .. } => (labels("", dims), Values::OneBased),
|
||||
ParamUi::Slider { min, max, .. } if *max == 100.0 && (*min == 0.0 || (*min == -100.0 && !in_effect)) => (labels("percent", dims), Values::Plain),
|
||||
ParamUi::Percent => (labels("percent", dims), Values::Plain),
|
||||
ParamUi::Angle => (labels("degrees", dims), Values::Plain),
|
||||
ParamUi::Point | ParamUi::Point3 => (labels("pixels", dims), Values::Plain),
|
||||
ParamUi::Number | ParamUi::Slider { .. } | ParamUi::Pixels | ParamUi::Checkbox => (labels("", dims), Values::Plain),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn transform(layer: &Layer, pr: &Property) -> Option<Channel> {
|
||||
let (id, unit, z) = TRANSFORM.iter().find(|(id, ..)| *id == pr.match_id)?;
|
||||
if !active(layer, id) || (pr.three_d_only && !layer.is_3d()) {
|
||||
return None;
|
||||
}
|
||||
// After Effects labels only the first of Orientation's three columns, and names Z Rotation
|
||||
// plain Rotation (as AE2Blend expects).
|
||||
let labels = if *id == "orientation" { vec![format!("X {unit}")] } else { labels(unit, pr.value.dims()) };
|
||||
let values = match z {
|
||||
Some(z) if !layer.is_3d() => Values::Flat(*z),
|
||||
_ => Values::Plain,
|
||||
};
|
||||
Some(Channel { header: format!("Transform\t{}", pr.name), labels, values })
|
||||
}
|
||||
|
||||
/// Column labels: the unit, or one per axis (`X pixels`, `Y pixels`; `X ` with no unit, as After
|
||||
/// Effects writes a shape's Size).
|
||||
fn labels(unit: &str, dims: usize) -> Vec<String> {
|
||||
match dims {
|
||||
0 => vec![],
|
||||
1 => vec![unit.to_string()],
|
||||
_ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}")).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the layer uses this Transform property: one-node cameras (and lights not aimed at
|
||||
/// it) have no Point of Interest, and Separate Dimensions replaces Position with X / Y / Z
|
||||
/// Position.
|
||||
fn active(layer: &Layer, id: &str) -> bool {
|
||||
match id {
|
||||
"poi" => !(layer.is_camera() || layer.is_light()) || layer.auto_orient == AutoOrient::TowardsPointOfInterest,
|
||||
"position" => layer.transform().is_none_or(|tr| tr.get("positionX").is_none()),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// A number with at most three decimals and no trailing zeros (`23.976`, `960`, `-0.5`).
|
||||
fn num(v: f64) -> String {
|
||||
let s = format!("{:.3}", if v.is_finite() { v } else { 0.0 });
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
if s == "-0" { "0".into() } else { s.into() }
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! After Effects' index and unit for effect parameters where they differ from what
|
||||
//! [`crate::keyframe_text`]'s rules give, as After Effects 2019 writes them in its keyframe text.
|
||||
//! After Effects keeps an effect's parameter indices for good (projects store them), so they
|
||||
//! hold for later versions. Parameters After Effects lacks, or names differently and keeps
|
||||
//! elsewhere, aren't listed.
|
||||
|
||||
/// Effect id, parameter path in the effect (its groups' match ids, then its own) and After
|
||||
/// Effects' index for it; the parameters after it count on from there. After Effects numbers
|
||||
/// legacy parameters it hides, so its indices skip ahead.
|
||||
pub(crate) const INDICES: &[(&str, &str, usize)] = &[
|
||||
("ec.3d.channelextract", "clampOutput", 6),
|
||||
("ec.3d.channelextract", "antialias", 5),
|
||||
("ec.3d.extractor", "blackPoint", 4),
|
||||
("ec.audio.parametriceq", "band1Enable", 3),
|
||||
("ec.blur.radial", "amount", 3),
|
||||
("ec.color.cccolorneutralizer", "midtonesUnbalance", 8),
|
||||
("ec.color.cccolorneutralizer", "highlightsUnbalance", 14),
|
||||
("ec.color.cccolorneutralizer", "pinning", 20),
|
||||
("ec.color.cckernel", "k1", 3),
|
||||
("ec.color.cckernel", "k4", 8),
|
||||
("ec.color.cckernel", "k7", 13),
|
||||
("ec.color.cckernel", "scale", 17),
|
||||
("ec.color.cckernel", "absolute", 18),
|
||||
("ec.color.colorstabilizer", "stabilize", 2),
|
||||
("ec.color.huesaturation", "hue", 4),
|
||||
("ec.color.huesaturation", "colorize", 7),
|
||||
("ec.color.levels", "inBlack", 4),
|
||||
("ec.color.levelsic", "rgb/rgbInBlack", 5),
|
||||
("ec.color.lumetri", "highDynamicRange", 3),
|
||||
("ec.color.lumetri", "basicCorrection/inputLut", 8),
|
||||
("ec.color.lumetri", "basicCorrection/whiteBalance/temperature", 12),
|
||||
("ec.color.lumetri", "basicCorrection/tone/hdrSpecular", 22),
|
||||
("ec.color.lumetri", "basicCorrection/saturation", 26),
|
||||
("ec.color.lumetri", "creative/look", 32),
|
||||
("ec.color.lumetri", "creative/lookIntensity", 33),
|
||||
("ec.color.lumetri", "creative/adjustments/tintBalance", 40),
|
||||
("ec.color.lumetri", "hslSecondary/key/showMask", 83),
|
||||
("ec.color.lumetri", "hslSecondary/refine/denoise", 90),
|
||||
("ec.color.lumetri", "hslSecondary/correction/temperature", 96),
|
||||
("ec.color.lumetri", "vignette/vignetteAmount", 105),
|
||||
("ec.color.selectivecolor", "details/reds/redsCyan", 10),
|
||||
("ec.color.videolimiter", "compressionBeforeClipping", 3),
|
||||
("ec.distort.ccflomotion", "knot1", 3),
|
||||
("ec.distort.ccpowerpin", "expandTop", 9),
|
||||
("ec.distort.upscale", "scale", 4),
|
||||
("ec.distort.puppet", "refinement", 3),
|
||||
("ec.control.stereo3d", "configuration", 3),
|
||||
("ec.control.stereo3d", "convergenceOf", 8),
|
||||
("ec.generate.advancedlightning", "turbulence", 17),
|
||||
("ec.generate.advancedlightning", "alphaObstacle", 16),
|
||||
("ec.generate.advancedlightning", "compositeOnOriginal", 21),
|
||||
("ec.generate.ccgluegun", "style", 9),
|
||||
("ec.generate.cclightburst", "setColor", 7),
|
||||
("ec.generate.cclightrays", "colorFromSource", 8),
|
||||
("ec.generate.cclightrays", "color", 10),
|
||||
("ec.generate.circle", "feather/feather", 7),
|
||||
("ec.generate.paintbucket", "color", 9),
|
||||
("ec.vr.blur", "frameLayout", 3),
|
||||
("ec.vr.blur", "blurriness", 6),
|
||||
("ec.vr.chromaticaberrations", "frameLayout", 3),
|
||||
("ec.vr.chromaticaberrations", "aberrationRed", 7),
|
||||
("ec.vr.colorgradients", "frameLayout", 3),
|
||||
("ec.vr.colorgradients", "point1", 10),
|
||||
("ec.vr.colorgradients", "point2", 12),
|
||||
("ec.vr.colorgradients", "point3", 14),
|
||||
("ec.vr.colorgradients", "point4", 16),
|
||||
("ec.vr.colorgradients", "point5", 18),
|
||||
("ec.vr.colorgradients", "blend", 7),
|
||||
("ec.vr.colorgradients", "opacity", 27),
|
||||
("ec.vr.denoise", "frameLayout", 3),
|
||||
("ec.vr.denoise", "noiseType", 6),
|
||||
("ec.vr.digitalglitch", "frameLayout", 3),
|
||||
("ec.vr.digitalglitch", "masterAmplitude", 9),
|
||||
("ec.vr.digitalglitch", "distortionRate", 16),
|
||||
("ec.vr.digitalglitch", "distortionComplexity", 15),
|
||||
("ec.vr.digitalglitch", "distortionEvolution", 17),
|
||||
("ec.vr.digitalglitch", "colorDistortion", 11),
|
||||
("ec.vr.digitalglitch", "randomSeed", 39),
|
||||
("ec.vr.fractalnoise", "frameLayout", 3),
|
||||
("ec.vr.fractalnoise", "fractalType", 6),
|
||||
("ec.vr.fractalnoise", "randomSeed", 25),
|
||||
("ec.vr.glow", "frameLayout", 3),
|
||||
("ec.vr.glow", "luminanceThreshold", 6),
|
||||
("ec.vr.planetosphere", "frameLayout", 3),
|
||||
("ec.vr.planetosphere", "scale", 6),
|
||||
("ec.vr.planetosphere", "feather", 8),
|
||||
("ec.vr.planetosphere", "tilt", 15),
|
||||
("ec.vr.rotatesphere", "frameLayout", 3),
|
||||
("ec.vr.rotatesphere", "invertRotation", 9),
|
||||
("ec.vr.rotatesphere", "tilt", 6),
|
||||
("ec.vr.sharpen", "frameLayout", 3),
|
||||
("ec.vr.sharpen", "sharpenAmount", 6),
|
||||
("ec.key.colordifference", "view", 3),
|
||||
("ec.key.colorrange", "fuzziness", 3),
|
||||
("ec.key.extract", "channel", 3),
|
||||
("ec.key.linearcolor", "view", 3),
|
||||
("ec.matte.refinehard", "feather", 2),
|
||||
("ec.matte.refinehard", "reduceChatter", 5),
|
||||
("ec.matte.refinehard", "decontaminationAmount", 14),
|
||||
("ec.matte.refinesoft", "reduceChatter", 10),
|
||||
("ec.matte.rotobrush", "rotoBrushPropagation/searchRadius", 3),
|
||||
("ec.matte.rotobrush", "rotoBrushMatte/feather", 13),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/smooth", 22),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/reduceChatter", 27),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/useMotionBlur", 29),
|
||||
("ec.noise.noisealpha", "noiseOptions/cycleNoise", 8),
|
||||
("ec.obsolete.basictext", "position", 2),
|
||||
("ec.key.spill", "suppression", 3),
|
||||
("ec.perspective.cameratracker", "shotType", 4),
|
||||
("ec.perspective.cameratracker", "advanced/solveMethod", 12),
|
||||
("ec.perspective.cameratracker", "advanced/detailedAnalysis", 17),
|
||||
("ec.perspective.cameratracker", "advanced/autoDeletePoints", 18),
|
||||
("ec.perspective.cccylinder", "rotationX", 9),
|
||||
("ec.perspective.cccylinder", "render", 13),
|
||||
("ec.perspective.cccylinder", "lightIntensity", 15),
|
||||
("ec.perspective.cccylinder", "ambient", 21),
|
||||
("ec.sim.ccdrizzle", "light/lightIntensity", 10),
|
||||
("ec.sim.ccdrizzle", "light/lightHeight", 13),
|
||||
("ec.sim.ccdrizzle", "light/lightDirection", 15),
|
||||
("ec.sim.ccmrmercury", "blobBirthSize", 15),
|
||||
("ec.sim.ccmrmercury", "light/lightIntensity", 19),
|
||||
("ec.sim.ccmrmercury", "light/lightHeight", 22),
|
||||
("ec.sim.ccmrmercury", "light/lightDirection", 24),
|
||||
("ec.sim.ccparticlesystems2", "particle/maxOpacity", 24),
|
||||
("ec.sim.ccparticlesystems2", "particle/birthColor", 27),
|
||||
("ec.sim.ccrainfall", "transferMode", 16),
|
||||
("ec.sim.ccrainfall", "extras/randomSeed", 23),
|
||||
("ec.sim.ccsnowfall", "color", 19),
|
||||
("ec.sim.ccsnowfall", "transferMode", 26),
|
||||
("ec.sim.ccsnowfall", "extras/randomSeed", 32),
|
||||
("ec.stylize.ccblockload", "startCleared", 4),
|
||||
("ec.stylize.cchextile", "rotate", 6),
|
||||
("ec.stylize.glow", "colorLooping", 9),
|
||||
("ec.text.timecode", "timeUnits", 5),
|
||||
("ec.text.timecode", "textPosition", 9),
|
||||
("ec.utility.facemeasurements", "headOrientationX", 9),
|
||||
("ec.utility.facemeasurements", "rightEyebrowRaise", 20),
|
||||
("ec.utility.facemeasurements", "mouthOffsetX", 26),
|
||||
("ec.utility.facetrackpoints", "leftEyebrowInner", 10),
|
||||
("ec.utility.facetrackpoints", "leftEyebrowMiddle", 12),
|
||||
("ec.utility.facetrackpoints", "leftEyebrowOuter", 14),
|
||||
("ec.utility.facetrackpoints", "rightEyebrowInner", 24),
|
||||
("ec.utility.facetrackpoints", "rightEyebrowMiddle", 26),
|
||||
("ec.utility.facetrackpoints", "rightEyebrowOuter", 28),
|
||||
("ec.utility.facetrackpoints", "leftEyeInner", 15),
|
||||
("ec.utility.facetrackpoints", "leftEyeOuter", 21),
|
||||
("ec.utility.facetrackpoints", "leftPupil", 20),
|
||||
("ec.utility.facetrackpoints", "rightEyeInner", 29),
|
||||
("ec.utility.facetrackpoints", "rightEyeOuter", 35),
|
||||
("ec.utility.facetrackpoints", "rightPupil", 34),
|
||||
("ec.utility.facetrackpoints", "noseTip", 41),
|
||||
("ec.utility.facetrackpoints", "leftNostril", 44),
|
||||
("ec.utility.facetrackpoints", "rightNostril", 46),
|
||||
("ec.utility.facetrackpoints", "mouthLeft", 49),
|
||||
("ec.utility.facetrackpoints", "mouthRight", 55),
|
||||
("ec.utility.facetrackpoints", "mouthTop", 52),
|
||||
("ec.utility.facetrackpoints", "mouthBottom", 58),
|
||||
("ec.utility.facetrackpoints", "chin", 84),
|
||||
];
|
||||
|
||||
/// Effect id, parameter path and the unit After Effects labels the parameter's columns with.
|
||||
pub(crate) const UNITS: &[(&str, &str, &str)] = &[
|
||||
("ec.3d.depthoffield", "focalBias", ""),
|
||||
("ec.3d.idmatte", "feather", ""),
|
||||
("ec.audio.flangechorus", "voiceSeparationTime", ""),
|
||||
("ec.audio.flangechorus", "voicePhaseChange", ""),
|
||||
("ec.audio.modulator", "modulationRate", ""),
|
||||
("ec.audio.parametriceq", "band1BoostCut", "percent"),
|
||||
("ec.audio.parametriceq", "band2BoostCut", "percent"),
|
||||
("ec.audio.parametriceq", "band3BoostCut", "percent"),
|
||||
("ec.audio.stereomixer", "leftLevel", "percent"),
|
||||
("ec.audio.stereomixer", "rightLevel", "percent"),
|
||||
("ec.audio.stereomixer", "leftPan", "percent"),
|
||||
("ec.audio.stereomixer", "rightPan", "percent"),
|
||||
("ec.blur.bilateral", "threshold", ""),
|
||||
("ec.blur.ccradial", "amount", "pixels"),
|
||||
("ec.blur.ccradialfast", "amount", ""),
|
||||
("ec.blur.camerashakedeblur", "strength", "percent"),
|
||||
("ec.blur.radial", "amount", ""),
|
||||
("ec.blur.smart", "radius", ""),
|
||||
("ec.blur.smart", "threshold", ""),
|
||||
("ec.color.autocolor", "blackClip", "percent"),
|
||||
("ec.color.autocolor", "whiteClip", "percent"),
|
||||
("ec.color.autocontrast", "blackClip", "percent"),
|
||||
("ec.color.autocontrast", "whiteClip", "percent"),
|
||||
("ec.color.autolevels", "blackClip", "percent"),
|
||||
("ec.color.autolevels", "whiteClip", "percent"),
|
||||
("ec.color.brightnesscontrast", "contrast", "pixels"),
|
||||
("ec.color.cccolorneutralizer", "pinning", ""),
|
||||
("ec.color.cccolorneutralizer", "blendWOriginal", ""),
|
||||
("ec.color.changecolor", "lightnessTransform", "pixels"),
|
||||
("ec.color.changecolor", "saturationTransform", "pixels"),
|
||||
("ec.color.colorbalance", "shadowRed", "pixels"),
|
||||
("ec.color.colorbalance", "shadowGreen", "pixels"),
|
||||
("ec.color.colorbalance", "shadowBlue", "pixels"),
|
||||
("ec.color.colorbalance", "midRed", "pixels"),
|
||||
("ec.color.colorbalance", "midGreen", "pixels"),
|
||||
("ec.color.colorbalance", "midBlue", "pixels"),
|
||||
("ec.color.colorbalance", "hiRed", "pixels"),
|
||||
("ec.color.colorbalance", "hiGreen", "pixels"),
|
||||
("ec.color.colorbalance", "hiBlue", "pixels"),
|
||||
("ec.color.colorbalancehls", "lightness", "pixels"),
|
||||
("ec.color.colorbalancehls", "saturation", "pixels"),
|
||||
("ec.color.colorstabilizer", "sampleSize", "percent"),
|
||||
("ec.color.gammapedestalgain", "blackStretch", "percent"),
|
||||
("ec.color.huesaturation", "saturation", "pixels"),
|
||||
("ec.color.huesaturation", "lightness", "pixels"),
|
||||
("ec.color.huesaturation", "colorizeSaturation", ""),
|
||||
("ec.color.huesaturation", "colorizeLightness", "pixels"),
|
||||
("ec.color.lumetri", "creative/adjustments/fadedFilm", ""),
|
||||
("ec.color.lumetri", "hslSecondary/refine/denoise", ""),
|
||||
("ec.color.lumetri", "hslSecondary/refine/blur", ""),
|
||||
("ec.color.lumetri", "vignette/vignetteMidpoint", ""),
|
||||
("ec.color.lumetri", "vignette/vignetteFeather", ""),
|
||||
("ec.color.psarbitrarymap", "phase", "degrees"),
|
||||
("ec.color.selectivecolor", "details/reds/redsCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/reds/redsMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/reds/redsYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/reds/redsBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/yellows/yellowsCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/yellows/yellowsMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/yellows/yellowsYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/yellows/yellowsBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/greens/greensCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/greens/greensMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/greens/greensYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/greens/greensBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/cyans/cyansCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/cyans/cyansMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/cyans/cyansYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/cyans/cyansBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/blues/bluesCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/blues/bluesMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/blues/bluesYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/blues/bluesBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/magentas/magentasCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/magentas/magentasMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/magentas/magentasYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/magentas/magentasBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/whites/whitesCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/whites/whitesMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/whites/whitesYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/whites/whitesBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/neutrals/neutralsCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/neutrals/neutralsMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/neutrals/neutralsYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/neutrals/neutralsBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/blacks/blacksCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/blacks/blacksMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/blacks/blacksYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/blacks/blacksBlack", "percent"),
|
||||
("ec.color.shadowhighlight", "shadowAmount", ""),
|
||||
("ec.color.shadowhighlight", "highlightAmount", ""),
|
||||
("ec.color.shadowhighlight", "moreOptions/shadowTonalWidth", ""),
|
||||
("ec.color.shadowhighlight", "moreOptions/highlightTonalWidth", ""),
|
||||
("ec.color.shadowhighlight", "moreOptions/blackClip", "percent"),
|
||||
("ec.color.shadowhighlight", "moreOptions/whiteClip", "percent"),
|
||||
("ec.distort.bulge", "height", "pixels"),
|
||||
("ec.distort.bulge", "taperRadius", ""),
|
||||
("ec.distort.ccbendit", "bend", "pixels"),
|
||||
("ec.distort.ccbender", "amount", "pixels"),
|
||||
("ec.distort.ccgriddler", "tileSize", "percent"),
|
||||
("ec.distort.cctiler", "scale", "percent"),
|
||||
("ec.distort.upscale", "scale", "percent"),
|
||||
("ec.distort.magnify", "magnification", "pixels"),
|
||||
("ec.distort.magnify", "size", "percent"),
|
||||
("ec.distort.puppet", "refinement", ""),
|
||||
("ec.distort.ripple", "radius", ""),
|
||||
("ec.distort.ripple", "speed", "percent"),
|
||||
("ec.distort.transform", "opacity", ""),
|
||||
("ec.distort.turbulentdisplace", "complexity", "percent"),
|
||||
("ec.distort.twirl", "radius", ""),
|
||||
("ec.distort.wavewarp", "width", "percent"),
|
||||
("ec.distort.wavewarp", "speed", "pixels"),
|
||||
("ec.generate.fourcolor", "blend", "percent"),
|
||||
("ec.generate.advancedlightning", "coreSettings/coreRadius", ""),
|
||||
("ec.generate.advancedlightning", "glowSettings/glowRadius", "percent"),
|
||||
("ec.generate.advancedlightning", "alphaObstacle", "pixels"),
|
||||
("ec.generate.ccgluegun", "reflection", ""),
|
||||
("ec.generate.cclightburst", "rayLength", ""),
|
||||
("ec.generate.cclightsweep", "edgeThickness", ""),
|
||||
("ec.generate.ccthreads", "coverage", ""),
|
||||
("ec.generate.ccthreads", "shadowing", ""),
|
||||
("ec.generate.ccthreads", "texture", ""),
|
||||
("ec.generate.checkerboard", "width", "percent"),
|
||||
("ec.generate.checkerboard", "height", "percent"),
|
||||
("ec.generate.grid", "width", "percent"),
|
||||
("ec.generate.grid", "height", "percent"),
|
||||
("ec.generate.lensflare", "flareBrightness", "percent"),
|
||||
("ec.vr.chromaticaberrations", "falloff", ""),
|
||||
("ec.vr.denoise", "noiseLevel", ""),
|
||||
("ec.vr.digitalglitch", "colorDistortion", ""),
|
||||
("ec.vr.fractalnoise", "subSettings/subInfluence", ""),
|
||||
("ec.vr.glow", "luminanceThreshold", ""),
|
||||
("ec.vr.planetosphere", "feather", ""),
|
||||
("ec.vr.sharpen", "sharpenAmount", ""),
|
||||
("ec.key.advancedspill", "suppression", "percent"),
|
||||
("ec.key.advancedspill", "ultraSettings/tolerance", ""),
|
||||
("ec.key.advancedspill", "ultraSettings/desaturate", ""),
|
||||
("ec.key.advancedspill", "ultraSettings/spillRange", ""),
|
||||
("ec.key.advancedspill", "ultraSettings/spillColorCorrection", ""),
|
||||
("ec.key.advancedspill", "ultraSettings/lumaCorrection", ""),
|
||||
("ec.key.ccsimplewireremoval", "thickness", ""),
|
||||
("ec.key.keycleaner", "additionalEdgeRadius", ""),
|
||||
("ec.key.keycleaner", "alphaContrast", "percent"),
|
||||
("ec.matte.refinehard", "feather", ""),
|
||||
("ec.matte.refinehard", "increaseDecontaminationRadius", ""),
|
||||
("ec.matte.refinesoft", "smooth", ""),
|
||||
("ec.matte.refinesoft", "feather", ""),
|
||||
("ec.matte.refinesoft", "shiftEdge", "percent"),
|
||||
("ec.matte.refinesoft", "decontamination/increaseDecontaminationRadius", ""),
|
||||
("ec.matte.rotobrush", "rotoBrushPropagation/searchRadius", ""),
|
||||
("ec.matte.rotobrush", "rotoBrushMatte/feather", ""),
|
||||
("ec.matte.rotobrush", "rotoBrushMatte/shiftEdge", "percent"),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/smooth", ""),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/feather", ""),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/shiftEdge", "percent"),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/decontamination/increaseDecontaminationRadius", ""),
|
||||
("ec.matte.simplechoker", "chokeMatte", "pixels"),
|
||||
("ec.noise.fractal", "transform/scale", "percent"),
|
||||
("ec.noise.fractal", "transform/scaleWidth", "percent"),
|
||||
("ec.noise.fractal", "transform/scaleHeight", "percent"),
|
||||
("ec.noise.fractal", "complexity", "percent"),
|
||||
("ec.noise.fractal", "subSettings/subInfluence", ""),
|
||||
("ec.noise.turbulent", "transform/scale", "percent"),
|
||||
("ec.noise.turbulent", "transform/scaleWidth", "percent"),
|
||||
("ec.noise.turbulent", "transform/scaleHeight", "percent"),
|
||||
("ec.noise.turbulent", "complexity", "percent"),
|
||||
("ec.noise.turbulent", "subSettings/subInfluence", ""),
|
||||
("ec.key.colorkey", "edgeFeather", ""),
|
||||
("ec.obsolete.lightning", "branchAngle", ""),
|
||||
("ec.key.luma", "edgeFeather", ""),
|
||||
("ec.blur.reduceflicker", "softness", ""),
|
||||
("ec.perspective.cameratracker", "trackPointSize", "percent"),
|
||||
("ec.perspective.cameratracker", "targetSize", "percent"),
|
||||
("ec.perspective.cccylinder", "lightHeight", "pixels"),
|
||||
("ec.sim.ccbubbles", "bubbleSize", "percent"),
|
||||
("ec.sim.ccdrizzle", "displacement", ""),
|
||||
("ec.sim.ccdrizzle", "light/lightHeight", "pixels"),
|
||||
("ec.sim.ccmrmercury", "longevity", ""),
|
||||
("ec.sim.ccmrmercury", "blobInfluence", "percent"),
|
||||
("ec.sim.ccmrmercury", "light/lightHeight", "pixels"),
|
||||
("ec.sim.ccmrmercury", "shading/metal", ""),
|
||||
("ec.sim.ccparticlesystems2", "birthRate", ""),
|
||||
("ec.sim.ccparticlesystems2", "longevity", ""),
|
||||
("ec.sim.ccparticlesystems2", "physics/gravity", "pixels"),
|
||||
("ec.sim.ccrainfall", "windVariation", ""),
|
||||
("ec.sim.ccrainfall", "opacity", ""),
|
||||
("ec.sim.ccsnowfall", "sizeVariation", ""),
|
||||
("ec.sim.ccsnowfall", "speedVariation", ""),
|
||||
("ec.sim.ccsnowfall", "windVariation", ""),
|
||||
("ec.sim.ccsnowfall", "wiggle/wiggleAmount", ""),
|
||||
("ec.sim.ccsnowfall", "wiggle/wiggleAmountVariation", ""),
|
||||
("ec.sim.ccsnowfall", "wiggle/wiggleFrequencyVariation", ""),
|
||||
("ec.sim.ccsnowfall", "opacity", ""),
|
||||
("ec.sim.ccstarburst", "speed", "percent"),
|
||||
("ec.sim.ccstarburst", "size", "pixels"),
|
||||
("ec.stylize.ccblockload", "completion", ""),
|
||||
("ec.stylize.ccburnfilm", "burn", ""),
|
||||
("ec.stylize.cchextile", "smearing", ""),
|
||||
("ec.stylize.cckaleida", "size", "percent"),
|
||||
("ec.stylize.ccvignette", "pinHighlights", ""),
|
||||
("ec.stylize.cartoon", "detailThreshold", ""),
|
||||
("ec.stylize.cartoon", "fill/shadingSmoothness", ""),
|
||||
("ec.stylize.cartoon", "edge/edgeSoftness", ""),
|
||||
("ec.stylize.cartoon", "advanced/edgeBlackLevel", ""),
|
||||
("ec.stylize.glow", "colorLoops", "percent"),
|
||||
("ec.stylize.roughenedges", "stretch", "pixels"),
|
||||
("ec.time.pixelmotionblur", "vectorDetail", ""),
|
||||
("ec.transition.blockdissolve", "blockWidth", "percent"),
|
||||
("ec.transition.blockdissolve", "blockHeight", "percent"),
|
||||
("ec.transition.ccgridwipe", "border", "pixels"),
|
||||
("ec.transition.ccgridwipe", "tiles", "percent"),
|
||||
("ec.transition.cclinesweep", "completion", ""),
|
||||
("ec.transition.ccscalewipe", "stretch", "pixels"),
|
||||
("ec.transition.venetian", "width", "percent"),
|
||||
("ec.transition.venetian", "feather", ""),
|
||||
("ec.utility.cineon", "highlightRolloff", ""),
|
||||
("ec.utility.facemeasurements", "headOrientationX", "degrees"),
|
||||
("ec.utility.facemeasurements", "headOrientationY", "degrees"),
|
||||
("ec.utility.facemeasurements", "headOrientationZ", "degrees"),
|
||||
("ec.utility.facemeasurements", "leftEyebrowRaise", "percent"),
|
||||
("ec.utility.facemeasurements", "rightEyebrowRaise", "percent"),
|
||||
("ec.utility.facemeasurements", "mouthOffsetX", "percent"),
|
||||
("ec.utility.facemeasurements", "mouthOffsetY", "percent"),
|
||||
];
|
||||
@@ -70,6 +70,7 @@ pub use home::templates;
|
||||
pub use interchange::psd_import;
|
||||
pub use interchange::vector;
|
||||
pub use interchange::xml_project;
|
||||
use interchange::{keyframe_text, keyframe_text_effects};
|
||||
pub use render_queue::{ExportJob, ExportResult, Exporter, JobState};
|
||||
pub use settings::autosave;
|
||||
pub use settings::config;
|
||||
|
||||
@@ -82,7 +82,8 @@ fn holder_of(s: &Session, keys: &str) -> Option<String> {
|
||||
/// Recent Script Files), `history` (undo steps, newest
|
||||
/// first), `view3dShortcuts`, `workspaceShortcuts`, `savedWorkspaces` (Window ▸ Workspace) and
|
||||
/// `openViewers` (open compositions other than the active one), `scripts` (File ▸ Scripts) and
|
||||
/// `scriptPanels` (ScriptUI panels).
|
||||
/// `scriptPanels` (ScriptUI panels; a script in a subfolder is labelled `Folder/File.jsx` here,
|
||||
/// and [`dynamic_nodes`] puts it in the folder's submenu).
|
||||
pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<&'static str>) {
|
||||
use serde_json::json;
|
||||
let indexed = |list: &[String], cmd: &str, stem: bool| -> Vec<MenuEntry> {
|
||||
@@ -167,6 +168,31 @@ pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<
|
||||
}
|
||||
}
|
||||
|
||||
/// [`dynamic`] as menu nodes, which is what frontends draw: scripts and panels in a subfolder
|
||||
/// (`Motion Kit/Animate.jsx`) go into a submenu named after the folder (`Motion Kit ▸
|
||||
/// Animate.jsx`), placed where the folder sorts among the other entries.
|
||||
pub fn dynamic_nodes(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuNode>, Option<&'static str>) {
|
||||
let (entries, empty) = dynamic(s, name, cx);
|
||||
let scripts = matches!(name, "scripts" | "scriptPanels");
|
||||
let mut out: Vec<MenuNode> = vec![];
|
||||
for mut e in entries {
|
||||
let Some((folder, file)) = e.label.split_once('/').filter(|_| scripts).map(|(d, f)| (d.to_string(), f.to_string())) else {
|
||||
out.push(MenuNode::Item(e));
|
||||
continue;
|
||||
};
|
||||
e.label = file;
|
||||
let open = out.iter_mut().find_map(|n| match n {
|
||||
MenuNode::Submenu { label, children } if *label == folder => Some(children),
|
||||
_ => None,
|
||||
});
|
||||
match open {
|
||||
Some(children) => children.push(MenuNode::Item(e)),
|
||||
None => out.push(MenuNode::Submenu { label: folder, children: vec![MenuNode::Item(e)] }),
|
||||
}
|
||||
}
|
||||
(out, empty)
|
||||
}
|
||||
|
||||
/// Display label of a submenu (the "Assign Shortcut to …" submenus name the current view or
|
||||
/// workspace, like After Effects).
|
||||
pub fn submenu_label(s: &Session, label: &str, cx: &DynCtx) -> String {
|
||||
|
||||
@@ -208,6 +208,16 @@ pub fn bytes_per_frame(om: &OutputModule, w: u32, h: u32, fps: f64) -> f64 {
|
||||
let bpp = if om.channels == effectcraft_project::render_queue::Channels::Rgba { 4 } else { 3 };
|
||||
((w as u64 * bpp).next_multiple_of(2) * h as u64) as f64
|
||||
}
|
||||
// An 18-byte header and 24-bit BGR, 32-bit BGRA or 8-bit grey (Alpha) pixels, uncompressed.
|
||||
OutputFormat::TargaSequence => {
|
||||
use effectcraft_project::render_queue::Channels;
|
||||
let bpp = match om.channels {
|
||||
Channels::Rgb => 3,
|
||||
Channels::Rgba => 4,
|
||||
Channels::Alpha => 1,
|
||||
};
|
||||
(18 + w as u64 * h as u64 * bpp) as f64
|
||||
}
|
||||
OutputFormat::WebM => mbps(8.0) * px / fps,
|
||||
OutputFormat::Gif => w as f64 * h as f64 * 0.5,
|
||||
OutputFormat::Wav | OutputFormat::Aiff => om.audio_sample_rate as f64 * 4.0 / fps,
|
||||
|
||||
@@ -116,6 +116,25 @@ fn persists_in_project_file() {
|
||||
assert!(effectcraft_project::Project::from_json(&old).unwrap().render_queue.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targa_sequence_output_module() {
|
||||
let (mut s, _) = rq_session();
|
||||
let a = s.execute("renderQueue.add", json!({"format": "tga", "output": "/out/[compName]_[#####].tga", "channels": "rgba"})).unwrap();
|
||||
let om = &s.project.render_queue[0].output;
|
||||
assert_eq!((format!("{:?}", om.format), om.channels.label()), ("TargaSequence".to_string(), "RGB + Alpha"));
|
||||
assert!(a["outputPath"].as_str().unwrap().ends_with("_[#####].tga"), "{a}");
|
||||
// Uncompressed: an 18-byte header and 4 bytes a pixel with alpha, 3 without.
|
||||
assert_eq!(crate::render_queue::bytes_per_frame(om, 7, 4, 10.0), 18.0 + 7.0 * 4.0 * 4.0);
|
||||
s.execute("renderQueue.setOutputModule", json!({"index": 1, "channels": "rgb"})).unwrap();
|
||||
assert_eq!(crate::render_queue::bytes_per_frame(&s.project.render_queue[0].output, 7, 4, 10.0), 18.0 + 7.0 * 4.0 * 3.0);
|
||||
let f = s.execute("renderQueue.formats", json!({})).unwrap();
|
||||
let tga = f["formats"].as_array().unwrap().iter().find(|f| f["id"] == "TargaSequence").cloned();
|
||||
assert_eq!(
|
||||
tga,
|
||||
Some(json!({"id": "TargaSequence", "label": "Targa Sequence", "extension": "tga", "sequence": true, "alpha": true, "audio": false, "available": true}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_toast_names_the_first_reason() {
|
||||
struct Failing;
|
||||
@@ -186,7 +205,7 @@ fn without_exporter() {
|
||||
s.execute("renderQueue.add", json!({})).unwrap();
|
||||
assert!(!s.is_enabled("renderQueue.render"));
|
||||
let f = s.execute("renderQueue.formats", json!({})).unwrap();
|
||||
assert_eq!(f["formats"].as_array().unwrap().len(), 13);
|
||||
assert_eq!(f["formats"].as_array().unwrap().len(), 14);
|
||||
assert_eq!(f["formats"][0]["available"], false);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,8 +50,20 @@ pub trait Services: Send + Sync {
|
||||
pub struct FsServices;
|
||||
|
||||
impl Services for FsServices {
|
||||
/// Refuses what isn't a regular file: a project can name `/dev/zero` or a FIFO, and reading
|
||||
/// those never ends (#2134).
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
||||
std::fs::read(path)
|
||||
use std::io::Read;
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
let meta = file.metadata()?;
|
||||
if !meta.is_file() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{path} is not a file")));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
// A sparse file can claim far more than memory: an error, not an abort.
|
||||
bytes.try_reserve_exact(usize::try_from(meta.len()).unwrap_or(usize::MAX)).map_err(|e| std::io::Error::new(std::io::ErrorKind::OutOfMemory, e))?;
|
||||
file.read_to_end(&mut bytes)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
config::atomic_write(std::path::Path::new(path), data)
|
||||
@@ -70,3 +82,21 @@ pub trait Importer: Send + Sync {
|
||||
/// media layer reads from memory (the browser) register them; file-based ones need nothing.
|
||||
fn add_frames(&self, _paths: &[String]) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{FsServices, Services};
|
||||
|
||||
/// #2134: a project can name a folder, `/dev/zero` or a FIFO; reading those is an error.
|
||||
#[test]
|
||||
fn fs_services_read_only_regular_files() {
|
||||
let dir = std::env::temp_dir();
|
||||
assert!(FsServices.read_file(&dir.to_string_lossy()).is_err());
|
||||
#[cfg(unix)]
|
||||
assert!(FsServices.read_file("/dev/zero").is_err());
|
||||
let f = dir.join(format!("ec-services-read-{}.txt", std::process::id()));
|
||||
std::fs::write(&f, b"data").unwrap();
|
||||
assert_eq!(FsServices.read_file(&f.to_string_lossy()).unwrap(), b"data");
|
||||
let _ = std::fs::remove_file(f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,9 @@ pub trait ConfigStore: Send + Sync {
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
/// Write entry `name`, replacing any earlier text.
|
||||
fn write(&self, name: &str, data: &str) -> std::io::Result<()>;
|
||||
/// Remove entry `name`; a missing entry is no error.
|
||||
fn remove(&self, name: &str) -> std::io::Result<()>;
|
||||
/// Names of the entries directly in `dir` (a `/`-separated name prefix such as `Scripts`),
|
||||
/// sorted. Stores that can't list return nothing.
|
||||
@@ -32,6 +34,11 @@ pub trait ConfigStore: Send + Sync {
|
||||
let _ = dir;
|
||||
vec![]
|
||||
}
|
||||
/// Names of the folders directly in `dir`, sorted, like [`ConfigStore::list`].
|
||||
fn list_dirs(&self, dir: &str) -> Vec<String> {
|
||||
let _ = dir;
|
||||
vec![]
|
||||
}
|
||||
/// The folder next to the configuration (default Auto-Save folder for untitled projects),
|
||||
/// in the namespace of [`ConfigStore::files`]; `None` when there is none.
|
||||
fn dir(&self) -> Option<PathBuf> {
|
||||
@@ -107,6 +114,15 @@ impl ConfigStore for MemoryConfig {
|
||||
let Ok(m) = self.files.lock() else { return vec![] };
|
||||
m.keys().filter_map(|k| k.strip_prefix(&pre)).filter(|n| !n.is_empty() && !n.contains('/')).map(str::to_string).collect()
|
||||
}
|
||||
/// The first name part of the keys under `dir/`.
|
||||
fn list_dirs(&self, dir: &str) -> Vec<String> {
|
||||
let pre = format!("{}/", dir.trim_end_matches('/'));
|
||||
let Ok(m) = self.files.lock() else { return vec![] };
|
||||
let mut v: Vec<String> = m.keys().filter_map(|k| k.strip_prefix(&pre)?.split_once('/')).map(|(d, _)| d.to_string()).filter(|d| !d.is_empty()).collect();
|
||||
v.sort();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// Files in one directory, written atomically.
|
||||
@@ -153,6 +169,13 @@ impl ConfigStore for DirConfig {
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
/// The folders in `dir`, following links like [`ConfigStore::list`] does for files.
|
||||
fn list_dirs(&self, dir: &str) -> Vec<String> {
|
||||
let Ok(rd) = std::fs::read_dir(self.dir.join(dir)) else { return vec![] };
|
||||
let mut v: Vec<String> = rd.flatten().filter(|e| e.path().is_dir()).map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
fn remove(&self, name: &str) -> std::io::Result<()> {
|
||||
match std::fs::remove_file(self.dir.join(name)) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
|
||||
|
||||
@@ -122,6 +122,11 @@ pub fn pages() -> Vec<Page> {
|
||||
Section("New Project"),
|
||||
s("project.useTemplate", "New Project Loads Template", B, true),
|
||||
s("project.templatePath", "Template Project", Kind::Path, true),
|
||||
Section("Project Files"),
|
||||
s("project.compactProjectFiles", "Save Compact Project Files", B, true),
|
||||
Note(
|
||||
"Saves .ecproj files as compact JSON on one line: smaller and faster to save and open, but hard to read or diff in version control. Off, they are indented, one value per line.",
|
||||
),
|
||||
Section("Auto-Save"),
|
||||
s("autoSave.enabled", "Automatically Save Projects", B, true),
|
||||
s("autoSave.intervalMinutes", "Save Every", Kind::Int(1, 240, "Minutes"), true),
|
||||
|
||||
@@ -62,6 +62,9 @@ page!(ProjectPrefs {
|
||||
/// Open a template project for File ▸ New ▸ New Project.
|
||||
use_template: bool = false,
|
||||
template_path: String = String::new(),
|
||||
/// Write `.ecproj` files as compact JSON on one line (smaller, faster to save and open). Off:
|
||||
/// indented, one value per line, so they read and diff cleanly in version control.
|
||||
compact_project_files: bool = false,
|
||||
});
|
||||
|
||||
page!(AutoSave {
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
//! terminal, so that file is what a bug report can attach. The last few hundred warnings and
|
||||
//! errors are also kept in memory for the System Compatibility Report.
|
||||
//!
|
||||
//! Levels: `info` for EffectCraft's own crates, `warn` for everything else (wgpu and naga are
|
||||
//! chatty). `RUST_LOG` replaces that (a level such as `debug`, or per target:
|
||||
//! Levels by default ([`DEFAULT_FILTER`]): `info` for EffectCraft's own crates, `warn` for
|
||||
//! everything else (wgpu and naga are chatty). `RUST_LOG` replaces that (a level such as `debug`, or per target:
|
||||
//! `effectcraft=debug,wgpu=warn`).
|
||||
//!
|
||||
//! Help ▸ Enable Logging ([`set_enabled`], the `help.enableLogging` command) additionally appends
|
||||
//! every EffectCraft record to `Logs/EffectCraft Log.txt`, across launches.
|
||||
//!
|
||||
//! Frontends call [`install`] once at startup and the desktop app [`attach_session_log`] once the
|
||||
//! settings folder is known; records logged before that are kept (up to [`MAX_PENDING`]) and
|
||||
//! Frontends call [`install`] (or [`install_with`] for another default, as the CLI does with `warn`)
|
||||
//! once at startup and the desktop app [`attach_session_log`] once the settings folder is known; records logged before that are kept (up to [`MAX_PENDING`]) and
|
||||
//! written first. Writing never panics: a file that can't be created or written leaves standard
|
||||
//! error as the only sink. Never log the control-channel token.
|
||||
|
||||
@@ -131,11 +131,21 @@ fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message:
|
||||
|
||||
/// Install the logger (once; later calls and other installed loggers are left alone).
|
||||
pub fn install() {
|
||||
install_with(DEFAULT_FILTER);
|
||||
}
|
||||
|
||||
/// The directive string: `RUST_LOG` when it is set and nonblank, otherwise `default`.
|
||||
fn filter_spec<'a>(rust_log: Option<&'a str>, default: &'a str) -> &'a str {
|
||||
rust_log.filter(|s| !s.trim().is_empty()).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// [`install`] with `default` as the filter when `RUST_LOG` is unset or empty.
|
||||
pub fn install_with(default: &str) {
|
||||
static LOGGER: Logger = Logger;
|
||||
if log::set_logger(&LOGGER).is_ok() {
|
||||
let filter = FILTER.get_or_init(|| {
|
||||
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
|
||||
parse_filter(spec.as_deref().unwrap_or(DEFAULT_FILTER))
|
||||
let spec = std::env::var("RUST_LOG").ok();
|
||||
parse_filter(filter_spec(spec.as_deref(), default))
|
||||
});
|
||||
log::set_max_level(filter.iter().map(|d| d.1).fold(LevelFilter::Info, Ord::max));
|
||||
}
|
||||
@@ -357,6 +367,21 @@ mod tests {
|
||||
assert_eq!(level_for(&f, "naga"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
/// The CLI's default (`warn`): warnings from every crate, no info chatter on stderr,
|
||||
/// while a nonblank `RUST_LOG` overrides `default`.
|
||||
#[test]
|
||||
fn the_cli_filter_shows_only_warnings() {
|
||||
assert_eq!(filter_spec(None, "warn"), "warn");
|
||||
assert_eq!(filter_spec(Some(" "), "warn"), "warn");
|
||||
assert_eq!(filter_spec(Some("info"), "warn"), "info");
|
||||
let f = parse_filter(filter_spec(None, "warn"));
|
||||
assert_eq!(level_for(&f, "effectcraft_gpu::context"), LevelFilter::Warn);
|
||||
assert_eq!(level_for(&f, "effectcraft_engine::render_queue"), LevelFilter::Warn);
|
||||
assert_eq!(level_for(&f, "wgpu_core::device"), LevelFilter::Warn);
|
||||
let over = parse_filter(filter_spec(Some("info"), "warn"));
|
||||
assert_eq!(level_for(&over, "effectcraft_gpu::context"), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(0), "1970-01-01T00:00:00.000Z");
|
||||
|
||||
@@ -15,8 +15,10 @@ mod frame_export;
|
||||
mod fuzz_open;
|
||||
mod history;
|
||||
mod import_project;
|
||||
mod instance3d;
|
||||
mod key_metadata_order;
|
||||
mod key_time;
|
||||
mod keyframe_text;
|
||||
mod keylight;
|
||||
mod limits;
|
||||
mod lottie;
|
||||
|
||||
@@ -78,7 +78,7 @@ fn transition_preset_by_name_and_bad_input() {
|
||||
#[test]
|
||||
fn preset_catalogue_is_unique_and_categorised() {
|
||||
let all = crate::animation_presets();
|
||||
let known = ["Transitions", "Backgrounds"];
|
||||
let known = ["Transitions", "Backgrounds", "Behaviors"];
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
let mut names = std::collections::HashSet::new();
|
||||
for (id, name, cat) in &all {
|
||||
@@ -90,7 +90,7 @@ fn preset_catalogue_is_unique_and_categorised() {
|
||||
assert!(!ids.contains(&id.to_lowercase()) && !names.contains(&name.to_lowercase()), "text preset {id} clashes");
|
||||
}
|
||||
let n = |c: &str| all.iter().filter(|(_, _, k)| k == c).count();
|
||||
assert!(n("Backgrounds") >= 15 && n("Transitions") >= 20);
|
||||
assert!(n("Backgrounds") >= 15 && n("Transitions") >= 20 && n("Behaviors") >= 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -126,3 +126,34 @@ fn preset_expressions_and_point_fractions() {
|
||||
assert_eq!(ramp.prop("end").unwrap().value, effectcraft_keyframe::Value::Vec2([80.0, 90.0]));
|
||||
assert_eq!(ramp.prop("startColor").unwrap().keys.len(), 2);
|
||||
}
|
||||
|
||||
/// #1510: Behaviors presets add no effects; each puts an enabled expression on a transform
|
||||
/// property, and undo removes it again.
|
||||
#[test]
|
||||
fn every_behavior_preset_applies_and_undoes() {
|
||||
let (mut s, solid) = session();
|
||||
let list = s.execute("anim.builtinPresets", json!({"category": "Behaviors"})).unwrap();
|
||||
let list = list.as_array().unwrap().clone();
|
||||
assert!(list.len() >= 20, "{}", list.len());
|
||||
let driven = |s: &Session| {
|
||||
let l = &s.active_comp().unwrap().layers[0];
|
||||
["transform/position", "transform/scale", "transform/rotation", "transform/opacity"]
|
||||
.iter()
|
||||
.filter(|p| l.props.prop(p).is_some_and(|pr| pr.has_expression()))
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for p in &list {
|
||||
let id = p["id"].as_str().unwrap();
|
||||
let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": id})).unwrap();
|
||||
assert_eq!(r["skipped"], json!([]), "{id}: every path and expression applies");
|
||||
assert_eq!(r["effects"], json!([]), "{id}: behaviors add no effects");
|
||||
let applied = driven(&s);
|
||||
assert_eq!(applied.len(), 1, "{id}: exactly one property is driven");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(driven(&s).is_empty(), "{id}: undo removes the expression");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(driven(&s), applied, "{id}: redo restores the expression");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,16 @@ fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (Strin
|
||||
s.execute_checked("prop.set", json!({"path": format!("effects/#{fx}/matteOnly"), "value": true})).unwrap();
|
||||
let (cid, comp) = (s.active_comp_id().unwrap(), s.active_comp().unwrap());
|
||||
let layer = &comp.layers[0];
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None };
|
||||
let ctx = effectcraft_render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: Tick::ZERO,
|
||||
expr: None,
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let buf = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).content_buf(&ctx, layer).unwrap();
|
||||
let (px, py) = buf.to_px([x, y]);
|
||||
let m = buf.img.get(px.floor() as i64, py.floor() as i64)[0];
|
||||
|
||||
@@ -136,3 +136,62 @@ fn footage_changes_change_the_salt() {
|
||||
assert_ne!(k3, k2);
|
||||
assert_ne!(set_proxy(&mut s, false), k3);
|
||||
}
|
||||
|
||||
/// Marker edits keep the disk key (the sound of a playing preview must not wait for frames to
|
||||
/// render again, #1083); an expression that reads markers makes them count.
|
||||
#[test]
|
||||
fn markers_keep_the_content_key_unless_an_expression_reads_them() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, cid);
|
||||
let k0 = key(&s);
|
||||
s.execute("comp.addMarker", json!({"time": 0.5, "comment": "beat"})).unwrap();
|
||||
s.execute("markers.set", json!({"layer": l, "new": true, "time": 0.25})).unwrap();
|
||||
assert_eq!(key(&s), k0, "markers");
|
||||
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "thisComp.marker.numKeys"})).unwrap();
|
||||
let k1 = key(&s);
|
||||
assert_ne!(k1, k0);
|
||||
s.execute("comp.addMarker", json!({"time": 1.0})).unwrap();
|
||||
assert_ne!(key(&s), k1, "a marker read by an expression");
|
||||
}
|
||||
|
||||
/// A comp marker alone (no layer marker beside it) keeps the key too.
|
||||
#[test]
|
||||
fn a_lone_comp_marker_keeps_the_content_key() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, cid);
|
||||
let k0 = key(&s);
|
||||
s.execute("comp.addMarker", json!({"time": 0.5, "comment": "beat"})).unwrap();
|
||||
assert_eq!(key(&s), k0);
|
||||
}
|
||||
|
||||
/// A protected marker with a duration retimes a stretched precomp layer (Responsive Design), so
|
||||
/// changing it changes the key of the comp that nests it; a plain marker beside it does not.
|
||||
#[test]
|
||||
fn protected_markers_with_a_duration_change_the_content_key() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "frameRate": 30, "duration": 10})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
let l = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.select", json!({"layers": [l]})).unwrap();
|
||||
s.execute("layer.timeStretch", json!({"percent": 200})).unwrap();
|
||||
let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, outer);
|
||||
let k0 = key(&s);
|
||||
s.execute("comp.addMarker", json!({"comp": inner.0, "time": 2.0, "comment": "beat"})).unwrap();
|
||||
assert_eq!(key(&s), k0, "a plain marker");
|
||||
s.execute("comp.addMarker", json!({"comp": inner.0, "time": 1.0})).unwrap();
|
||||
let index = s.project.comp(inner).unwrap().markers.iter().position(|m| m.time.seconds() == 1.0).unwrap();
|
||||
s.execute("markers.set", json!({"comp": inner.0, "index": index, "duration": 1.0, "protected": true})).unwrap();
|
||||
let k1 = key(&s);
|
||||
assert_ne!(k1, k0, "a protected marker with a duration");
|
||||
s.execute("markers.set", json!({"comp": inner.0, "index": index, "duration": 2.0})).unwrap();
|
||||
assert_ne!(key(&s), k1, "its duration");
|
||||
}
|
||||
|
||||
@@ -772,6 +772,132 @@ fn preset_round_trips_effect_properties_by_match_name() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An animation preset saved by an earlier version gets the upgrade a project gets on open: a
|
||||
/// Change to Color saved with its single Tolerance applies with the Tolerance group, that value
|
||||
/// as its Hue and the added parameters at their legacy defaults, and a lone property saved at the
|
||||
/// old id applies to the new one.
|
||||
#[test]
|
||||
fn applying_an_old_preset_upgrades_its_effects_and_property_paths() {
|
||||
let (mut s, a, b) = comp_with_two_solids();
|
||||
apply(&mut s, a, "Change to Color");
|
||||
let mut old = layer(&s, a).effects().unwrap().groups().next().unwrap().clone();
|
||||
old.children.retain(|c| c.match_id() != "toleranceGroup");
|
||||
old.children.push(effectcraft_project::Property::new(0, "tolerance", "Tolerance", KV::Scalar(12.0)).into());
|
||||
let preset = json!({"effectcraftAnimationPreset": 1, "name": "old", "properties": [], "effects": [old]});
|
||||
let r = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [b]})).unwrap();
|
||||
assert_eq!(r["applied"], 1, "{r}");
|
||||
let l = layer(&s, b);
|
||||
let g = l.effects().unwrap().groups().next().unwrap();
|
||||
assert!(g.get("tolerance").is_none(), "the old Tolerance is still there");
|
||||
let tol = g.sub("toleranceGroup").expect("no Tolerance group");
|
||||
assert_eq!(tol.get("hue").unwrap().value, KV::Scalar(12.0));
|
||||
assert_eq!(tol.get("lightness").unwrap().value, KV::Scalar(100.0));
|
||||
assert_eq!(tol.get("saturation").unwrap().value, KV::Scalar(100.0));
|
||||
let path = "effects/ec.color.changetocolor/tolerance";
|
||||
let preset = json!({"effectcraftAnimationPreset": 1, "name": "old", "properties": [{"path": path, "value": KV::Scalar(30.0), "keys": []}], "effects": []});
|
||||
let r = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [a]})).unwrap();
|
||||
assert_eq!(r["applied"], 1, "{r}");
|
||||
assert_eq!(r["skipped"], json!([]), "{r}");
|
||||
let l = layer(&s, a);
|
||||
let hue = l.effects().unwrap().groups().next().unwrap().sub("toleranceGroup").unwrap().get("hue").unwrap().value.clone();
|
||||
assert_eq!(hue, KV::Scalar(30.0));
|
||||
}
|
||||
|
||||
/// Save the selected effects and properties as a preset, then apply it to `target` at `at`.
|
||||
fn save_and_apply_preset(s: &mut Session, name: &str, selected: Vec<(LayerId, u64)>, target: u64, at: f64) -> serde_json::Value {
|
||||
let path = std::env::temp_dir().join(format!("ec-1971-{name}-{}.ecpreset", std::process::id())).to_string_lossy().to_string();
|
||||
s.state.selected_props = selected;
|
||||
s.execute("anim.savePreset", json!({"path": path})).unwrap();
|
||||
s.execute("time.set", json!({"time": at})).unwrap();
|
||||
let r = s.execute("anim.applyPreset", json!({"path": path, "layers": [target]}));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
r.unwrap()
|
||||
}
|
||||
|
||||
/// The comp times of the keys at `path` on `lid`'s `n`th effect (0-based), or on the layer when
|
||||
/// `n` is `None`.
|
||||
fn key_times(s: &Session, lid: u64, n: Option<usize>, path: &str) -> Vec<f64> {
|
||||
let l = layer(s, lid);
|
||||
let group = match n {
|
||||
Some(n) => l.effects().unwrap().groups().nth(n).unwrap().clone(),
|
||||
None => l.props.clone(),
|
||||
};
|
||||
group.prop(path).unwrap().keys.iter().map(|k| l.comp_time(k.time).seconds()).collect()
|
||||
}
|
||||
|
||||
/// #1971: a saved effect's keys start at the playhead of the layer it's applied to, as a saved
|
||||
/// property's do, nested controls included, an effect without keys still applies, and the whole
|
||||
/// apply is one undo step.
|
||||
#[test]
|
||||
fn preset_effect_keys_start_at_the_playhead() {
|
||||
let (mut s, a, b) = comp_with_two_solids();
|
||||
let blur = apply(&mut s, a, "Gaussian Blur");
|
||||
let fractal = apply(&mut s, a, "Fractal");
|
||||
let slider = apply(&mut s, a, "Slider Control");
|
||||
animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (5.5, 30.0), (6.0, 10.0)]);
|
||||
animate_effect(&mut s, a, fractal, "mandelbrot/mandelbrotX", &[(5.5, 1.25), (7.0, 2.5)]);
|
||||
let before = layer(&s, b);
|
||||
let undo = s.history.undo.len();
|
||||
let r = save_and_apply_preset(&mut s, "effects", vec![(LayerId(a), blur), (LayerId(a), fractal), (LayerId(a), slider)], b, 0.5);
|
||||
assert_eq!(r["applied"], 3, "{r}");
|
||||
assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![0.5, 1.0, 1.5]);
|
||||
assert_eq!(key_times(&s, b, Some(1), "mandelbrot/mandelbrotX"), vec![1.0, 2.5]);
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "the whole apply is one undo step");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layer(&s, b), before);
|
||||
}
|
||||
|
||||
/// #1971: effects and properties saved together keep their relative timing: the earliest key of
|
||||
/// either starts at the playhead.
|
||||
#[test]
|
||||
fn preset_effects_and_properties_share_one_anchor() {
|
||||
let (mut s, a, b) = comp_with_two_solids();
|
||||
let blur = apply(&mut s, a, "Gaussian Blur");
|
||||
animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]);
|
||||
for (time, value) in [(4.0, -12.0), (7.0, 12.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/rotation", "time": time, "timeBase": "comp", "value": value})).unwrap();
|
||||
}
|
||||
let rotation = layer(&s, a).props.prop("transform/rotation").unwrap().uid;
|
||||
save_and_apply_preset(&mut s, "mixed", vec![(LayerId(a), blur), (LayerId(a), rotation)], b, 1.0);
|
||||
assert_eq!(key_times(&s, b, None, "transform/rotation"), vec![1.0, 4.0]);
|
||||
assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![2.0, 3.0]);
|
||||
}
|
||||
|
||||
/// #1971: on a stretched layer a saved effect's keys land where a saved property's keys do.
|
||||
#[test]
|
||||
fn preset_effect_keys_follow_a_stretched_layer_like_properties() {
|
||||
let (mut s, a, b) = comp_with_two_solids();
|
||||
let blur = apply(&mut s, a, "Gaussian Blur");
|
||||
animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]);
|
||||
for (time, value) in [(5.0, -12.0), (6.0, 12.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/rotation", "time": time, "timeBase": "comp", "value": value})).unwrap();
|
||||
}
|
||||
let rotation = layer(&s, a).props.prop("transform/rotation").unwrap().uid;
|
||||
set_layer_clock(&mut s, b, 1.0, 200.0);
|
||||
save_and_apply_preset(&mut s, "stretched", vec![(LayerId(a), blur), (LayerId(a), rotation)], b, 2.0);
|
||||
let property = key_times(&s, b, None, "transform/rotation");
|
||||
assert_eq!(property.first(), Some(&2.0), "the property's keys start at the playhead");
|
||||
assert_eq!(key_times(&s, b, Some(0), "blurriness"), property, "and the effect's keys land with them");
|
||||
}
|
||||
|
||||
/// #1971: a preset saved before effect keys were stored relative to the earliest key (version 1)
|
||||
/// has its effects' keys at the source layer's times; they also start at the playhead.
|
||||
#[test]
|
||||
fn old_preset_effect_keys_start_at_the_playhead() {
|
||||
let (mut s, a, b) = comp_with_two_solids();
|
||||
let blur = apply(&mut s, a, "Gaussian Blur");
|
||||
animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]);
|
||||
let old = json!({
|
||||
"effectcraftAnimationPreset": 1,
|
||||
"name": "shake",
|
||||
"properties": [],
|
||||
"effects": [serde_json::to_value(effect_group(&s, a, blur)).unwrap()],
|
||||
});
|
||||
s.execute("time.set", json!({"time": 0.5})).unwrap();
|
||||
s.execute("anim.applyPreset", json!({"preset": old, "layers": [b]})).unwrap();
|
||||
assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![0.5, 1.5]);
|
||||
}
|
||||
|
||||
/// #2064: `prop.addKey` accepts the `effects/<match name>[#n]/<property>` path that
|
||||
/// `essential.list` reports.
|
||||
#[test]
|
||||
|
||||
@@ -367,6 +367,128 @@ fn media_replacement_swaps_footage_per_instance() {
|
||||
assert!(center(&s, main)[0] > 0.99);
|
||||
}
|
||||
|
||||
/// #1747: a collapsed precomp with an Essential Property override must keep drawing its nested
|
||||
/// layers past the precomp's frame instead of falling back to a flattened, clipped frame.
|
||||
#[test]
|
||||
fn collapsed_precomp_applies_property_overrides_without_clipping() {
|
||||
struct AddY;
|
||||
impl effectcraft_render::ExprHost for AddY {
|
||||
fn eval(
|
||||
&self,
|
||||
_: &effectcraft_render::EvalCtx,
|
||||
_: &effectcraft_project::Layer,
|
||||
_: &effectcraft_project::Property,
|
||||
value: &effectcraft_keyframe::Value,
|
||||
) -> Result<effectcraft_keyframe::Value, String> {
|
||||
Ok(match value {
|
||||
effectcraft_keyframe::Value::Vec3([x, y, z]) => effectcraft_keyframe::Value::Vec3([*x, y + 100.0, *z]),
|
||||
_ => value.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
let box_ = s.execute("layer.newSolid", json!({"name": "Box", "color": "#ffffff", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": box_, "path": "transform/position", "value": [200, 50, 0]})).unwrap();
|
||||
// The source property's expression still runs after the instance override.
|
||||
s.execute("prop.setExpression", json!({"layer": box_, "path": "transform/position", "expression": "value + [0, 100, 0]"})).unwrap();
|
||||
let position = s.execute("essential.addProperty", json!({"layer": box_, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
let other = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [instance, other], "switch": "collapse", "value": true})).unwrap();
|
||||
s.expr = Some(std::sync::Arc::new(AddY));
|
||||
s.execute("layer.select", json!({"layers": [instance]})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -120, 0]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [other]})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": other, "control": position, "value": [200, 220, 0]})).unwrap();
|
||||
let img = s.render(outer, Tick::ZERO, RenderOpts::default());
|
||||
// The expression adds 100 px: the overrides give inner y = -20 and 320. The first maps to
|
||||
// outer y = 130 (the precomp is centred at 300) and its 100 px box spans 80..179, 70 px above
|
||||
// the instance's frame (150..449), where a flattened precomp would stop. The second maps to
|
||||
// 470 and spans 420..519, past the bottom.
|
||||
assert!(img.get(200, 100)[3] > 0.99, "the override draws above the nested frame: {:?}", img.get(200, 100));
|
||||
assert!(img.get(200, 90)[3] > 0.99, "the box reaches the top of its overridden bounds");
|
||||
assert!(img.get(200, 500)[3] > 0.99, "the other instance keeps its own override: {:?}", img.get(200, 500));
|
||||
assert!(img.get(200, 200)[3] < 0.01, "nothing is left between the instances");
|
||||
}
|
||||
|
||||
/// #1747: two collapsed instances of the same comp keep their own source-affecting property
|
||||
/// overrides in the layer cache (the nested layers' ids and static values are identical).
|
||||
#[test]
|
||||
fn collapsed_instances_keep_distinct_property_overrides_in_the_cache() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let card = s.active_comp_id().unwrap();
|
||||
let solid = s.execute("layer.newSolid", json!({"name": "BG", "color": "#ffffff", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("effect.apply", json!({"layer": solid, "effect": "Fill"})).unwrap();
|
||||
let color = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let main = s.active_comp_id().unwrap();
|
||||
let a = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "collapse", "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [60, 50, 0]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [140, 50, 0]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [a]})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": a, "control": color, "value": [1.0, 0.0, 0.0, 1.0]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": b, "control": color, "value": [0.0, 0.0, 1.0, 1.0]})).unwrap();
|
||||
let img = s.render(main, Tick::ZERO, RenderOpts::default());
|
||||
let left = img.get(60, 50);
|
||||
let right = img.get(140, 50);
|
||||
assert!(left[0] > 0.99 && left[2] < 0.01, "left instance is red: {left:?}");
|
||||
assert!(right[2] > 0.99 && right[0] < 0.01, "right instance is blue: {right:?}");
|
||||
}
|
||||
|
||||
/// #1747 with Media Replacement: the replacement footage and the position override both apply
|
||||
/// while the precomp stays collapsed, so the box is blue and still draws above the frame.
|
||||
#[test]
|
||||
fn collapsed_precomp_applies_media_replacement_without_clipping() {
|
||||
struct Colors;
|
||||
impl crate::render::FootageSource for Colors {
|
||||
fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option<std::sync::Arc<effectcraft_raster::Image>> {
|
||||
let c = if f.path.contains("blue") { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] };
|
||||
Some(std::sync::Arc::new(effectcraft_raster::Image::filled(100, 100, c)))
|
||||
}
|
||||
}
|
||||
let mut s = Session { footage: std::sync::Arc::new(Colors), ..Default::default() };
|
||||
let footage = |path: &str| effectcraft_project::Footage {
|
||||
path: path.into(),
|
||||
kind: effectcraft_project::FootageKind::Still,
|
||||
width: 100,
|
||||
height: 100,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
};
|
||||
let (red, blue) = s
|
||||
.edit("test", None, |proj, _| {
|
||||
let red = proj.add_item("red.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("red.png")));
|
||||
let blue = proj.add_item("blue.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("blue.png")));
|
||||
Ok((red, blue))
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
let clip = s.execute("layer.addItem", json!({"item": red.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": clip, "path": "transform/position", "value": [200, 50, 0]})).unwrap();
|
||||
let media = s.execute("essential.addMedia", json!({"layer": clip})).unwrap()["control"].as_u64().unwrap();
|
||||
let position = s.execute("essential.addProperty", json!({"layer": clip, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [instance], "switch": "collapse", "value": true})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [instance]})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -20, 0]})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": instance, "control": media, "item": blue.0})).unwrap();
|
||||
let img = s.render(outer, Tick::ZERO, RenderOpts::default());
|
||||
let above = img.get(200, 100);
|
||||
assert!(above[2] > 0.99 && above[0] < 0.01, "the replacement media is blue above the frame: {above:?}");
|
||||
assert!(img.get(200, 200)[3] < 0.01, "the collapsed box is not clipped at the nested frame");
|
||||
}
|
||||
|
||||
/// Responsive Design — Time: stretching a precomp keeps its protected regions at their
|
||||
/// original speed; the unprotected parts absorb the stretch.
|
||||
#[test]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::essential::{self, ControlType};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Event, Session};
|
||||
@@ -102,3 +103,33 @@ fn open_in_essential_graphics_sets_the_primary_comp() {
|
||||
);
|
||||
assert_eq!(s.execute("essential.list", json!({})).unwrap()["name"], "A");
|
||||
}
|
||||
|
||||
/// #1747: through a collapsed precomp, a Text override and a Font override of the same Source
|
||||
/// Text both apply: the Font merges into the overridden text, as `essential::with_overrides`
|
||||
/// does on the flattened path, instead of one entry replacing the other.
|
||||
#[test]
|
||||
fn collapsed_instance_merges_text_and_font_overrides() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Title", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let title = s.active_comp_id().unwrap();
|
||||
let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let body = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
let font = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let main = s.active_comp_id().unwrap();
|
||||
let inst = s.execute("layer.addItem", json!({"item": title.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [inst], "switch": "collapse", "value": true})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": inst, "control": body, "value": "Bye"})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": inst, "control": font, "value": {"font": "Serif Test", "size": 40}})).unwrap();
|
||||
// Evaluate the nested text in the collapsed instance's context, as the renderer does.
|
||||
let r = effectcraft_render::Renderer::new(&s.project, &*s.footage, crate::render::RenderOpts::default());
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, main, s.project.comp(main).unwrap(), Tick::ZERO);
|
||||
let layer = ctx.comp.layer(effectcraft_project::LayerId(inst)).unwrap();
|
||||
let collapsed = r.collapsed(&ctx, layer).expect("the instance collapses");
|
||||
let (_, nctx) = r.collapse_into(&ctx, layer, &collapsed, 1.0).expect("a nested context");
|
||||
let src = nctx.comp.layer(effectcraft_project::LayerId(text)).unwrap();
|
||||
match nctx.value(src, src.props.prop("text/sourceText").unwrap()) {
|
||||
KV::Text(d) => assert_eq!((d.text.as_str(), d.font.as_str(), d.size), ("Bye", "Serif Test", 40.0)),
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,3 +164,69 @@ fn copy_frame_hands_the_rendered_frame_to_the_frontend() {
|
||||
assert!(Session::default().execute("comp.copyFrame", json!({})).is_err());
|
||||
assert!(crate::menus::entries().iter().any(|(path, e)| e.command == "comp.copyFrame" && path.first().map(String::as_str) == Some("Composition")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_frame_as_png_preserves_alpha_channel() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "AlphaComp", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap();
|
||||
let red = s.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000", "width": 16, "height": 16})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": red, "path": "transform/position", "value": [8, 8, 0]})).unwrap();
|
||||
let dir = out_dir("frame-png-alpha");
|
||||
let path = dir.join("alpha.png").to_string_lossy().to_string();
|
||||
|
||||
s.execute("comp.saveFrameAs", json!({"path": path})).unwrap();
|
||||
let img = image::load_from_memory(&std::fs::read(&path).unwrap()).unwrap().to_rgba8();
|
||||
assert_eq!((img.width(), img.height()), (32, 32));
|
||||
let solid_px = img.get_pixel(8, 8);
|
||||
assert_eq!(solid_px[3], 255, "solid region must be opaque: {solid_px:?}");
|
||||
assert_eq!(solid_px[0], 255);
|
||||
let empty_px = img.get_pixel(24, 24);
|
||||
assert_eq!(empty_px[3], 0, "outside solid region must have alpha 0: {empty_px:?}");
|
||||
|
||||
let flat_path = dir.join("flat.png").to_string_lossy().to_string();
|
||||
s.execute_checked("comp.saveFrameAs", json!({"path": flat_path, "transparent": false})).unwrap();
|
||||
let flat_img = image::load_from_memory(&std::fs::read(&flat_path).unwrap()).unwrap().to_rgba8();
|
||||
let flat_empty_px = flat_img.get_pixel(24, 24);
|
||||
assert_eq!(flat_empty_px[3], 255, "transparent: false must composite over background: {flat_empty_px:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_frame_as_queued_respects_frame_default_template() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Comp", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap();
|
||||
|
||||
// Default Still template is "PNG Sequence" (Channels::Rgb)
|
||||
let r = s.execute_checked("comp.saveFrameAs", json!({"queue": true})).unwrap();
|
||||
assert_eq!(r["queued"], true);
|
||||
let it = s.project.render_queue.last().unwrap();
|
||||
assert_eq!(it.output.name, "PNG Sequence");
|
||||
assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb);
|
||||
|
||||
// Test undo and redo of queuing a frame
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.project.render_queue.is_empty());
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
let it = s.project.render_queue.last().unwrap();
|
||||
assert_eq!(it.output.name, "PNG Sequence");
|
||||
|
||||
// Explicit transparent: false forces Channels::Rgb
|
||||
s.execute_checked("comp.saveFrameAs", json!({"queue": true, "transparent": false})).unwrap();
|
||||
let it = s.project.render_queue.last().unwrap();
|
||||
assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb);
|
||||
|
||||
// When Frame Default is set to an RGBA template, it is respected
|
||||
s.execute_checked("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "PNG Sequence with Alpha"})).unwrap();
|
||||
s.execute_checked("comp.saveFrameAs", json!({"queue": true})).unwrap();
|
||||
let it = s.project.render_queue.last().unwrap();
|
||||
assert_eq!(it.output.name, "PNG Sequence with Alpha");
|
||||
assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgba);
|
||||
|
||||
// Passing transparent: false forces Channels::Rgb even with an RGBA template
|
||||
s.execute_checked("comp.saveFrameAs", json!({"queue": true, "transparent": false})).unwrap();
|
||||
let it = s.project.render_queue.last().unwrap();
|
||||
assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb);
|
||||
|
||||
// With JPEG Sequence as Frame Default (non-alpha format), queue succeeds without error
|
||||
s.execute_checked("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "JPEG Sequence"})).unwrap();
|
||||
assert!(s.execute_checked("comp.saveFrameAs", json!({"queue": true})).is_ok());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Create 3D Layer Instance (Layer ▸ Create): a comp-sized solid at the top of the layer stack
|
||||
//! with a 3D Layer Instance effect referencing the selected 3D model layer — a 2D copy of its
|
||||
//! render that 2D effects can be applied to (behaviour, pixels, undo/redo, serde).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, Project};
|
||||
use effectcraft_render::{FootageSource, RenderOpts};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Importer, Session};
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session { footage: Arc::new(DiskModels), importer: Some(Arc::new(ModelImporter)), ..Default::default() };
|
||||
s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "frameRate": 30, "duration": 2})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn cid(s: &Session) -> ItemId {
|
||||
s.active_comp_id().unwrap()
|
||||
}
|
||||
|
||||
fn layer_in(s: &Session, comp: ItemId, id: u64) -> Layer {
|
||||
s.project.comp(comp).unwrap().layer(LayerId(id)).unwrap().clone()
|
||||
}
|
||||
|
||||
fn px(img: &effectcraft_raster::Image, x: u32, y: u32) -> [f32; 4] {
|
||||
img.data[(y * img.width + x) as usize]
|
||||
}
|
||||
|
||||
fn close(a: &effectcraft_raster::Image, b: &effectcraft_raster::Image, tol: f32) -> bool {
|
||||
a.data.iter().zip(b.data.iter()).all(|(p, q)| p.iter().zip(q.iter()).all(|(x, y)| (x - y).abs() <= tol))
|
||||
}
|
||||
|
||||
struct DiskModels;
|
||||
|
||||
impl FootageSource for DiskModels {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<effectcraft_raster::Image>> {
|
||||
None
|
||||
}
|
||||
fn model(&self, _: ItemId, f: &Footage) -> Option<Arc<effectcraft_model::Model>> {
|
||||
let bytes = std::fs::read(&f.path).ok()?;
|
||||
effectcraft_model::load(&f.path, &bytes, &|_| None).ok().map(Arc::new)
|
||||
}
|
||||
}
|
||||
|
||||
struct ModelImporter;
|
||||
|
||||
impl Importer for ModelImporter {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
effectcraft_model::format_of(path).ok_or("not a model")?;
|
||||
Ok(Footage { path: path.into(), kind: FootageKind::Model, frame_rate: FrameRate::FPS_30, codec: "glTF".into(), ..Default::default() })
|
||||
}
|
||||
}
|
||||
|
||||
/// A red triangle in the Z = 0 plane (buffer embedded as a data URI).
|
||||
const TRIANGLE: &str = r#"{
|
||||
"asset": {"version": "2.0"},
|
||||
"scenes": [{"nodes": [0]}],
|
||||
"nodes": [{"mesh": 0}],
|
||||
"meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}, "material": 0}]}],
|
||||
"materials": [{"pbrMetallicRoughness": {"baseColorFactor": [1.0, 0.0, 0.0, 1.0]}}],
|
||||
"buffers": [{"byteLength": 72, "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/"}],
|
||||
"bufferViews": [
|
||||
{"buffer": 0, "byteOffset": 0, "byteLength": 36},
|
||||
{"buffer": 0, "byteOffset": 36, "byteLength": 36}
|
||||
],
|
||||
"accessors": [
|
||||
{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 0.0]},
|
||||
{"bufferView": 1, "componentType": 5126, "type": "VEC3", "count": 3}
|
||||
]
|
||||
}"#;
|
||||
|
||||
fn triangle_model(s: &mut Session) -> u64 {
|
||||
let dir = std::env::temp_dir().join(format!("ec-instance3d-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Triangle.gltf");
|
||||
std::fs::write(&path, TRIANGLE).unwrap();
|
||||
s.execute("layer.newModel", json!({"path": path.to_string_lossy(), "name": "Triangle"})).unwrap()["layer"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance3d_creates_a_2d_copy_of_the_model() {
|
||||
let mut s = session();
|
||||
let model = triangle_model(&mut s);
|
||||
// The model renders through the comp's camera.
|
||||
let before = s.render(cid(&s), Tick::ZERO, RenderOpts::default());
|
||||
assert!(px(&before, 320, 180)[3] > 0.9, "the model covers the centre: {:?}", px(&before, 320, 180));
|
||||
assert!(px(&before, 4, 4)[3] < 0.1, "the comp is transparent around it: {:?}", px(&before, 4, 4));
|
||||
// Create 3D Layer Instance.
|
||||
s.execute("layer.select", json!({"layers": [model]})).unwrap();
|
||||
let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap();
|
||||
let solid = r["layers"][0].as_u64().unwrap();
|
||||
let comp = cid(&s);
|
||||
let l = layer_in(&s, comp, solid);
|
||||
assert_eq!(l.name, "Triangle Instance");
|
||||
assert_eq!(s.active_comp().unwrap().layers[0].id.0, solid, "at the top of the layer stack");
|
||||
// A 3D Layer Instance effect referencing the 3D layer.
|
||||
let g = l.effects().unwrap().groups().next().unwrap().clone();
|
||||
assert!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.3d.instance3d"), "{:?}", g.kind);
|
||||
assert_eq!(g.get("layer").unwrap().value, KV::Layer(Some(model)));
|
||||
// The model is switched off, the copy selected.
|
||||
assert!(!layer_in(&s, comp, model).switches.video, "the referenced 3D layer is off, as After Effects does");
|
||||
assert_eq!(s.state.selected_layers, vec![LayerId(solid)]);
|
||||
// The comp renders the same through the copy: a 2D copy of the 3D layer, transparent
|
||||
// around it.
|
||||
let after = s.render(comp, Tick::ZERO, RenderOpts::default());
|
||||
assert!(close(&before, &after, 2e-2), "the copy shows the model:\n{:?}\n{:?}", px(&before, 320, 180), px(&after, 320, 180));
|
||||
assert!(px(&after, 320, 180)[3] > 0.9 && px(&after, 4, 4)[3] < 0.1, "{:?} {:?}", px(&after, 320, 180), px(&after, 4, 4));
|
||||
// Undo removes the copy and switches the model back on; redo returns it.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active_comp().unwrap().layer(LayerId(solid)).is_none());
|
||||
assert!(layer_in(&s, comp, model).switches.video);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert!(s.active_comp().unwrap().layer(LayerId(solid)).is_some());
|
||||
assert!(!layer_in(&s, comp, model).switches.video);
|
||||
// Serde round-trips the layer parameter.
|
||||
let j = serde_json::to_string(&*s.project).unwrap();
|
||||
let back: Project = serde_json::from_str(&j).unwrap();
|
||||
assert_eq!(back, *s.project);
|
||||
// 2D effects can be applied to the copy (the model layer itself takes none): Fill in blue
|
||||
// recolours the triangle.
|
||||
s.execute("effect.apply", json!({"layer": solid, "effect": "ec.generate.fill"})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": solid, "path": "effects/#2/color", "value": [0.0, 0.0, 1.0, 1.0]})).unwrap();
|
||||
let with_fx = s.render(comp, Tick::ZERO, RenderOpts::default());
|
||||
let p = px(&with_fx, 320, 180);
|
||||
assert!(p[2] > 0.9 && p[0] < 0.1, "the fill recolours the copy: {p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance3d_also_takes_3d_primitives() {
|
||||
let mut s = session();
|
||||
let prim = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 200, "height": 200, "depth": 200})).unwrap()["layer"].as_u64().unwrap();
|
||||
let before = s.render(cid(&s), Tick::ZERO, RenderOpts::default());
|
||||
let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [prim]})).unwrap();
|
||||
let solid = r["layers"][0].as_u64().unwrap();
|
||||
let l = layer_in(&s, cid(&s), solid);
|
||||
assert_eq!(l.name, "Cube Instance");
|
||||
assert!(!layer_in(&s, cid(&s), prim).switches.video);
|
||||
let after = s.render(cid(&s), Tick::ZERO, RenderOpts::default());
|
||||
assert!(close(&before, &after, 2e-2), "the copy shows the cube");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance3d_needs_a_3d_layer() {
|
||||
let mut s = session();
|
||||
let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
let err = s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [solid]}));
|
||||
assert!(err.is_err());
|
||||
let text = s.execute("layer.newText", json!({"text": "hi"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err());
|
||||
// A 3D-switched text layer is not a model layer either (it has 2D pixels of its own).
|
||||
s.execute("layer.setSwitch", json!({"layers": [text], "switch": "threeD", "value": true})).unwrap();
|
||||
assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance3d_creates_every_selected_copy_in_one_undo() {
|
||||
let mut s = session();
|
||||
let a = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap();
|
||||
let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [a, b]})).unwrap();
|
||||
let copies = r["layers"].as_array().unwrap();
|
||||
assert_eq!(copies.len(), 2);
|
||||
let (ia, ib) = (copies[0].as_u64().unwrap(), copies[1].as_u64().unwrap());
|
||||
let comp = cid(&s);
|
||||
assert_ne!(ia, ib);
|
||||
assert_ne!(layer_in(&s, comp, ia).name, layer_in(&s, comp, ib).name, "each copy has a name of its own");
|
||||
// Both sources are off, both copies selected, above their sources in the stack.
|
||||
assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video);
|
||||
let mut sel = s.state.selected_layers.clone();
|
||||
let mut want = vec![LayerId(ia), LayerId(ib)];
|
||||
sel.sort_by_key(|l| l.0);
|
||||
want.sort_by_key(|l| l.0);
|
||||
assert_eq!(sel, want);
|
||||
let c = s.active_comp().unwrap();
|
||||
let pos = |id: u64| c.layers.iter().position(|l| l.id.0 == id).unwrap();
|
||||
assert!(pos(ia) < pos(a) && pos(ib) < pos(b), "the copies sit above their sources");
|
||||
// One undo removes both copies and switches both models back on; one redo returns them.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
assert!(c.layer(LayerId(ia)).is_none() && c.layer(LayerId(ib)).is_none(), "one undo removes every instance");
|
||||
assert!(layer_in(&s, comp, a).switches.video && layer_in(&s, comp, b).switches.video);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
assert!(c.layer(LayerId(ia)).is_some() && c.layer(LayerId(ib)).is_some(), "one redo returns every instance");
|
||||
assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance3d_in_a_mixed_selection_only_takes_the_models() {
|
||||
let mut s = session();
|
||||
let model = triangle_model(&mut s);
|
||||
let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [model, solid]})).unwrap();
|
||||
let copies = r["layers"].as_array().unwrap();
|
||||
assert_eq!(copies.len(), 1, "only the model gets a copy: {r}");
|
||||
let copy = copies[0].as_u64().unwrap();
|
||||
let comp = cid(&s);
|
||||
assert!(!layer_in(&s, comp, model).switches.video, "the model is switched off");
|
||||
assert!(layer_in(&s, comp, solid).switches.video, "the plain solid is untouched");
|
||||
let skipped = r["skipped"].as_array().unwrap();
|
||||
assert_eq!(skipped.len(), 1);
|
||||
assert!(skipped[0].as_str().unwrap().contains("not a 3D model layer"), "{skipped:?}");
|
||||
// The copy references the model, not the solid.
|
||||
let l = layer_in(&s, comp, copy);
|
||||
let g = l.effects().unwrap().groups().next().unwrap().clone();
|
||||
assert_eq!(g.get("layer").unwrap().value, KV::Layer(Some(model)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance3d_shows_nothing_outside_the_source_layer_range() {
|
||||
let mut s = session();
|
||||
let model = triangle_model(&mut s);
|
||||
// The model's range ends at 1 s; the comp runs to 2 s.
|
||||
let comp = cid(&s);
|
||||
s.edit("trim", None, |p, _| {
|
||||
p.comp_mut(comp).unwrap().layer_mut(LayerId(model)).unwrap().out_point = Tick::from_seconds_f64(1.0);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("layer.select", json!({"layers": [model]})).unwrap();
|
||||
let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap();
|
||||
let _copy = r["layers"][0].as_u64().unwrap();
|
||||
// Inside the model's range the copy shows it, transparent around it.
|
||||
let inside = s.render(comp, Tick::from_seconds_f64(0.5), RenderOpts::default());
|
||||
assert!(px(&inside, 320, 180)[3] > 0.9, "the copy shows the model: {:?}", px(&inside, 320, 180));
|
||||
// After its out point the source has nothing to read: the copy is transparent.
|
||||
let outside = s.render(comp, Tick::from_seconds_f64(1.5), RenderOpts::default());
|
||||
assert!(outside.data.iter().all(|p| p[3] < 1e-3), "transparent after the source's out point: {:?}", px(&outside, 320, 180));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance3d_keeps_the_copy_visible_when_the_source_is_soloed() {
|
||||
let mut s = session();
|
||||
let model = triangle_model(&mut s);
|
||||
let comp = cid(&s);
|
||||
// Solo the model, then create the instance: any_solo hides non-soloed layers and the
|
||||
// source's video goes off, so the copy has to take the Solo over.
|
||||
s.edit("solo", None, |p, _| {
|
||||
p.comp_mut(comp).unwrap().layer_mut(LayerId(model)).unwrap().switches.solo = true;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.render(comp, Tick::ZERO, RenderOpts::default());
|
||||
assert!(px(&before, 320, 180)[3] > 0.9, "the soloed model shows: {:?}", px(&before, 320, 180));
|
||||
s.execute("layer.select", json!({"layers": [model]})).unwrap();
|
||||
let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap();
|
||||
let solid = r["layers"][0].as_u64().unwrap();
|
||||
assert!(layer_in(&s, comp, solid).switches.solo, "the copy takes the source's Solo switch");
|
||||
assert!(!layer_in(&s, comp, model).switches.video, "the source keeps its video off");
|
||||
let after = s.render(comp, Tick::ZERO, RenderOpts::default());
|
||||
assert!(px(&after, 320, 180)[3] > 0.9, "the soloed copy shows the model: {:?}", px(&after, 320, 180));
|
||||
assert!(close(&before, &after, 2e-2), "the comp renders the same through the copy:\n{:?}\n{:?}", px(&before, 320, 180), px(&after, 320, 180));
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
//! Copied keyframes as After Effects keyframe text for other applications (#1908).
|
||||
|
||||
use effectcraft_project::{LayerId, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{KeyRef, Session};
|
||||
|
||||
/// A 1920×1080 23.976 fps comp with a 200×100 solid.
|
||||
fn session() -> (Session, LayerId) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 1920, "height": 1080, "frameRate": 23.976, "duration": 10})).unwrap();
|
||||
let id = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
(s, LayerId(id))
|
||||
}
|
||||
|
||||
fn frame(s: &Session, n: i64) -> Tick {
|
||||
Tick(s.active_comp().unwrap().frame_rate.frame_duration().0 * n)
|
||||
}
|
||||
|
||||
fn uid(s: &Session, lid: LayerId, path: &str) -> Uid {
|
||||
s.active_comp().unwrap().layer(lid).unwrap().props.prop(path).unwrap().uid
|
||||
}
|
||||
|
||||
fn edit_layer(s: &mut Session, lid: LayerId, f: impl FnOnce(&mut effectcraft_project::Layer)) {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
f(std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(lid).unwrap());
|
||||
}
|
||||
|
||||
fn add_key(s: &mut Session, lid: LayerId, path: &str, time: Tick, value: serde_json::Value) {
|
||||
s.execute("prop.addKey", json!({"layer": lid.0, "path": path, "time": time.seconds(), "value": value})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_keys_are_after_effects_text_in_comp_frames() {
|
||||
let (mut s, l) = session();
|
||||
// The layer starts 10 frames in: its keys at layer frames 2, 5 and 9 are comp frames 12, 15 and 19.
|
||||
let start = frame(&s, 10);
|
||||
edit_layer(&mut s, l, |l| l.start_time = start);
|
||||
for (n, v) in [(2, [960.0, 540.0]), (5, [1000.5, 520.25]), (9, [1100.0, 500.0])] {
|
||||
let t = frame(&s, n);
|
||||
add_key(&mut s, l, "transform/position", t, json!(v));
|
||||
}
|
||||
let t = frame(&s, 5);
|
||||
add_key(&mut s, l, "transform/rotation", t, json!(-45));
|
||||
let (position, rotation) = (uid(&s, l, "transform/position"), uid(&s, l, "transform/rotation"));
|
||||
// A subset: two of the three Position keys and the Rotation key.
|
||||
s.state.selected_keys = [(position, 2), (position, 9), (rotation, 5)].map(|(prop, n)| KeyRef { layer: l, prop, time: frame(&s, n) }).to_vec();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
assert_eq!(
|
||||
s.state.keyframe_text.as_deref(),
|
||||
Some(
|
||||
"Adobe After Effects 9.0 Keyframe Data\n\n\tUnits Per Second\t23.976\n\tSource Width\t200\n\tSource Height\t100\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t1\n\n\
|
||||
Layer\n\
|
||||
Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t12\t960\t540\t0\t\n\t19\t1100\t500\t0\t\n\n\
|
||||
Transform\tRotation\n\tFrame\tdegrees\t\n\t15\t-45\t\n\n\
|
||||
\n\
|
||||
End of Keyframe Data\n"
|
||||
)
|
||||
);
|
||||
// The internal clipboard still has the keys themselves, relative to the earliest.
|
||||
let times: Vec<Vec<Tick>> = s.state.key_clipboard.iter().map(|c| c.keys.iter().map(|k| k.time).collect()).collect();
|
||||
assert_eq!(times, [vec![Tick::ZERO, frame(&s, 7)], vec![frame(&s, 3)]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stretched_layer_writes_the_frames_its_keys_show_at() {
|
||||
let (mut s, l) = session();
|
||||
edit_layer(&mut s, l, |l| l.stretch = 200.0);
|
||||
let t = frame(&s, 6);
|
||||
add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(0));
|
||||
add_key(&mut s, l, "transform/opacity", t, json!(100));
|
||||
s.execute("prop.select", json!({"layer": l.0, "path": "transform/opacity"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\t\n\t0\t0\t\n\t12\t100\t\n\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_static_values_have_no_frame() {
|
||||
let (mut s, l) = session();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": l.0, "path": "transform/position"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t\t100\t200\t0\t\n\n"), "{text}");
|
||||
assert_eq!(s.state.value_clipboard.len(), 1, "the internal clipboard keeps the value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copied_transform_group_lists_what_the_layer_shows() {
|
||||
let (mut s, l) = session();
|
||||
let t = frame(&s, 24);
|
||||
add_key(&mut s, l, "transform/scale", Tick::ZERO, json!([50, 50, 100]));
|
||||
add_key(&mut s, l, "transform/scale", t, json!([150, 150, 100]));
|
||||
let transform = s.active_comp().unwrap().layer(l).unwrap().props.sub("transform").unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect();
|
||||
assert_eq!(names, ["Anchor Point", "Position", "Scale", "Rotation", "Opacity"], "a 2D layer has no Orientation or X / Y Rotation");
|
||||
assert!(text.contains("\tFrame\tX percent\tY percent\tZ percent\t\n\t0\t50\t50\t100\t\n\t24\t150\t150\t100\t\n"), "{text}");
|
||||
// 3D: Orientation and X / Y Rotation. Z Rotation stays Rotation, and Orientation's three
|
||||
// values sit under one label, as After Effects writes them.
|
||||
edit_layer(&mut s, l, |l| l.switches.three_d = true);
|
||||
s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect();
|
||||
assert_eq!(names, ["Anchor Point", "Position", "Scale", "Orientation", "X Rotation", "Y Rotation", "Rotation", "Opacity"]);
|
||||
assert!(text.contains("Transform\tOrientation\n\tFrame\tX degrees\t\n\t\t0\t0\t0\t\n"), "{text}");
|
||||
}
|
||||
|
||||
fn copy_transform(s: &mut Session, lid: LayerId) -> String {
|
||||
let transform = s.active_comp().unwrap().layer(lid).unwrap().props.sub("transform").unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": lid.0, "prop": transform, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.state.keyframe_text.clone().unwrap()
|
||||
}
|
||||
|
||||
fn names(text: &str) -> Vec<&str> {
|
||||
text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_the_layer_does_not_use_are_left_out() {
|
||||
// Separate Dimensions: the old Position no longer moves the layer.
|
||||
let (mut s, l) = session();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap();
|
||||
s.execute("prop.separateDimensions", json!({"layer": l.0, "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/positionX", "value": 321})).unwrap();
|
||||
let text = copy_transform(&mut s, l);
|
||||
assert!(!names(&text).contains(&"Position"), "{text}");
|
||||
// A one-node camera has no Point of Interest; a two-node one does.
|
||||
let cam = LayerId(s.execute("layer.newCamera", json!({"type": "oneNode"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_transform(&mut s, cam);
|
||||
assert!(!names(&text).contains(&"Point of Interest"), "{text}");
|
||||
s.execute("layer.cameraSettings", json!({"layer": cam.0, "type": "twoNode"})).unwrap();
|
||||
let text = copy_transform(&mut s, cam);
|
||||
assert!(names(&text).contains(&"Point of Interest"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separated_position_writes_x_y_and_z_position() {
|
||||
let (mut s, l) = session();
|
||||
s.execute("prop.separateDimensions", json!({"layer": l.0, "value": true})).unwrap();
|
||||
let t = frame(&s, 12);
|
||||
add_key(&mut s, l, "transform/positionX", Tick::ZERO, json!(100));
|
||||
add_key(&mut s, l, "transform/positionX", t, json!(321));
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/positionY", "value": 200})).unwrap();
|
||||
let text = copy_transform(&mut s, l);
|
||||
// After Effects labels their single column with no unit.
|
||||
assert!(text.contains("Transform\tX Position\n\tFrame\t\t\n\t0\t100\t\n\t12\t321\t\n\nTransform\tY Position\n\tFrame\t\t\n\t\t200\t\n\n"), "{text}");
|
||||
assert!(!names(&text).contains(&"Z Position"), "a 2D layer has no Z Position: {text}");
|
||||
edit_layer(&mut s, l, |l| l.switches.three_d = true);
|
||||
let text = copy_transform(&mut s, l);
|
||||
assert!(text.contains("Transform\tZ Position\n\tFrame\t\t\n\t\t0\t\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_remap_writes_source_seconds_at_comp_frames() {
|
||||
let (mut s, l) = session();
|
||||
let r = s.execute("layer.precompose", json!({"layers": [l.0], "name": "Inner"})).unwrap();
|
||||
let l = LayerId(r["layer"].as_u64().unwrap());
|
||||
s.execute("layer.select", json!({"layers": [l.0]})).unwrap();
|
||||
s.execute("layer.enableTimeRemap", json!({})).unwrap();
|
||||
let start = frame(&s, 10);
|
||||
edit_layer(&mut s, l, |l| l.start_time = start);
|
||||
let t = frame(&s, 6);
|
||||
add_key(&mut s, l, "timeRemap", t, json!(2.5));
|
||||
s.execute("prop.select", json!({"layer": l.0, "path": "timeRemap"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
// As After Effects writes it: the key at layer frame 6 of a layer starting at frame 10 is at 16.
|
||||
assert!(text.contains("Layer\nTime Remap\n\tFrame\tseconds\t\n\t10\t0\t\n\t16\t2.5\t\n"), "{text}");
|
||||
}
|
||||
|
||||
/// Selects the layer's properties at `paths` and copies them.
|
||||
fn copy_props(s: &mut Session, lid: LayerId, paths: &[&str]) -> String {
|
||||
for (i, path) in paths.iter().enumerate() {
|
||||
s.execute("prop.select", json!({"layer": lid.0, "path": path, "add": i > 0, "selectKeys": false})).unwrap();
|
||||
}
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.state.keyframe_text.clone().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_properties_are_written_as_after_effects_does() {
|
||||
// The blocks After Effects 2019 wrote for the same effects and values (2019 has no Dropdown
|
||||
// Menu Control: that block follows its other popups, written 1-based with no unit).
|
||||
let (mut s, l) = session();
|
||||
for fx in ["Fill", "Checkbox Control", "Dropdown Menu Control", "Slider Control", "Point Control"] {
|
||||
s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap();
|
||||
}
|
||||
let t = frame(&s, 12);
|
||||
add_key(&mut s, l, "effects/#1/color", Tick::ZERO, json!([1, 0.5, 0.25, 1]));
|
||||
add_key(&mut s, l, "effects/#1/color", t, json!([0, 0, 1, 1]));
|
||||
add_key(&mut s, l, "effects/#2/checkbox", Tick::ZERO, json!(true));
|
||||
add_key(&mut s, l, "effects/#2/checkbox", t, json!(false));
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#3/menu", "value": 1})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#4/slider", "value": 20.5})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#5/point", "value": [10, 20]})).unwrap();
|
||||
// After Effects writes a renamed effect under the effect's own name.
|
||||
let slider = s.active_comp().unwrap().layer(l).unwrap().props.group("effects/#4").unwrap().uid;
|
||||
s.execute("prop.renameGroup", json!({"layer": l.0, "prop": slider, "name": "My Slider"})).unwrap();
|
||||
let text = copy_props(&mut s, l, &["effects/#1/color", "effects/#2/checkbox", "effects/#3/menu", "effects/#4/slider", "effects/#5/point"]);
|
||||
assert!(
|
||||
text.contains(
|
||||
"Layer\n\
|
||||
Effects\tFill #1\tColor #4\n\tFrame\talpha \tred \tgreen \tblue \t\n\t0\t255\t255\t127.5\t63.75\t\n\t12\t255\t0\t0\t255\t\n\n\
|
||||
Effects\tCheckbox Control #2\tCheckbox #2\n\tFrame\t\t\n\t0\t1\t\n\t12\t0\t\n\n\
|
||||
Effects\tDropdown Menu Control #3\tMenu #2\n\tFrame\t\t\n\t\t2\t\n\n\
|
||||
Effects\tSlider Control #4\tSlider #2\n\tFrame\t\t\n\t\t20.5\t\n\n\
|
||||
Effects\tPoint Control #5\tPoint #2\n\tFrame\tX pixels\tY pixels\t\n\t\t10\t20\t\n\n"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_properties_have_after_effects_indices() {
|
||||
// After Effects 2019 numbered Fractal Noise's Rotation (in the Transform group) #9,
|
||||
// Complexity (after it) #17, Evolution #25, and Opacity (after Evolution Options) #31: a
|
||||
// group takes an index where it opens and closes, and its properties are written without
|
||||
// it. Set Matte's Use For Matte is #3: the layer parameter before it has no extra index for
|
||||
// its source choice.
|
||||
let (mut s, l) = session();
|
||||
for fx in ["Fractal Noise", "Set Matte"] {
|
||||
s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap();
|
||||
}
|
||||
let text = copy_props(
|
||||
&mut s,
|
||||
l,
|
||||
&[
|
||||
"effects/#1/transform/rotation",
|
||||
"effects/#1/complexity",
|
||||
"effects/#1/evolution",
|
||||
"effects/#1/opacity",
|
||||
"effects/#1/blendingMode",
|
||||
"effects/#2/takeMatteFrom",
|
||||
],
|
||||
);
|
||||
for header in [
|
||||
"Effects\tFractal Noise #1\tRotation #9\n",
|
||||
"Effects\tFractal Noise #1\tComplexity #17\n",
|
||||
"Effects\tFractal Noise #1\tEvolution #25\n",
|
||||
"Effects\tFractal Noise #1\tOpacity #31\n",
|
||||
"Effects\tFractal Noise #1\tBlending Mode #32\n",
|
||||
"Effects\tSet Matte #2\tUse For Matte #3\n",
|
||||
] {
|
||||
assert!(text.contains(header), "{header}in\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_parameters_after_effects_numbers_or_labels_otherwise_follow_it() {
|
||||
// After Effects 2019: Radial Blur counts a hidden parameter before Amount, which has no
|
||||
// unit; 3D Channel Extract lists Anti-alias before Clamp Output; Selective Color's per-color
|
||||
// sliders start at #10 and are percent.
|
||||
let (mut s, l) = session();
|
||||
for fx in ["Radial Blur", "3D Channel Extract", "Selective Color"] {
|
||||
s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap();
|
||||
}
|
||||
let text = copy_props(
|
||||
&mut s,
|
||||
l,
|
||||
&["effects/#1/amount", "effects/#1/center", "effects/#2/antialias", "effects/#2/invertDepthMap", "effects/#3/details/reds/redsCyan"],
|
||||
);
|
||||
for block in [
|
||||
"Effects\tRadial Blur #1\tAmount #3\n\tFrame\t\t\n",
|
||||
"Effects\tRadial Blur #1\tCenter #4\n\tFrame\tX pixels\tY pixels\t\n",
|
||||
"Effects\t3D Channel Extract #2\tAnti-alias #5\n\tFrame\t\t\n",
|
||||
"Effects\t3D Channel Extract #2\tInvert Depth Map #7\n\tFrame\t\t\n",
|
||||
"Effects\tSelective Color #3\tReds Cyan #10\n\tFrame\tpercent\t\n",
|
||||
] {
|
||||
assert!(text.contains(block), "{block}in\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_properties_are_written_as_after_effects_does() {
|
||||
// The blocks After Effects 2019 wrote for the same masks and values: masks go by place, not
|
||||
// name, and Mask Path is left out.
|
||||
let (mut s, l) = session();
|
||||
for _ in 0..2 {
|
||||
s.execute("mask.new", json!({"layer": l.0, "vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap();
|
||||
}
|
||||
let t = frame(&s, 12);
|
||||
for (prop, a, b) in [("feather", json!([5, 5]), json!([10, 20])), ("opacity", json!(50), json!(75)), ("expansion", json!(3), json!(-4))] {
|
||||
add_key(&mut s, l, &format!("masks/#1/{prop}"), Tick::ZERO, a);
|
||||
add_key(&mut s, l, &format!("masks/#1/{prop}"), t, b);
|
||||
}
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "masks/#2/feather", "value": [7, 8]})).unwrap();
|
||||
let second = s.active_comp().unwrap().layer(l).unwrap().props.group("masks/#2").unwrap().uid;
|
||||
s.execute("prop.renameGroup", json!({"layer": l.0, "prop": second, "name": "Renamed Mask"})).unwrap();
|
||||
let text = copy_props(&mut s, l, &["masks/#1/path", "masks/#1/feather", "masks/#1/opacity", "masks/#1/expansion", "masks/#2/feather"]);
|
||||
assert!(
|
||||
text.contains(
|
||||
"Layer\n\
|
||||
Masks\tMask #1\tMask Feather\n\tFrame\tX pixels\tY pixels\t\n\t0\t5\t5\t\n\t12\t10\t20\t\n\n\
|
||||
Masks\tMask #1\tMask Opacity\n\tFrame\tpercent\t\n\t0\t50\t\n\t12\t75\t\n\n\
|
||||
Masks\tMask #1\tMask Expansion\n\tFrame\tpixels\t\n\t0\t3\t\n\t12\t-4\t\n\n\
|
||||
Masks\tMask #2\tMask Feather\n\tFrame\tX pixels\tY pixels\t\n\t\t7\t8\t\n\n\
|
||||
\nEnd of Keyframe Data\n"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_properties_are_written_as_after_effects_does() {
|
||||
// The blocks After Effects 2019 wrote for the same text properties and values.
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 1920, "height": 1080, "frameRate": 23.976})).unwrap();
|
||||
let l = LayerId(s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.execute("layer.addTextAnimator", json!({"properties": ["position", "fillColor"]})).unwrap();
|
||||
s.execute("layer.addTextAnimator", json!({"properties": ["opacity"]})).unwrap();
|
||||
let second = s.active_comp().unwrap().layer(l).unwrap().props.group("text/animators/#2").unwrap().uid;
|
||||
s.execute("prop.renameGroup", json!({"layer": l.0, "prop": second, "name": "Fade"})).unwrap();
|
||||
let t = frame(&s, 12);
|
||||
add_key(&mut s, l, "text/sourceText", Tick::ZERO, json!("Hello"));
|
||||
add_key(&mut s, l, "text/pathOptions/firstMargin", Tick::ZERO, json!(0));
|
||||
add_key(&mut s, l, "text/pathOptions/firstMargin", t, json!(50));
|
||||
add_key(&mut s, l, "text/animators/#1/selectors/#1/start", Tick::ZERO, json!(0));
|
||||
add_key(&mut s, l, "text/animators/#1/selectors/#1/start", t, json!(100));
|
||||
add_key(&mut s, l, "text/animators/#1/properties/position", Tick::ZERO, json!([0, 0, 0]));
|
||||
add_key(&mut s, l, "text/animators/#1/properties/position", t, json!([10, -20, 0]));
|
||||
for (path, v) in [
|
||||
("text/animators/#1/selectors/#1/end", json!(80)),
|
||||
("text/animators/#1/selectors/#1/offset", json!(-10)),
|
||||
("text/animators/#1/properties/fillColor", json!([1, 0, 0, 1])),
|
||||
("text/moreOptions/groupingAlignment", json!([10, -20])),
|
||||
] {
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": path, "value": v})).unwrap();
|
||||
}
|
||||
let text = copy_props(
|
||||
&mut s,
|
||||
l,
|
||||
&[
|
||||
"text/sourceText",
|
||||
"text/pathOptions/firstMargin",
|
||||
"text/animators/#1/selectors/#1/start",
|
||||
"text/animators/#1/selectors/#1/end",
|
||||
"text/animators/#1/selectors/#1/offset",
|
||||
"text/animators/#1/selectors/#1/advanced/amount",
|
||||
"text/animators/#1/properties/position",
|
||||
"text/animators/#1/properties/fillColor",
|
||||
"text/animators/#2/properties/opacity",
|
||||
"text/moreOptions/groupingAlignment",
|
||||
],
|
||||
);
|
||||
for block in [
|
||||
"Text\tPath Options\tFirst Margin\n\tFrame\t\t\n\t0\t0\t\n\t12\t50\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tStart #1\n\tFrame\tpercent\t\n\t0\t0\t\n\t12\t100\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tEnd #2\n\tFrame\tpercent\t\n\t\t80\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tOffset #3\n\tFrame\tpercent\t\n\t\t-10\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tProperties\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t0\t0\t0\t0\t\n\t12\t10\t-20\t0\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tProperties\tFill Color\n\tFrame\talpha \tred \tgreen \tblue \t\n\t\t255\t255\t0\t0\t\n\n",
|
||||
"Text\tAnimators\tAnimator #2\tProperties\tOpacity\n\tFrame\tpercent\t\n\t\t100\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tAdvanced\tAmount\n\tFrame\tpercent\t\n\t\t100\t\n\n",
|
||||
"Text\tMore Options\tGrouping Alignment\n\tFrame\tX percent\tY percent\t\n\t\t10\t-20\t\n\n",
|
||||
] {
|
||||
assert!(text.contains(block), "{block}\nin\n{text}");
|
||||
}
|
||||
assert!(!text.contains("Source Text"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_2d_layer_writes_the_z_it_shows() {
|
||||
// Keys pasted from a 3D layer keep their Z on a 2D one, which ignores it.
|
||||
let (mut s, l) = session();
|
||||
let src = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap());
|
||||
edit_layer(&mut s, src, |l| l.switches.three_d = true);
|
||||
add_key(&mut s, src, "transform/position", Tick::ZERO, json!([100, 200, 300]));
|
||||
add_key(&mut s, src, "transform/scale", Tick::ZERO, json!([50, 60, 70]));
|
||||
s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: src, prop: uid(&s, src, p), time: Tick::ZERO }).to_vec();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
s.execute("keys.paste", json!({"layers": [l.0], "time": 0})).unwrap();
|
||||
let pasted = s.active_comp().unwrap().layer(l).unwrap().props.prop("transform/position").unwrap().keys[0].value.components();
|
||||
assert_eq!(pasted, [100.0, 200.0, 300.0], "the 2D layer keeps the pasted Z");
|
||||
s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: l, prop: uid(&s, l, p), time: Tick::ZERO }).to_vec();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t0\t100\t200\t0\t\n"), "{text}");
|
||||
assert!(text.contains("Transform\tScale\n\tFrame\tX percent\tY percent\tZ percent\t\n\t0\t50\t60\t100\t\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_header_has_the_source_pixel_aspect() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 1920, "height": 1080, "frameRate": 24, "pixelAspect": 1.2})).unwrap();
|
||||
let solid = LayerId(s.execute("layer.newSolid", json!({"width": 200, "height": 100, "pixelAspect": 1.5})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_transform(&mut s, solid);
|
||||
assert!(text.contains("\tSource Pixel Aspect Ratio\t1.5\n\tComp Pixel Aspect Ratio\t1.2\n"), "{text}");
|
||||
// A text layer: 1 × 1, as After Effects 2019 wrote it, with the comp's pixel aspect.
|
||||
let t = LayerId(s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_transform(&mut s, t);
|
||||
assert!(text.contains("\tSource Width\t1\n\tSource Height\t1\n\tSource Pixel Aspect Ratio\t1.2\n"), "{text}");
|
||||
// A shape layer has no source of its own: the comp's size.
|
||||
let shape = LayerId(s.execute("layer.newShape", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_transform(&mut s, shape);
|
||||
assert!(text.contains("\tSource Width\t1920\n\tSource Height\t1080\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_from_two_layers_have_no_text() {
|
||||
let (mut s, a) = session();
|
||||
let b = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap());
|
||||
for l in [a, b] {
|
||||
add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(50));
|
||||
}
|
||||
s.state.selected_keys = [a, b].map(|l| KeyRef { layer: l, prop: uid(&s, l, "transform/opacity"), time: Tick::ZERO }).to_vec();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
assert_eq!(s.state.keyframe_text, None, "After Effects copies one layer's keys as text");
|
||||
assert_eq!(s.state.key_clipboard.len(), 2, "EffectCraft's own clipboard has both");
|
||||
// A copied layer leaves no text behind.
|
||||
s.state.selected_keys = vec![KeyRef { layer: a, prop: uid(&s, a, "transform/opacity"), time: Tick::ZERO }];
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
assert!(s.state.keyframe_text.is_some());
|
||||
s.state.selected_keys.clear();
|
||||
s.execute("edit.copy", json!({"layers": [a.0]})).unwrap();
|
||||
assert_eq!(s.state.keyframe_text, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_contents_are_written_as_after_effects_does() {
|
||||
// Headers and labels After Effects 2019 wrote for the same shape items: items go by kind and
|
||||
// place in their group, and a Size has no unit (`X `).
|
||||
let (mut s, _) = session();
|
||||
let sh = LayerId(s.execute("layer.newShape", json!({"kind": "none"})).unwrap()["layer"].as_u64().unwrap());
|
||||
for (kind, group) in [("group", None), ("rectangle", Some("contents/#1")), ("fill", Some("contents/#1")), ("star", None), ("repeater", None)] {
|
||||
let mut p = json!({"layer": sh.0, "kind": kind});
|
||||
if let Some(g) = group {
|
||||
p["group"] = json!(g);
|
||||
}
|
||||
s.execute("layer.addShapeItem", p).unwrap();
|
||||
}
|
||||
let text = copy_props(
|
||||
&mut s,
|
||||
sh,
|
||||
&[
|
||||
"contents/#1/contents/#1/size",
|
||||
"contents/#1/contents/#2/color",
|
||||
"contents/#1/transform/position",
|
||||
"contents/#2/points",
|
||||
"contents/#3/transform/startOpacity",
|
||||
],
|
||||
);
|
||||
for block in [
|
||||
"Contents\tGroup #1\tContents\tRectangle Path #1\tSize\n\tFrame\tX \tY \t\n",
|
||||
"Contents\tGroup #1\tContents\tFill #2\tColor\n\tFrame\talpha \tred \tgreen \tblue \t\n",
|
||||
"Contents\tGroup #1\tTransform\tPosition\n\tFrame\tX pixels\tY pixels\t\n",
|
||||
"Contents\tPolystar Path #2\tPoints\n\tFrame\t\t\n",
|
||||
"Contents\tRepeater #3\tTransform\tStart Opacity\n\tFrame\tpercent\t\n",
|
||||
] {
|
||||
assert!(text.contains(block), "{block}in\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_light_and_material_options_are_written_as_after_effects_does() {
|
||||
// Blocks After Effects 2019 wrote for these options, defaults included: Zoom and Shadow
|
||||
// Diffusion in pixels, Highlight Threshold on 0–1, and the material's shadow choices from 0.
|
||||
let (mut s, l) = session();
|
||||
let cam = LayerId(s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_props(&mut s, cam, &["cameraOptions/zoom", "cameraOptions/highlightThreshold"]);
|
||||
assert!(text.contains("Camera Options\tZoom\n\tFrame\tpixels\t\n"), "{text}");
|
||||
assert!(text.contains("Camera Options\tHighlight Threshold\n\tFrame\t\t\n\t\t1\t\n"), "{text}");
|
||||
let light = LayerId(s.execute("layer.newLight", json!({"type": "spot"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_props(&mut s, light, &["lightOptions/intensity", "lightOptions/shadowDiffusion"]);
|
||||
assert!(text.contains("Light Options\tIntensity\n\tFrame\tpercent\t\n\t\t100\t\n"), "{text}");
|
||||
assert!(text.contains("Light Options\tShadow Diffusion\n\tFrame\tpixels\t\n\t\t0\t\n"), "{text}");
|
||||
edit_layer(&mut s, l, |l| l.switches.three_d = true);
|
||||
let text = copy_props(&mut s, l, &["materialOptions/castsShadows", "materialOptions/acceptsShadows", "materialOptions/diffuse"]);
|
||||
for block in [
|
||||
"Material Options\tCasts Shadows\n\tFrame\t\t\n\t\t0\t\n",
|
||||
"Material Options\tAccepts Shadows\n\tFrame\t\t\n\t\t1\t\n",
|
||||
"Material Options\tDiffuse\n\tFrame\tpercent\t\n\t\t50\t\n",
|
||||
] {
|
||||
assert!(text.contains(block), "{block}in\n{text}");
|
||||
}
|
||||
}
|
||||
@@ -1103,6 +1103,66 @@ fn transform_commands_keep_depth_and_follow_separated_dimensions() {
|
||||
assert_eq!(pos(&s, b)[..2], [200.0, 100.0], "the 200×100 solid's centre");
|
||||
}
|
||||
|
||||
/// The layer's painted bounds mapped to comp space — what the viewer shows.
|
||||
fn seen_bounds(s: &Session, lid: u64) -> [f64; 4] {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let comp = s.project.comp(cid).unwrap().clone();
|
||||
let layer = comp.layer(effectcraft_project::LayerId(lid)).unwrap();
|
||||
let ectx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, s.time());
|
||||
let b = effectcraft_render::content_bounds(&ectx, layer).expect("painted bounds");
|
||||
let m = ectx.layer_to_comp(layer).0;
|
||||
let corners = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]];
|
||||
let pts: Vec<[f64; 2]> = corners
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let q = m.apply(effectcraft_geom::vec2(p[0], p[1]));
|
||||
[q.x, q.y]
|
||||
})
|
||||
.collect();
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
for p in &pts {
|
||||
x0 = x0.min(p[0]);
|
||||
y0 = y0.min(p[1]);
|
||||
x1 = x1.max(p[0]);
|
||||
y1 = y1.max(p[1]);
|
||||
}
|
||||
[x0, y0, x1, y1]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_to_comp_fills_the_comp_with_shape_and_text_bounds() {
|
||||
let mut s = comp();
|
||||
let close = |x: f64, y: f64| (x - y).abs() < 1e-6;
|
||||
// A shape or text layer has no source item: fit sizes it by its painted bounds, anchored at
|
||||
// the bounds' centre, so what the viewer shows fills the frame (#2198).
|
||||
let a = s.execute("layer.newShape", json!({"kind": "rect", "size": [180, 80]})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).unwrap();
|
||||
let seen = seen_bounds(&s, a);
|
||||
assert!(close(seen[0], 0.0) && close(seen[1], 0.0) && close(seen[2], 640.0) && close(seen[3], 360.0), "the rect's painted bounds fill the comp: {seen:?}");
|
||||
|
||||
let t = s.execute("layer.newText", json!({"text": "Title", "size": 80})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.transform", json!({"layers": [t], "op": "fit"})).unwrap();
|
||||
let seen = seen_bounds(&s, t);
|
||||
assert!(close(seen[0], 0.0) && close(seen[1], 0.0) && close(seen[2], 640.0) && close(seen[3], 360.0), "the text's painted bounds fill the comp: {seen:?}");
|
||||
|
||||
// fitWidth spans the comp's width and centres vertically.
|
||||
for lid in [a, t] {
|
||||
s.execute("layer.transform", json!({"layers": [lid], "op": "fitWidth"})).unwrap();
|
||||
let seen = seen_bounds(&s, lid);
|
||||
assert!(close(seen[0], 0.0) && close(seen[2], 640.0), "fitWidth spans the comp width: {seen:?}");
|
||||
assert!(close((seen[1] + seen[3]) / 2.0, 180.0), "fitWidth centres vertically: {seen:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_to_comp_refuses_a_scale_that_overflows() {
|
||||
let mut s = comp();
|
||||
let a = s.execute("layer.newShape", json!({"kind": "rect", "size": [1e-305, 80]})).unwrap()["layer"].as_u64().unwrap();
|
||||
let before = s.project.clone();
|
||||
assert!(s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).is_err(), "an infinite scale is refused");
|
||||
assert_eq!(s.project, before, "nothing was written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicated_and_pasted_layers_keep_their_links() {
|
||||
let mut s = comp();
|
||||
|
||||
@@ -14,7 +14,8 @@ 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
|
||||
/// even rows red, odd rows blue; 4×4, 10 fps), `flash.mov` (10×10, 10 fps: red, then blue from
|
||||
/// the second frame), `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;
|
||||
@@ -61,6 +62,11 @@ 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);
|
||||
// `flash.mov` is red for its first frame and blue from its second (0.1 s) on.
|
||||
if f.path.ends_with("flash.mov") {
|
||||
let c = if t.seconds() >= 0.1 - 1e-9 { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] };
|
||||
return Some(Arc::new(Image::filled(w, h, c)));
|
||||
}
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// Decoders hand out premultiplied pixels (straight file colour × alpha).
|
||||
@@ -182,6 +188,118 @@ fn comp_proxy_and_create_proxy() {
|
||||
assert!(blue(px(&s, outer, 0.0, o, 8, 8)));
|
||||
}
|
||||
|
||||
/// Outer (4×4, 20 fps) holding a layer of Inner (4×4, 10 fps, a red solid): (inner, outer).
|
||||
fn nested(s: &mut Session) -> (ItemId, ItemId) {
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 2})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 20, "duration": 2})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
|
||||
(inner, outer)
|
||||
}
|
||||
|
||||
/// Interpret Footage ▸ Proxy's Invert Alpha and Interpret As Linear Light change what a
|
||||
/// composition proxy draws, as they do for the same file used as footage.
|
||||
#[test]
|
||||
fn comp_proxy_draws_with_its_interpretation() {
|
||||
let mut s = session();
|
||||
let (inner, outer) = nested(&mut s);
|
||||
let o = RenderOpts::default();
|
||||
// Invert Alpha: the 25 % proxy covers 75 %.
|
||||
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/half.png"})).unwrap();
|
||||
s.execute("file.interpretProxy", json!({"item": inner.0, "alpha": "straight", "invertAlpha": true})).unwrap();
|
||||
let p = px(&s, outer, 0.0, o, 1, 1);
|
||||
assert!((p[3] - 0.75).abs() < 0.01, "{p:?}");
|
||||
// Interpret As Linear Light: the proxy draws as the file does as footage with that setting.
|
||||
let f = import(&mut s, "/m/matte_black.png");
|
||||
s.execute("file.interpretFootage", json!({"item": f.0, "linearLight": true})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Ref", "width": 4, "height": 4, "frameRate": 20, "duration": 2})).unwrap();
|
||||
let r = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": f.0})).unwrap();
|
||||
let want = px(&s, r, 0.0, o, 1, 1);
|
||||
assert!(want[1] / want[3] > 0.7, "linear light re-encodes the 0.5 green: {want:?}");
|
||||
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/matte_black.png"})).unwrap();
|
||||
s.execute("file.interpretProxy", json!({"item": inner.0, "linearLight": true})).unwrap();
|
||||
let p = px(&s, outer, 0.0, o, 1, 1);
|
||||
assert!((0..4).all(|i| (p[i] - want[i]).abs() < 1e-3), "{p:?} != {want:?}");
|
||||
}
|
||||
|
||||
/// Interpret Footage ▸ Proxy ▸ Separate Fields: a composition proxy shows one field per frame
|
||||
/// at twice the rate, as footage does.
|
||||
#[test]
|
||||
fn comp_proxy_separates_fields() {
|
||||
let mut s = session();
|
||||
let (inner, outer) = nested(&mut s);
|
||||
let o = RenderOpts::default();
|
||||
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/lines.mov"})).unwrap();
|
||||
assert!(red(px(&s, outer, 0.05, o, 1, 0)) && blue(px(&s, outer, 0.05, o, 1, 1)), "progressive");
|
||||
s.execute("file.interpretProxy", json!({"item": inner.0, "fields": "upper"})).unwrap();
|
||||
// At 0.05 s the lower field (odd rows, blue) is shown, with the even rows interpolated.
|
||||
for y in 0..3 {
|
||||
assert!(blue(px(&s, outer, 0.05, o, 1, y)), "lower field at row {y}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame Blending on a precomp layer blends the frames of the composition's proxy.
|
||||
#[test]
|
||||
fn comp_proxy_frames_blend() {
|
||||
let mut s = session();
|
||||
let (inner, outer) = nested(&mut s);
|
||||
let o = RenderOpts::default();
|
||||
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/flash.mov"})).unwrap();
|
||||
assert!(red(px(&s, outer, 0.05, o, 1, 1)));
|
||||
s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap();
|
||||
s.execute("comp.setSwitch", json!({"switch": "frameBlending", "value": true})).unwrap();
|
||||
// Halfway between the red first frame and the blue second one.
|
||||
let p = px(&s, outer, 0.05, o, 1, 1);
|
||||
assert!((p[0] - 0.5).abs() < 0.05 && (p[2] - 0.5).abs() < 0.05 && p[3] > 0.99, "{p:?}");
|
||||
assert!(blue(px(&s, outer, 0.1, o, 1, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comp_proxy_frame_blend_cache_tracks_alpha_interpretation() {
|
||||
struct Interpreted(std::sync::atomic::AtomicUsize);
|
||||
impl FootageSource for Interpreted {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let pixel = match f.alpha {
|
||||
AlphaMode::Straight => [0.4, 0.2, 0.1, 0.5],
|
||||
AlphaMode::Premultiplied => [f.premul_color[0] * 0.5, 0.1, 0.0, 0.5],
|
||||
AlphaMode::Ignore => [0.8, 0.4, 0.2, 1.0],
|
||||
};
|
||||
Some(Arc::new(Image::filled(4, 4, pixel)))
|
||||
}
|
||||
}
|
||||
for mode in ["frameMix", "pixelMotion"] {
|
||||
let mut s = session();
|
||||
let footage = Arc::new(Interpreted(std::sync::atomic::AtomicUsize::new(0)));
|
||||
s.footage = footage.clone();
|
||||
let (inner, outer) = nested(&mut s);
|
||||
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/alpha.mov"})).unwrap();
|
||||
s.execute("file.interpretProxy", json!({"item": inner.0, "alpha": "straight"})).unwrap();
|
||||
s.execute("layer.frameBlending", json!({"mode": mode})).unwrap();
|
||||
s.execute("comp.setSwitch", json!({"switch": "frameBlending", "value": true})).unwrap();
|
||||
let opts = RenderOpts::default();
|
||||
let first = px(&s, outer, 0.05, opts, 1, 1);
|
||||
let calls = footage.0.load(std::sync::atomic::Ordering::Relaxed);
|
||||
assert_eq!(px(&s, outer, 0.05, opts, 1, 1), first);
|
||||
assert_eq!(footage.0.load(std::sync::atomic::Ordering::Relaxed), calls);
|
||||
for params in [
|
||||
json!({"item": inner.0, "alpha": "ignore"}),
|
||||
json!({"item": inner.0, "alpha": "premultiplied", "premulColor": [0.0, 0.0, 0.0]}),
|
||||
json!({"item": inner.0, "alpha": "premultiplied", "premulColor": [1.0, 0.0, 0.0]}),
|
||||
] {
|
||||
s.execute("file.interpretProxy", params.clone()).unwrap();
|
||||
let mut fresh = s.renderer(opts);
|
||||
fresh.cache = None;
|
||||
let want = fresh.comp_frame(outer, Tick::from_seconds_f64(0.05)).get(1, 1);
|
||||
let actual = px(&s, outer, 0.05, opts, 1, 1);
|
||||
assert_eq!(actual, want, "{mode} after {params}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_proxy_settings_apply_to_the_proxy() {
|
||||
let mut s = session();
|
||||
@@ -198,6 +316,38 @@ fn interpret_proxy_settings_apply_to_the_proxy() {
|
||||
assert_eq!(f.alpha, AlphaMode::Straight, "the main footage keeps its interpretation");
|
||||
}
|
||||
|
||||
/// Agents and the control channel run commands through `execute_checked`, which refuses keys a
|
||||
/// params doc does not list. The Interpret Footage commands list every key they read.
|
||||
#[test]
|
||||
fn interpret_commands_accept_their_keys_when_checked() {
|
||||
let mut s = session();
|
||||
let mov = import(&mut s, "/m/lines.mov");
|
||||
let b = import(&mut s, "/m/red16.png");
|
||||
s.execute("file.setProxy", json!({"item": mov.0, "path": "/m/lines.mov"})).unwrap();
|
||||
s.execute("file.setProxy", json!({"item": b.0, "path": "/m/matte_black.png"})).unwrap();
|
||||
s.execute_checked("file.interpretProxy", json!({"item": mov.0, "preserveRgb": true, "overrideStart": true, "startTimecode": 25})).unwrap();
|
||||
let px = s.project.item(mov).unwrap().proxy.clone().unwrap().footage;
|
||||
assert!(px.preserve_rgb);
|
||||
assert_eq!(px.start_timecode, Some(25));
|
||||
s.execute_checked("file.interpretProxy", json!({"item": mov.0, "overrideStart": false})).unwrap();
|
||||
assert_eq!(s.project.item(mov).unwrap().proxy.as_ref().unwrap().footage.start_timecode, None);
|
||||
s.execute_checked("file.interpretProxy", json!({"item": b.0, "guessAlpha": true})).unwrap();
|
||||
assert_eq!(s.project.item(b).unwrap().proxy.as_ref().unwrap().footage.alpha, AlphaMode::Premultiplied);
|
||||
s.execute_checked("file.interpretProxy", json!({"item": b.0, "premulColor": [1.0, 0.0, 0.0]})).unwrap();
|
||||
assert_eq!(s.project.item(b).unwrap().proxy.as_ref().unwrap().footage.premul_color, [1.0, 0.0, 0.0]);
|
||||
// `item` names one footage item, as `items` names several.
|
||||
s.execute_checked("file.interpretFootage", json!({"item": b.0, "invertAlpha": true, "premulColor": [0.0, 1.0, 0.0]})).unwrap();
|
||||
s.execute_checked("file.rememberInterpretation", json!({"item": b.0})).unwrap();
|
||||
s.execute_checked("file.applyInterpretation", json!({"item": mov.0})).unwrap();
|
||||
let f = match &s.project.item(mov).unwrap().kind {
|
||||
ItemKind::Footage(f) => f.clone(),
|
||||
_ => panic!("not footage"),
|
||||
};
|
||||
assert!(f.invert_alpha);
|
||||
assert_eq!(f.premul_color, [0.0, 1.0, 0.0]);
|
||||
assert_eq!(s.execute_checked("file.reloadFootage", json!({"item": mov.0})).unwrap()["reloaded"], 1);
|
||||
}
|
||||
|
||||
/// Separate Fields: each field becomes a frame at twice the frame rate, the other field's lines
|
||||
/// interpolated; the dominant field comes first.
|
||||
#[test]
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::sync::Arc;
|
||||
use effectcraft_geom::{Vec3, vec3};
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, LayerSource, LightKind, Project};
|
||||
use effectcraft_render::three_d::camera::layer_frame;
|
||||
use effectcraft_render::three_d::camera::{is_two_node, layer_frame};
|
||||
use effectcraft_render::{EvalCtx, FootageSource, RenderOpts};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
@@ -40,7 +40,7 @@ fn close3(a: [f64; 3], b: [f64; 3], tol: f64) -> bool {
|
||||
fn frame(s: &Session, id: u64) -> (Vec3, Vec3) {
|
||||
let c = cid(s);
|
||||
let comp = s.project.comp(c).unwrap();
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None };
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &[], media_overrides: &[] };
|
||||
let (e, f, _) = layer_frame(&ctx, comp.layer(LayerId(id)).unwrap());
|
||||
(e, f)
|
||||
}
|
||||
@@ -217,8 +217,189 @@ fn control_light_with_camera_follows_and_releases() {
|
||||
assert!(s.execute("light.controlWithCamera", json!({"layers": [cam]})).is_err());
|
||||
}
|
||||
|
||||
/// Parenting keeps a camera where it is (After Effects' Parent & Link): Position and Point of
|
||||
/// Interest are re-expressed in the null's space, so the frame does not move.
|
||||
#[test]
|
||||
fn parented_camera_keeps_its_frame_under_an_offset_null() {
|
||||
let mut s = session();
|
||||
let cam = camera(&mut s);
|
||||
let (eye0, f0) = frame(&s, cam);
|
||||
let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap();
|
||||
let (eye1, f1) = frame(&s, cam);
|
||||
assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?}");
|
||||
// The null's origin is Position minus its default anchor (50, 50): (50, 10) in the comp.
|
||||
let c = layer_in(&s, cid(&s), cam);
|
||||
assert!(close3(v3(&c, "transform/position"), [270.0, 170.0, -800.0], 1e-6), "{:?}", v3(&c, "transform/position"));
|
||||
assert!(close3(v3(&c, "transform/poi"), [270.0, 170.0, 0.0], 1e-6), "{:?}", v3(&c, "transform/poi"));
|
||||
// Moving the null carries the camera and keeps its aim.
|
||||
s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [140, 90, 0]})).unwrap();
|
||||
let (eye2, f2) = frame(&s, cam);
|
||||
assert!((eye2 - (eye0 + vec3(40.0, 30.0, 0.0))).length() < 1e-6, "{eye2:?}");
|
||||
assert!((f2 - f0).length() < 1e-6, "{f2:?}");
|
||||
}
|
||||
|
||||
/// World (eye, forward, down) of a layer in the active comp at time `t`.
|
||||
fn frame_at(s: &Session, id: u64, t: Tick) -> (Vec3, Vec3, Vec3) {
|
||||
let c = cid(s);
|
||||
let comp = s.project.comp(c).unwrap();
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None, property_overrides: &[], media_overrides: &[] };
|
||||
layer_frame(&ctx, comp.layer(LayerId(id)).unwrap())
|
||||
}
|
||||
|
||||
/// Whether two frames agree on eye, forward and down.
|
||||
fn frames_match(a: (Vec3, Vec3, Vec3), b: (Vec3, Vec3, Vec3)) -> bool {
|
||||
(a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-6 && (a.2 - b.2).length() < 1e-6
|
||||
}
|
||||
|
||||
/// A 3D null off-centre in the comp, with an Orientation and a Z rotation.
|
||||
fn rotated_null(s: &mut Session, orientation: [f64; 3], turn: f64) -> u64 {
|
||||
let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [nul], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 40]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": nul, "path": "transform/orientation", "value": orientation})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": turn})).unwrap();
|
||||
nul
|
||||
}
|
||||
|
||||
/// A two-node camera under a rotated null keeps its eye, aim and roll: its Orientation is
|
||||
/// re-expressed against the new aim, so Up does not drift.
|
||||
#[test]
|
||||
fn two_node_camera_under_rotated_null_keeps_its_roll() {
|
||||
let mut s = session();
|
||||
let cam = camera(&mut s);
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "transform/rotation", "value": 25})).unwrap();
|
||||
assert!(is_two_node(&layer_in(&s, cid(&s), cam)));
|
||||
let before = frame_at(&s, cam, Tick::ZERO);
|
||||
let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0);
|
||||
s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap();
|
||||
let after = frame_at(&s, cam, Tick::ZERO);
|
||||
assert!(frames_match(before, after), "{before:?} {after:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parented_point_light_keeps_its_direction_under_a_rotated_null() {
|
||||
let mut s = session();
|
||||
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap();
|
||||
let (eye0, f0) = frame(&s, light);
|
||||
let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": 30})).unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
|
||||
let (eye1, f1) = frame(&s, light);
|
||||
assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?} {f0:?} {f1:?}");
|
||||
}
|
||||
|
||||
/// Keyed Orientation under a Z-only turn is shifted exactly, so the light keeps its direction at
|
||||
/// every time, between the keys too.
|
||||
#[test]
|
||||
fn keyed_orientation_under_a_z_turn_keeps_the_direction_at_every_time() {
|
||||
let mut s = session();
|
||||
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] {
|
||||
s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64);
|
||||
let before = times.map(|t| frame_at(&s, light, t));
|
||||
let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 30.0);
|
||||
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
|
||||
for (t, b) in times.into_iter().zip(before) {
|
||||
assert!(frames_match(b, frame_at(&s, light, t)), "a Z-only turn keeps the light's direction");
|
||||
}
|
||||
}
|
||||
|
||||
/// Under a tilted null the keys alone cannot follow (Euler angles are blended between them), so keys are
|
||||
/// added between them: the light keeps its direction within a small angle at every time.
|
||||
#[test]
|
||||
fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() {
|
||||
{
|
||||
let mut s = session();
|
||||
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.5, json!([50.0, 118.0, 74.0]))] {
|
||||
s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
let times = [0.0, 0.2, 0.5, 0.9, 1.3, 1.5].map(Tick::from_seconds_f64);
|
||||
let before = times.map(|t| frame_at(&s, light, t));
|
||||
let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0);
|
||||
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
|
||||
for (t, b) in times.into_iter().zip(before) {
|
||||
let a = frame_at(&s, light, t);
|
||||
// 0.05 degrees is about 9e-4 of a radian on a unit vector.
|
||||
assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 2e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1);
|
||||
}
|
||||
assert!(layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len() > 2, "keys were added between the two");
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-node light parented under a mirrored null (Z scale -100%) keeps the Position and
|
||||
/// Orientation it was authored with: it has no Scale to carry the mirror, so none of it is
|
||||
/// compensated rather than reversing its frame (a half-compensated light would point backwards).
|
||||
#[test]
|
||||
fn one_node_light_under_a_mirrored_null_keeps_its_authored_values() {
|
||||
let mut s = session();
|
||||
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap();
|
||||
let before = layer_in(&s, cid(&s), light);
|
||||
let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 0.0);
|
||||
s.execute("prop.set", json!({"layer": nul, "path": "transform/scale", "value": [100, 100, -100]})).unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
|
||||
let after = layer_in(&s, cid(&s), light);
|
||||
assert_eq!(after.parent, Some(LayerId(nul)));
|
||||
assert_eq!(v3(&after, "transform/position"), v3(&before, "transform/position"));
|
||||
assert_eq!(v3(&after, "transform/orientation"), v3(&before, "transform/orientation"));
|
||||
}
|
||||
|
||||
/// The cap on keys added by baking is for the whole Orientation property, not for each segment:
|
||||
/// many strongly eased segments under a tilted parent stay within it.
|
||||
#[test]
|
||||
fn baked_orientation_keys_are_capped_across_the_whole_property() {
|
||||
let mut s = session();
|
||||
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
|
||||
let count = 120;
|
||||
for i in 0..count {
|
||||
let swing = if i % 2 == 0 { json!([0.0, 0.0, 0.0]) } else { json!([170.0, 60.0, 120.0]) };
|
||||
s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": i as f64 * 0.05, "value": swing})).unwrap();
|
||||
}
|
||||
let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid;
|
||||
let keys: Vec<_> = (0..count).map(|i| json!({"layer": light, "prop": uid, "time": i as f64 * 0.05})).collect();
|
||||
s.execute("keys.select", json!({"keys": keys})).unwrap();
|
||||
s.execute("keys.easyEase", json!({})).unwrap();
|
||||
let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0);
|
||||
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
|
||||
let n = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len();
|
||||
assert!(n <= 4096, "{n} keys: the cap is for the property, not per segment");
|
||||
// the segments the cap left unchecked keep their authored easing instead of turning into straight blends
|
||||
let orientation = layer_in(&s, cid(&s), light);
|
||||
let keys = &orientation.props.prop("transform/orientation").unwrap().keys;
|
||||
let last = keys.iter().rev().nth(1).expect("the penultimate key");
|
||||
assert_ne!(last.out_interp, effectcraft_keyframe::Interp::Linear, "the last segment keeps its authored easing");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- from 3D model
|
||||
|
||||
/// Easy Ease agrees with a straight blend at the midpoint, but not at the quarter points.
|
||||
#[test]
|
||||
fn eased_orientation_under_x_parent_keeps_motion_between_keys() {
|
||||
let mut s = session();
|
||||
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (time, value) in [(0.0, json!([0.0, 0.0, 0.0])), (1.0, json!([90.0, 0.0, 0.0]))] {
|
||||
s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid;
|
||||
s.execute("keys.select", json!({"keys": [{"layer": light, "prop": uid, "time": 0.0}, {"layer": light, "prop": uid, "time": 1.0}]})).unwrap();
|
||||
s.execute("keys.easyEase", json!({})).unwrap();
|
||||
let times: Vec<_> = (0..=100).map(|i| Tick::from_seconds_f64(i as f64 / 100.0)).collect();
|
||||
let before: Vec<_> = times.iter().map(|t| frame_at(&s, light, *t)).collect();
|
||||
let parent = rotated_null(&mut s, [30.0, 0.0, 0.0], 0.0);
|
||||
s.execute("layer.setParent", json!({"layers": [light], "parent": parent})).unwrap();
|
||||
for (t, b) in times.into_iter().zip(before) {
|
||||
let a = frame_at(&s, light, t);
|
||||
assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1);
|
||||
}
|
||||
}
|
||||
|
||||
struct DiskModels;
|
||||
|
||||
impl FootageSource for DiskModels {
|
||||
|
||||
@@ -164,6 +164,33 @@ fn alpha_at(s: &Session, f: u32) -> Vec<f32> {
|
||||
img.data.iter().map(|p| p[3]).collect()
|
||||
}
|
||||
|
||||
/// Green, then Alt (red) over part of the object, then green there again: the last stroke wins,
|
||||
/// in the render and through Undo / Redo (#1646).
|
||||
#[test]
|
||||
fn roto_brush_strokes_correct_earlier_ones() {
|
||||
let (mut s, clip, _) = painted(center, "disk-1646.mov");
|
||||
let c = center(0);
|
||||
let spot = [c[0], c[1] + 25.0];
|
||||
let i = spot[1] as usize * W as usize + spot[0] as usize;
|
||||
let paint = |s: &mut Session, kind: &str| {
|
||||
s.execute_checked(
|
||||
"roto.stroke",
|
||||
json!({"layer": clip.0, "kind": kind, "points": line([spot[0] - 6.0, spot[1]], [spot[0] + 6.0, spot[1]]), "radius": 7}),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
assert!(alpha_at(&s, 0)[i] > 0.5, "the spot starts inside the matte");
|
||||
paint(&mut s, "bg");
|
||||
assert!(alpha_at(&s, 0)[i] < 0.5, "Alt removes it");
|
||||
paint(&mut s, "fg");
|
||||
assert!(alpha_at(&s, 0)[i] > 0.5, "green over the Alt stroke restores it");
|
||||
assert!(iou_at(&mut s, clip, 0, c) > 0.95);
|
||||
assert!(s.undo());
|
||||
assert!(alpha_at(&s, 0)[i] < 0.5, "undo brings the removal back");
|
||||
assert!(s.redo());
|
||||
assert!(alpha_at(&s, 0)[i] > 0.5, "redo restores it again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roto_brush_segments_and_propagates() {
|
||||
let (mut s, clip, uid) = painted(center, "disk-a.mov");
|
||||
|
||||
@@ -104,10 +104,11 @@ fn numbered_stills_import_as_stills_by_default() {
|
||||
let r = s.execute("file.import", json!({"paths": [dir.clone()], "drag": true})).unwrap();
|
||||
let (name, f) = footage(&s, &r["items"][0]);
|
||||
assert_eq!((name.as_str(), f.kind), ("shot_[0001-0005].png", FootageKind::Sequence));
|
||||
// Scripts and agents naming one frame without `sequence` still get its run.
|
||||
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0003.png")]})).unwrap();
|
||||
let (_, f) = footage(&s, &r["items"][0]);
|
||||
assert_eq!(f.kind, FootageKind::Sequence);
|
||||
// #2018: scripts and agents naming one frame without `sequence` get that still, as
|
||||
// `ImportOptions.sequence` defaults to false in After Effects.
|
||||
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0005.png")]})).unwrap();
|
||||
let (name, f) = footage(&s, &r["items"][0]);
|
||||
assert_eq!((name.as_str(), f.kind), ("shot_0005.png", FootageKind::Still));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -181,14 +182,58 @@ fn interpret_footage_start_timecode_relabels_source_time() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_footage_and_reload_keep_sequences() {
|
||||
fn replace_footage_keeps_a_still_a_still() {
|
||||
let dir = folder(&RUN);
|
||||
let mut s = session();
|
||||
let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": false})).unwrap()["items"][0].clone();
|
||||
s.state.project_selection = vec![ItemId(item.as_u64().unwrap())];
|
||||
// Replacing with a numbered still does not silently turn the item into a sequence (#2073).
|
||||
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png")})).unwrap();
|
||||
let (name, f) = footage(&s, &item);
|
||||
assert_eq!((name.as_str(), f.sequence.len()), ("shot_[0001-0005].png", 4));
|
||||
assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still));
|
||||
// JSON null is unspecified, not "on": the still stays a still.
|
||||
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "sequence": null})).unwrap();
|
||||
let (name, f) = footage(&s, &item);
|
||||
assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still));
|
||||
// Alphabetical is a sequence modifier, not a request to make one.
|
||||
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "alphabetical": true})).unwrap();
|
||||
let (name, f) = footage(&s, &item);
|
||||
assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still));
|
||||
// Asking for the run still brings it.
|
||||
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "sequence": true})).unwrap();
|
||||
let (name, f) = footage(&s, &item);
|
||||
assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("shot_[0001-0005].png", FootageKind::Sequence, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_footage_keeps_a_one_frame_run_a_sequence() {
|
||||
let dir = folder(&["shot_0001.png", "shot_0002.png", "solo_0007.png", "cover.png"]);
|
||||
let mut s = session();
|
||||
let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": true})).unwrap()["items"][0].clone();
|
||||
s.state.project_selection = vec![ItemId(item.as_u64().unwrap())];
|
||||
// A numbered file with no other frame in its run is still the replacement for a sequence.
|
||||
s.execute("file.replaceFootage", json!({"path": at(&dir, "solo_0007.png")})).unwrap();
|
||||
let (name, f) = footage(&s, &item);
|
||||
assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("solo_0007.png", FootageKind::Sequence, 1));
|
||||
// A single-file source keeps the sequence kind too.
|
||||
s.execute("file.replaceFootage", json!({"path": at(&dir, "cover.png")})).unwrap();
|
||||
let (name, f) = footage(&s, &item);
|
||||
assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("cover.png", FootageKind::Sequence, 1));
|
||||
// An explicit `sequence: false` still asks for one frame.
|
||||
s.execute("file.replaceFootage", json!({"path": at(&dir, "cover.png"), "sequence": false})).unwrap();
|
||||
let (_, f) = footage(&s, &item);
|
||||
assert_eq!((f.kind, f.sequence.len()), (FootageKind::Still, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_footage_and_reload_keep_sequences() {
|
||||
let dir = folder(&RUN);
|
||||
let mut s = session();
|
||||
let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": true})).unwrap()["items"][0].clone();
|
||||
s.state.project_selection = vec![ItemId(item.as_u64().unwrap())];
|
||||
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png")})).unwrap();
|
||||
let (name, f) = footage(&s, &item);
|
||||
assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("shot_[0001-0005].png", FootageKind::Sequence, 4));
|
||||
// Reload finds a new frame and keeps the interpretation.
|
||||
s.execute("file.interpretFootage", json!({"items": [item], "frameRate": 12, "startTimecode": 2})).unwrap();
|
||||
std::fs::write(at(&dir, "shot_0003.png"), b"").unwrap();
|
||||
|
||||
@@ -389,6 +389,23 @@ fn new_project_template_and_default_renderer() {
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_files_are_indented_unless_compact_is_on() {
|
||||
let d = tmp("compact-project-files");
|
||||
let path = d.join("p.ecproj");
|
||||
let mut s = session_with_comp();
|
||||
s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let pretty = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(pretty.starts_with("{\n \"savedBy\""), "indented by default");
|
||||
s.execute("prefs.set", json!({"key": "project.compactProjectFiles", "value": true})).unwrap();
|
||||
s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let compact = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(!compact.contains('\n'), "one line when Save Compact Project Files is on");
|
||||
let open = |text: &str| effectcraft_project::Project::from_json(text).unwrap();
|
||||
assert_eq!(open(&compact), open(&pretty), "both read back as the same project");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shortcuts
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -184,6 +184,28 @@ fn in_and_out_points_stretch_to_the_current_time() {
|
||||
assert!(s.execute("layer.timeStretch", json!({"to": "middle"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trimming_out_to_the_playhead_keeps_its_frame_visible() {
|
||||
let (mut s, ids) = session([(1.0, 9.0); 3]);
|
||||
let frame = 1.0 / 30.0;
|
||||
// Alt+] at 4 s: the Out edge lands one frame after the playhead, so 4 s is the layer's last
|
||||
// visible frame — it must not be cut away at the playhead (as in After Effects).
|
||||
s.execute("time.set", json!({"time": 4.0})).unwrap();
|
||||
s.execute("layer.timing", json!({"layers": [ids[0]], "op": "trimOutToTime"})).unwrap();
|
||||
assert!((span(&s, ids[0]).1 - (4.0 + frame)).abs() < 1e-9, "{:?}", span(&s, ids[0]));
|
||||
// Alt+[ at 2 s: the playhead's frame becomes the layer's first visible one.
|
||||
s.execute("time.set", json!({"time": 2.0})).unwrap();
|
||||
s.execute("layer.timing", json!({"layers": [ids[1]], "op": "trimInToTime"})).unwrap();
|
||||
assert!((span(&s, ids[1]).0 - 2.0).abs() < 1e-9, "{:?}", span(&s, ids[1]));
|
||||
// Trimmed before the In point, the Out point keeps one frame of layer.
|
||||
s.execute("time.set", json!({"time": 0.5})).unwrap();
|
||||
s.execute("layer.timing", json!({"layers": [ids[2]], "op": "trimOutToTime"})).unwrap();
|
||||
assert!((span(&s, ids[2]).1 - (1.0 + frame)).abs() < 1e-9, "{:?}", span(&s, ids[2]));
|
||||
// One undo step back to the original span.
|
||||
assert!(s.undo());
|
||||
assert!((span(&s, ids[2]).1 - 9.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_are_stepped_through_and_wrap() {
|
||||
let (mut s, ids) = session([(0.0, 10.0); 3]);
|
||||
|
||||
@@ -519,9 +519,10 @@ fn convert_to_bezier_turns_a_parametric_shape_into_an_editable_path() {
|
||||
};
|
||||
let radius = |v: [f64; 2]| v[0].hypot(v[1]);
|
||||
|
||||
// A 120 x 80 rectangle is its four corners, joined by straight edges.
|
||||
// A 120 x 80 rectangle is its four corners, joined by straight edges, from the top-right
|
||||
// corner clockwise as After Effects draws it.
|
||||
let (mut s, l, was, rect) = convert(json!({"kind": "rect", "size": [120, 80]}));
|
||||
assert_eq!(rect.vertices, vec![[-60.0, -40.0], [60.0, -40.0], [60.0, 40.0], [-60.0, 40.0]]);
|
||||
assert_eq!(rect.vertices, vec![[60.0, -40.0], [60.0, 40.0], [-60.0, 40.0], [-60.0, -40.0]]);
|
||||
assert!(rect.in_tangents.iter().chain(&rect.out_tangents).all(|t| *t == [0.0, 0.0]), "straight edges: {rect:?}");
|
||||
// Undo brings the parametric shape back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
@@ -583,3 +584,22 @@ fn convert_to_bezier_reads_the_shape_as_it_is_set() {
|
||||
let mid = |v: &[f64]| (v.iter().copied().fold(f64::MAX, f64::min) + v.iter().copied().fold(f64::MIN, f64::max)) / 2.0;
|
||||
assert!((mid(&xs) - 10.0).abs() < 1e-6 && (mid(&ys) - 5.0).abs() < 1e-6, "centred on the position: {:?} {:?}", mid(&xs), mid(&ys));
|
||||
}
|
||||
|
||||
/// #2201: a viewer drag on the free-transform box sends one `path.freeTransform` per frame with a
|
||||
/// shared merge key; one undo must revert the whole drag, not the last frame.
|
||||
#[test]
|
||||
fn free_transform_drag_undoes_in_one_step() {
|
||||
let mut s = comp();
|
||||
let (l, m) = masked(&mut s);
|
||||
s.state.selected_vertices.clear();
|
||||
let before = mask_path(&s, l, m);
|
||||
for k in [90, 90, 90, 90] {
|
||||
s.execute("path.freeTransform", json!({"layer": l, "scale": k, "merge": "viewer-drag-1"})).unwrap();
|
||||
}
|
||||
let after = mask_path(&s, l, m);
|
||||
assert_ne!(after, before);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(mask_path(&s, l, m), before, "one undo reverts the whole drag");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(mask_path(&s, l, m), after, "redo restores the whole drag");
|
||||
}
|
||||
|
||||
@@ -421,6 +421,70 @@ fn camera_focus_commands_undo_and_serde() {
|
||||
assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok());
|
||||
}
|
||||
|
||||
/// Set Focus Distance to Layer: degenerate targets (behind the camera, exactly at it) are clean
|
||||
/// errors, and a depth-of-field frame renders right after the command.
|
||||
#[test]
|
||||
fn camera_set_focus_to_layer_degenerate_targets_and_dof_frame() {
|
||||
let mut s = comp(400, 300);
|
||||
let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap();
|
||||
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [200, 150, -500]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": true})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [cam, t]})).unwrap();
|
||||
// Behind the camera (which now sits at z = −500 looking towards +z).
|
||||
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -1000]})).unwrap();
|
||||
let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("behind the camera"), "{e}");
|
||||
// Exactly at the camera: depth 0, still a clean error.
|
||||
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -500]})).unwrap();
|
||||
assert!(s.execute("camera.setFocusToLayer", json!({})).is_err());
|
||||
// In front: the command works, and the depth-of-field frame renders with finite pixels.
|
||||
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 400]})).unwrap();
|
||||
let d = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap();
|
||||
assert!((d - 900.0).abs() < 1e-6, "{d}");
|
||||
// An out-of-focus 3D layer, which must visibly blur, and a 0%-scaled one, whose pixels cover
|
||||
// ~0 screen pixels and so turn an ordinary defocus into a radius of ~1e9 buffer pixels.
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/aperture", "value": 120})).unwrap();
|
||||
let blur = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 60, "height": 60})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [blur], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.setTransform", json!({"layer": blur, "prop": "position", "value": [200, 150, 1200]})).unwrap();
|
||||
let invis = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [invis], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.setTransform", json!({"layer": invis, "prop": "position", "value": [200, 150, 1600]})).unwrap();
|
||||
s.execute("layer.setTransform", json!({"layer": invis, "prop": "scale", "value": [0.0, 0.0, 0.0]})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let img = s.render(cid, effectcraft_time::Tick::ZERO, Default::default());
|
||||
assert!(img.data.iter().all(|p| p.iter().all(|c| c.is_finite())), "non-finite pixels after setFocusToLayer");
|
||||
// The DoF frame actually blurred (not a no-op blur): the same frame with DoF off differs.
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": false})).unwrap();
|
||||
let plain = s.render(cid, effectcraft_time::Tick::ZERO, Default::default());
|
||||
assert_ne!(img.data, plain.data, "the out-of-focus layer should have blurred");
|
||||
}
|
||||
|
||||
/// Set Focus Distance to Layer fails cleanly on a depth that isn't finite. Every stored number
|
||||
/// here is finite, but their difference overflows f64 to infinity; JSON has no infinity, so
|
||||
/// storing it would leave a project that can't be saved.
|
||||
#[test]
|
||||
fn camera_set_focus_to_layer_rejects_non_finite_depth() {
|
||||
let mut s = comp(400, 300);
|
||||
let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200.0, 150.0, 1e308]})).unwrap();
|
||||
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setTransform", json!({"layer": cam, "prop": "position", "value": [200.0, 150.0, -1e308]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [cam, t]})).unwrap();
|
||||
let before = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
|
||||
let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("out of range"), "{e}");
|
||||
let after = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
|
||||
assert!((after - before).abs() < 1e-9, "a non-finite focus distance was written: {after}");
|
||||
// The project still round-trips through serde (a stored infinity would come back as null).
|
||||
let back = roundtrip(&s);
|
||||
let f = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(cam)).unwrap().props.prop("cameraOptions/focusDistance").unwrap().value.as_f64();
|
||||
assert!((f - before).abs() < 1e-9, "serde focus distance: {f}");
|
||||
}
|
||||
|
||||
/// An id sent as a string (`"98"`, as MCP clients often do) resolves as an id when no name matches.
|
||||
#[test]
|
||||
fn numeric_string_resolves_as_id() {
|
||||
|
||||
@@ -199,6 +199,20 @@ fn typing_deleting_and_caret_movement() {
|
||||
assert!(s.state.text_edit.is_none());
|
||||
}
|
||||
|
||||
/// Duplicating during a text edit moves the selection to the copy: the edit on the original ends
|
||||
/// (#2226), as in After Effects — typing must not keep editing an unselected layer.
|
||||
#[test]
|
||||
fn duplicate_during_a_text_edit_ends_the_edit() {
|
||||
let (mut s, t) = session("Hello");
|
||||
s.execute("text.edit", json!({"layer": t, "caret": 5})).unwrap();
|
||||
let copy = s.execute("edit.duplicate", json!({"layers": [t]})).unwrap()[0].as_u64().unwrap();
|
||||
assert_ne!(copy, t, "the duplicate is created");
|
||||
assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "the copy is selected");
|
||||
assert!(s.state.text_edit.is_none(), "the edit ended with the selection move (#2226)");
|
||||
assert_eq!(doc(&s, t).text, "Hello", "the original keeps its text");
|
||||
assert_eq!(doc(&s, copy).text, "Hello", "and the copy carries it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_style_applies_to_the_next_typed_text() {
|
||||
let (mut s, t) = session("ab");
|
||||
@@ -260,25 +274,25 @@ fn clipboard_and_paste_formatting() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_tool_layers_point_box_and_empty_cleanup() {
|
||||
fn type_tool_layers_point_box_and_empty_layer_is_kept() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 4})).unwrap();
|
||||
let t = s.execute("layer.newText", json!({"text": "", "position": [100, 200], "justify": "left", "edit": true})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_eq!(s.state.text_edit.as_ref().map(|e| e.layer.0), Some(t));
|
||||
// Leaving an untouched Type-tool layer deletes it.
|
||||
// Leaving an untouched Type-tool layer keeps it, as After Effects does.
|
||||
s.execute("text.endEdit", json!({})).unwrap();
|
||||
assert!(s.active_comp().unwrap().layers.is_empty());
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 1, "an empty Type-tool layer is kept, as in AE");
|
||||
let b = s.execute("layer.newText", json!({"text": "", "box": [40, 50, 300, 120], "edit": true})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("text.insert", json!({"text": "Boxed"})).unwrap();
|
||||
let d = doc(&s, b);
|
||||
assert_eq!(d.box_size, Some([300.0, 120.0]));
|
||||
assert_eq!(d.box_pos, [-150.0, -60.0]);
|
||||
let l = s.active_comp().unwrap().layers[0].clone();
|
||||
let l = s.active_comp().unwrap().layers.iter().find(|l| l.id.0 == b).expect("the boxed layer").clone();
|
||||
assert_eq!(l.name, "Boxed", "named after its text");
|
||||
let pos = l.props.prop("transform/position").unwrap().value.as_vec3();
|
||||
assert_eq!([pos[0], pos[1]], [190.0, 110.0]);
|
||||
s.execute("text.endEdit", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 2, "both text layers stay, the empty one included");
|
||||
// Resizing the box (layer space) is an attribute too.
|
||||
s.execute("layer.setText", json!({"layer": b, "box": [-100, -60, 200, 120]})).unwrap();
|
||||
assert_eq!(doc(&s, b).box_size, Some([200.0, 120.0]));
|
||||
@@ -928,11 +942,19 @@ fn fonts_lists_bundled_and_installed_families() {
|
||||
let r = s.execute("text.fonts", json!({"query": "noto ser", "rescan": true})).unwrap();
|
||||
assert_eq!(r["families"][0]["family"], "Noto Serif");
|
||||
// The bundled face is Regular; the OS can also provide Bold, Italic, or other faces.
|
||||
// Compare against the independent directory inventory rather than assuming an empty OS.
|
||||
let mut expected_styles = std::collections::BTreeSet::from(["Regular".to_string()]);
|
||||
expected_styles.extend(installed.iter().filter(|face| face.family.eq_ignore_ascii_case("Noto Serif")).map(|face| face.style.clone()));
|
||||
// Model `add_source()` on the independent directory inventory: a system face whose family
|
||||
// and primary style duplicate an earlier face is skipped, with its named instances.
|
||||
let mut expected_styles = std::collections::BTreeSet::from(["regular".to_string()]);
|
||||
let mut retained = std::collections::BTreeSet::from([("noto serif".to_string(), "regular".to_string())]);
|
||||
for face in installed.iter().filter(|face| face.family.eq_ignore_ascii_case("Noto Serif")) {
|
||||
if retained.insert((face.family.to_ascii_lowercase(), face.style.to_ascii_lowercase())) {
|
||||
expected_styles.insert(face.style.to_ascii_lowercase());
|
||||
expected_styles.extend(face.instances.iter().map(|i| i.style.to_ascii_lowercase()));
|
||||
}
|
||||
}
|
||||
let styles: Vec<String> = r["families"][0]["styles"].as_array().unwrap().iter().map(|style| style.as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(styles.iter().cloned().collect::<std::collections::BTreeSet<_>>(), expected_styles);
|
||||
// `families()` keeps the first spelling of a style that differs only in case.
|
||||
assert_eq!(styles.iter().map(|style| style.to_ascii_lowercase()).collect::<std::collections::BTreeSet<_>>(), expected_styles);
|
||||
assert_eq!(styles.len(), expected_styles.len(), "style menu has no duplicates");
|
||||
assert!(r["families"].as_array().unwrap().iter().all(|f| f["family"].as_str().unwrap().to_lowercase().contains("noto ser")));
|
||||
// The style menus offer a family's own styles.
|
||||
@@ -1202,7 +1224,7 @@ fn deselect_all_ends_the_open_edit() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deselect_all_discards_an_empty_type_tool_layer() {
|
||||
fn deselect_all_keeps_an_empty_type_tool_layer() {
|
||||
let (mut s, _keep) = session("Hello");
|
||||
let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(
|
||||
@@ -1214,7 +1236,5 @@ fn deselect_all_discards_an_empty_type_tool_layer() {
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
assert!(s.state.text_edit.is_none());
|
||||
assert!(s.state.selected_layers.is_empty());
|
||||
assert_eq!(count(&s), before - 1, "a Type-tool layer left empty is removed, as text.endEdit does");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(count(&s), before, "that removal is one undo step of its own, as on text.endEdit");
|
||||
assert_eq!(count(&s), before, "an empty Type-tool layer is kept, as in After Effects");
|
||||
}
|
||||
|
||||
@@ -765,6 +765,59 @@ fn pick_whip_generates_ae_reference_expressions() {
|
||||
assert!(s.execute("prop.pickWhip", json!({"layer": l, "path": "transform/opacity", "target": {"layer": l, "path": "transform/opacity"}})).is_err());
|
||||
}
|
||||
|
||||
/// #1072: an Alt pick whip (`byIndex`) selects an effect's properties by their index,
|
||||
/// `effect("Color Control")(1)`, its place among its group's children (nested groups too). The
|
||||
/// effect keeps its name; other properties, dimensions, insertion into an expression and match
|
||||
/// names (Compact English off) work as before. (`effectcraft-host`'s `pick_whip` tests check the
|
||||
/// references read the right values.)
|
||||
#[test]
|
||||
fn pick_whip_by_index_selects_effect_properties_by_their_index() {
|
||||
let (mut s, l) = setup();
|
||||
let o = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for fx in ["Color Control", "Fractal Noise", "Point Control"] {
|
||||
s.execute("effect.apply", json!({"layer": o, "effect": fx})).unwrap();
|
||||
}
|
||||
// Fractal Noise ▸ Transform ▸ Scale, by its places in the property tree.
|
||||
let fx = s.active_comp().unwrap().layer(effectcraft_project::LayerId(o)).unwrap().props.sub("effects").unwrap().groups().nth(1).unwrap().clone();
|
||||
let group = fx.children.iter().position(|c| c.match_id() == "transform").unwrap() + 1;
|
||||
let scale = fx.sub("transform").unwrap().children.iter().position(|c| c.match_id() == "scale").unwrap() + 1;
|
||||
let whip = |s: &mut Session, path: &str, target: Value, extra: Value| {
|
||||
let mut p = json!({"layer": l, "path": path, "target": target, "byIndex": true});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
s.execute("prop.pickWhip", p).unwrap()
|
||||
};
|
||||
let other = "thisComp.layer(\"Other\")";
|
||||
let undo = s.history.undo.len();
|
||||
assert_eq!(
|
||||
whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#1/color"}), json!({})),
|
||||
json!(format!("{other}.effect(\"Color Control\")(1)[0]")),
|
||||
"a colour into one value: its first"
|
||||
);
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
|
||||
let nested = format!("{other}.effect(\"Fractal Noise\")({group})({scale})");
|
||||
assert_eq!(whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({})), json!(nested));
|
||||
assert_eq!(
|
||||
whip(&mut s, "transform/opacity", json!({"layer": o, "path": "effects/#3/point", "dimension": 1}), json!({})),
|
||||
json!(format!("{other}.effect(\"Point Control\")(1)[1]"))
|
||||
);
|
||||
assert_eq!(
|
||||
whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({"expression": "1 + ", "range": [4, 4]})),
|
||||
json!(format!("1 + {nested}")),
|
||||
"inserted at the cursor"
|
||||
);
|
||||
assert_eq!(
|
||||
whip(&mut s, "transform/opacity", json!({"layer": o, "path": "transform/opacity"}), json!({})),
|
||||
json!(format!("{other}.transform.opacity")),
|
||||
"only an effect's properties go by index"
|
||||
);
|
||||
// Match names (Compact English off): the effect by its match name, its properties by index.
|
||||
s.execute("prefs.set", json!({"key": "general.expressionPickWhipCompact", "value": false})).unwrap();
|
||||
let e = whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({}));
|
||||
assert!(e.as_str().unwrap().ends_with(&format!("({group})({scale})")), "{e}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, l, "transform/rotation").expr.unwrap().text, format!("1 + {nested}"), "one undo per pick");
|
||||
}
|
||||
|
||||
/// #407: Reset without a value gives a property its own default (a Transform property's, an
|
||||
/// effect parameter's) besides removing its keyframes and expression.
|
||||
#[test]
|
||||
|
||||
@@ -419,7 +419,7 @@ fn shape_tool_options_paint_new_shapes() {
|
||||
fn masks_of_every_shape_tool_kind() {
|
||||
let mut s = comp();
|
||||
let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (kind, n) in [("rect", 4), ("ellipse", 4), ("rounded", 8), ("polygon", 6), ("star", 10)] {
|
||||
for (kind, n) in [("rect", 4), ("ellipse", 4), ("rounded", 8), ("polygon", 5), ("star", 10)] {
|
||||
let m = s.execute("layer.addMask", json!({"layer": sol, "shape": kind, "rect": [100, 100, 80, 80]})).unwrap()["mask"].as_u64().unwrap();
|
||||
let path = layer(&s, sol).props.find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().clone();
|
||||
assert!(path.closed, "{kind}");
|
||||
|
||||
Reference in New Issue
Block a user