Compare commits

..

No commits in common. "ci-packaging-lint" and "timeline-keyframes" have entirely different histories.

48 changed files with 332 additions and 1700 deletions

View File

@ -221,9 +221,8 @@ jobs:
- name: Install wasm-bindgen CLI
run: |
# Must match the wasm-bindgen version in Cargo.lock exactly (cargo xtask web checks it).
if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then
cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
fi
command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION" \
|| cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
wasm-bindgen --version
- name: Apply version override
if: inputs.version != ''

70
Cargo.lock generated
View File

@ -1134,7 +1134,7 @@ dependencies = [
[[package]]
name = "effectcraft"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"cpal",
"effectcraft-engine",
@ -1155,7 +1155,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@ -1167,14 +1167,14 @@ dependencies = [
[[package]]
name = "effectcraft-av1enc"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"cpu-time",
"effectcraft-automation",
@ -1190,7 +1190,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"proptest",
"serde",
@ -1198,7 +1198,7 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1214,7 +1214,7 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1246,7 +1246,7 @@ dependencies = [
[[package]]
name = "effectcraft-export"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
@ -1275,7 +1275,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"boa_engine",
"effectcraft-color",
@ -1292,7 +1292,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"proptest",
"serde",
@ -1300,7 +1300,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"bytemuck",
"effectcraft-color",
@ -1319,14 +1319,14 @@ dependencies = [
[[package]]
name = "effectcraft-hevcenc"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@ -1348,7 +1348,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@ -1367,7 +1367,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1379,7 +1379,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1396,7 +1396,7 @@ dependencies = [
[[package]]
name = "effectcraft-media"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-model",
"effectcraft-pdf",
@ -1421,7 +1421,7 @@ dependencies = [
[[package]]
name = "effectcraft-model"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-geom",
"image",
@ -1432,14 +1432,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@ -1451,7 +1451,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-raster",
"effectcraft-svg",
@ -1465,7 +1465,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@ -1477,7 +1477,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1490,14 +1490,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "effectcraft-raster"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1506,7 +1506,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1528,7 +1528,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"boa_engine",
"effectcraft-engine",
@ -1543,7 +1543,7 @@ dependencies = [
[[package]]
name = "effectcraft-svg"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@ -1555,7 +1555,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@ -1567,7 +1567,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"proptest",
"serde",
@ -1575,7 +1575,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-raster",
"rayon",
@ -1586,7 +1586,7 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-color",
"effectcraft-engine",
@ -1614,11 +1614,11 @@ dependencies = [
[[package]]
name = "effectcraft-vp9enc"
version = "0.2.0"
version = "0.1.1"
[[package]]
name = "effectcraft-web"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@ -6318,7 +6318,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.2.0"
version = "0.1.1"
dependencies = [
"serde_json",
]

View File

@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
version = "0.2.0"
version = "0.1.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"

View File

@ -10,7 +10,7 @@ once.
| M1 | Keyframes: temporal and spatial interpolation, Easy Ease, roving, velocity | Done |
| M2 | Compositing: 38 blend modes, track mattes, parenting, adjustment layers | Done |
| M3 | Project operations: settings dialogs, layer commands, `.ecproj`, undo, After Effects menu bar | Done (unsaved-changes prompts, Solid / Layer Settings, lock-aware layer commands, safe project files; View ▸ New Viewer still opens only the one locked viewer) |
| M4 | Preview: precomps, motion blur, cached playback | Mostly done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle, motion blur of collapsed precomps and of animated shape / text content; left: content-keyed RAM preview frames) |
| M4 | Preview: precomps, motion blur, cached playback | Mostly done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle; left: motion blur of collapsed precomps and of animated layer content) |
| M5 | Timeline depth: graph editor, keyframe clipboard and dialogs, time remapping, pick-whips, expression editor | Done |
| M6 | Shapes, masks and footage: shape operators, masks and the pen tool, video and image import | Done |
| M7 | 3D: 3D layers, cameras, lights, shadows, depth of field, 3D views and camera tools | In review |
@ -33,6 +33,7 @@ Measured feature by feature in [docs/parity.md](docs/parity.md) (4 October 2026)
- **≈ 8.5 agent-hours of work remain**: about **2–3 hours of wall-clock time** with five Claude
Opus 5.5 agents in parallel, ≈ 1 hour without learned models for Roto Brush and face tracking.
- What is left: GPU kernels for the remaining visual effects, and learned-model quality for Roto
Brush and face tracking; in previews, content-keyed RAM preview frames ([parity.md](docs/parity.md#highest-value-gaps-in-order)).
Brush and face tracking; in previews, motion blur of collapsed precomps and of animated layer
content, and content-keyed RAM preview frames ([parity.md](docs/parity.md#highest-value-gaps-in-order)).
Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).

View File

@ -610,9 +610,6 @@ mod tests {
let g = run_fx("ec.keying.keylight", &[("screenColour", green()), ("screenGain", num(150.0))], plate(), 0.0, EffectEnv::default());
assert!(g.img.get(27, 5)[3] < out.img.get(27, 5)[3]);
assert_eq!(crate::lookup("Keylight (1.2)").map(|s| s.id), Some("ec.keying.keylight"));
// Searches find it by After Effects' name as well as its own.
let spec = crate::find("ec.keying.keylight").unwrap();
assert!(crate::name_matches(spec, "keylight") && crate::name_matches(spec, "key light") && !crate::name_matches(spec, "blur"));
}
/// Instances saved with the flat layout get their parameters moved into the twirl-downs.

View File

@ -504,25 +504,14 @@ pub fn categories() -> Vec<&'static str> {
v
}
/// After Effects' third-party display names of effects we register under a generic name.
const ALIASES: &[(&str, &[&str])] = &[("ec.keying.keylight", keylight::KEYLIGHT_ALIASES)];
/// Other names effect `id` answers to (in [`lookup`] and searches).
pub fn aliases(id: &str) -> &'static [&'static str] {
ALIASES.iter().find(|(i, _)| *i == id).map_or(&[], |(_, a)| a)
}
/// Search (Effects & Presets, `effect.list`): the display name or one of its [`aliases`]
/// contains `query` (lowercase).
pub fn name_matches(spec: &EffectSpec, query: &str) -> bool {
std::iter::once(spec.name).chain(aliases(spec.id).iter().copied()).any(|n| n.to_lowercase().contains(query))
}
/// Find by id or (case-insensitive) display name.
pub fn lookup(name_or_id: &str) -> Option<&'static EffectSpec> {
find(name_or_id)
.or_else(|| all().into_iter().find(|s| s.name.eq_ignore_ascii_case(name_or_id)))
.or_else(|| ALIASES.iter().find(|(_, names)| names.iter().any(|a| a.eq_ignore_ascii_case(name_or_id))).and_then(|(id, _)| find(id)))
.or_else(|| {
// After Effects' third-party display names we register under a generic name.
keylight::KEYLIGHT_ALIASES.iter().any(|a| a.eq_ignore_ascii_case(name_or_id)).then(|| find("ec.keying.keylight")).flatten()
})
.or_else(|| {
// Display names of earlier versions.
migrate::EFFECT_NAME_ALIASES.iter().find(|(old, _)| old.eq_ignore_ascii_case(name_or_id)).and_then(|(_, id)| find(id))

View File

@ -139,14 +139,7 @@ fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, mut layers) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let mut sel = vec![];
if let Some(visible) = super::visible_p(p) {
// Ctrl+Alt+A in the Timeline: every key it shows (its revealed properties).
for (lid, uid) in visible {
if let Some(pr) = comp.layer(lid).and_then(|l| l.props.find(uid)) {
sel.extend(pr.keys.iter().map(|k| KeyRef { layer: lid, prop: uid, time: k.time }));
}
}
} else if p.get("layers").is_none() && p.get("layer").is_none() && !s.state.selected_props.is_empty() {
if p.get("layers").is_none() && p.get("layer").is_none() && !s.state.selected_props.is_empty() {
for (lid, uid) in &s.state.selected_props {
if let Some(pr) = comp.layer(*lid).and_then(|l| l.props.find(*uid)) {
sel.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time }));
@ -429,15 +422,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("keys.copy", "Copy Keyframes", [], None, "{}", has_keys, copy),
cmd!("keys.paste", "Paste Keyframes", [], None, "{layers?, prop?|path?, time?}", has_key_clip, paste),
cmd!(
"keys.selectAll",
"Select All Keyframes",
[],
Some("Cmd+Alt+A"),
"{layers?, visible?: [{layer, prop}] (every key of these properties)}",
keys_or_layers,
select_all
),
cmd!("keys.selectAll", "Select All Keyframes", [], Some("Cmd+Alt+A"), "{layers?}", keys_or_layers, select_all),
cmd!("keys.nudge", "Nudge Keyframes", [], None, "{frames, merge?}", has_keys, nudge),
cmd!("keys.nudgeForward", "Move Keyframes 1 Frame Later", [], Some("Alt+ArrowRight"), "{}", has_keys, |s, _| nudge(s, &json!({"frames": 1}))),
cmd!("keys.nudgeBackward", "Move Keyframes 1 Frame Earlier", [], Some("Alt+ArrowLeft"), "{}", has_keys, |s, _| nudge(s, &json!({"frames": -1}))),

View File

@ -12,14 +12,6 @@ pub(crate) fn rate_p(p: &Value) -> Option<FrameRate> {
f_p(p, "frameRate").or(f_p(p, "fps")).map(FrameRate::from_f64)
}
/// A zero, negative or tiny rate would make a zero-length frame (and divide by zero later).
fn check_rate(p: &Value, cmd: &str) -> Result<()> {
match f_p(p, "frameRate").or(f_p(p, "fps")) {
Some(f) if !(0.001..=1000.0).contains(&f) => Err(bad(cmd, "frameRate: fps from 0.001 to 1000")),
_ => Ok(()),
}
}
fn apply_settings(c: &mut Comp, p: &Value) {
let (ow, oh) = (c.width, c.height);
if let Some(w) = p.get("width").and_then(Value::as_u64) {
@ -107,7 +99,6 @@ fn apply_settings(c: &mut Comp, p: &Value) {
}
fn new_comp(s: &mut Session, p: &Value) -> Result<Value> {
check_rate(p, "comp.new")?;
let name = str_p(p, "name").unwrap_or("Comp 1").to_string();
let rate = rate_p(p).unwrap_or(FrameRate::FPS_29_97);
let mut c = Comp::new(1920, 1080, rate, rate.snap_nearest(Tick::from_seconds_f64(10.0)));
@ -134,7 +125,6 @@ fn new_comp(s: &mut Session, p: &Value) -> Result<Value> {
}
fn settings(s: &mut Session, p: &Value) -> Result<Value> {
check_rate(p, "comp.settings")?;
let cid = comp_id(s, p)?;
s.edit("Composition Settings", None, |proj, _| {
if let Some(n) = str_p(p, "name")

View File

@ -4,7 +4,7 @@ use effectcraft_project::build::Ids;
use effectcraft_project::{GroupKind, LayerId, PropGroup, Uid};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, has_layers, layer_mut, layers_p, str_p};
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
/// `effect.plugins.list`: the registered plug-in effects (WebAssembly or Rust).
@ -132,52 +132,6 @@ fn find_fx(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project::It
Ok((cid, lid, g.uid))
}
/// A colour parameter's eyedropper on a keyer (Effect Controls): the colour of the effect's input
/// (the layer's pixels before this effect, so a keyed screen can still be picked) at a
/// layer-space point, set on the parameter. `average` takes the 5 × 5 pixels around it.
fn pick_color(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "effect.pickColor";
let (cid, lid, fx_uid) = find_fx(s, p, cmd)?;
let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) };
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let fx = layer.effects().ok_or_else(|| bad(cmd, "no effects"))?;
let index = fx.groups().position(|g| g.uid == fx_uid).ok_or_else(|| bad(cmd, "no such effect"))?;
let g = fx.groups().nth(index).ok_or_else(|| bad(cmd, "no such effect"))?;
let prop = match (p.get("prop").and_then(Value::as_u64), str_p(p, "param")) {
(Some(uid), _) => g.find(uid),
(None, Some(path)) => g.prop(path),
_ => return Err(bad(cmd, "missing `param` (e.g. `screenColour`) or `prop` (uid)")),
}
.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 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);
let buf = r.layer_input(&ctx, layer, index).ok_or_else(|| bad(cmd, "this layer has no pixels"))?;
let (px, py) = buf.to_px([x, y]);
let (cx, cy) = (px.floor() as i64, py.floor() as i64);
let rad = if b_p(p, "average") == Some(true) { 2 } else { 0 };
let mut sum = [0.0f32; 4];
for yy in cy.saturating_sub(rad)..=cy.saturating_add(rad) {
for xx in cx.saturating_sub(rad)..=cx.saturating_add(rad) {
// Outside the layer reads as transparent.
let c = buf.img.get(xx, yy);
(0..4).for_each(|i| sum[i] += c[i]);
}
}
if sum[3] <= 1e-6 {
return Err(bad(cmd, "nothing to sample there (transparent or outside the layer)"));
}
// Straight colour of the (averaged) premultiplied pixels.
let color: Vec<f64> = (0..3).map(|i| (sum[i] / sum[3]).clamp(0.0, 1.0) as f64).chain([1.0]).collect();
let uid = prop.uid;
s.execute("prop.set", json!({"comp": cid.0, "layer": lid.0, "prop": uid, "value": color}))?;
Ok(json!({"prop": uid, "color": color}))
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let cid = super::comp_id(s, p)?;
let fx = effects_p(s, p, "effect.remove")?;
@ -401,11 +355,11 @@ fn last(s: &mut Session, p: &Value) -> Result<Value> {
}
fn list(_: &mut Session, p: &Value) -> Result<Value> {
let filter = str_p(p, "filter").map(str::to_lowercase);
let filter = str_p(p, "filter").map(str::to_ascii_lowercase);
let v: Vec<Value> = effectcraft_effects::all()
.into_iter()
.filter(|e| {
filter.as_ref().is_none_or(|f| effectcraft_effects::name_matches(e, f) || e.id.contains(f.as_str()) || e.category.to_ascii_lowercase().contains(f))
filter.as_ref().is_none_or(|f| e.name.to_ascii_lowercase().contains(f) || e.id.contains(f.as_str()) || e.category.to_ascii_lowercase().contains(f))
})
.map(|e| {
json!({"id": e.id, "name": e.name, "category": e.category, "gpu": e.gpu, "float": e.float, "params": e.params.iter().map(|p| json!({"id": p.id, "name": p.name, "default": p.default.to_json()})).collect::<Vec<_>>()})
@ -417,15 +371,6 @@ fn list(_: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("effect.apply", "Apply Effect", [], None, "{effect: id|name (e.g. Gaussian Blur), layers?}", has_layers, apply),
cmd!(
"effect.pickColor",
"Pick Effect Colour",
[],
None,
"{layer?, effect?: index|uid|name, param: id (e.g. screenColour) | prop: uid, x, y (layer px), average?: bool (5×5), time?} — the colour of the effect's input there",
has_layers,
pick_color
),
cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last),
cmd!("effect.removeAll", "Remove All", ["Effect"], Some("Cmd+Shift+E"), "{layers?}", has_layers, remove_all),
cmd!("effect.remove", "Remove Effect", [], None, "{layer?, effect: index|uid|name}", has_layers, remove),

View File

@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Other ArtCraft Apps",
[],
None,
"{app: photocraft|vectorcraft|filmcraft|lightcraft|printcraft|designcraft, kind?: page|github}",
"{app: photocraft|drawcraft|filmcraft|lightcraft|printcraft|designcraft, kind?: page|github}",
always,
sibling
),

View File

@ -517,13 +517,6 @@ pub(crate) fn unlocked(s: &Session, cid: ItemId, ids: Vec<LayerId>, cmd: &str) -
}
}
/// `visible: [{layer, prop}]`: the properties the Timeline shows (J / K and Select All
/// Keyframes use only those, as in After Effects). `None` when not given.
pub(crate) fn visible_p(p: &Value) -> Option<Vec<(LayerId, effectcraft_project::Uid)>> {
let a = p.get("visible")?.as_array()?;
Some(a.iter().filter_map(|v| Some((LayerId(v.get("layer")?.as_u64()?), v.get("prop")?.as_u64()?))).collect())
}
/// `time` (seconds) or `frame` param, else the CTI.
pub(crate) fn time_p(s: &Session, p: &Value, comp: Option<&Comp>) -> Tick {
if let Some(t) = f_p(p, "time") {

View File

@ -71,7 +71,6 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
let t = s.time();
let to = str_p(p, "to").unwrap_or("start");
let sel_layer = s.state.selected_layers.first().and_then(|id| comp.layer(*id));
let visible = super::visible_p(p);
let target = match to {
"start" => Tick::ZERO,
"end" => comp.duration - comp.frame_duration(),
@ -79,23 +78,6 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
"workEnd" => comp.work_area.1 - comp.frame_duration(),
"layerIn" => sel_layer.map(|l| l.in_point).unwrap_or(t),
"layerOut" => sel_layer.map(|l| l.out_point - comp.frame_duration()).unwrap_or(t),
"nextKey" | "prevKey" if visible.is_some() => {
// What the Timeline shows: keys of its revealed properties, layer and comp markers
// and the work area (After Effects' "visible items").
let mut times: Vec<Tick> = comp.markers.iter().map(|m| m.time).collect();
times.extend([comp.work_area.0, comp.work_area.1 - comp.frame_duration()]);
for l in comp.layers.iter().filter(|l| !(comp.hide_shy && l.switches.shy)) {
times.extend(l.markers.iter().map(|m| l.comp_time(m.time)));
}
for (lid, uid) in visible.iter().flatten() {
if let Some(l) = comp.layer(*lid)
&& let Some(pr) = l.props.find(*uid)
{
times.extend(pr.keys.iter().map(|k| l.comp_time(k.time)));
}
}
step_to(times, t, comp.frame_duration(), to == "nextKey")
}
"nextKey" | "prevKey" => {
// Keyframes (of visible/selected layers), markers and the work area, in comp time.
let mut times: Vec<Tick> = comp.markers.iter().map(|m| m.time).collect();
@ -125,7 +107,14 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
times.extend(l.markers.iter().map(|m| l.comp_time(m.time)));
}
}
step_to(times, t, comp.frame_duration(), to == "nextKey")
times.sort();
times.dedup();
let half = comp.frame_duration().0 / 2;
if to == "nextKey" {
times.into_iter().find(|x| x.0 > t.0 + half).unwrap_or(t)
} else {
times.into_iter().rev().find(|x| x.0 < t.0 - half).unwrap_or(t)
}
}
_ => return Err(super::bad("time.go", "to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey")),
};
@ -133,22 +122,6 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
Ok(report(s))
}
/// The nearest time in `times` after (or before) `t`, more than half a frame away; `t` itself
/// when there is none.
fn step_to(mut times: Vec<Tick>, t: Tick, frame: Tick, next: bool) -> Tick {
times.sort();
times.dedup();
let half = frame.0 / 2;
if next { times.into_iter().find(|x| x.0 > t.0 + half).unwrap_or(t) } else { times.into_iter().rev().find(|x| x.0 < t.0 - half).unwrap_or(t) }
}
/// `p` with `to` set (J / K pass their other parameters on).
fn with_to(p: &Value, to: &str) -> Value {
let mut p = if p.is_object() { p.clone() } else { json!({}) };
p["to"] = json!(to);
p
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("time.set", "Go to Time...", ["View"], Some("Alt+Shift+J"), "{time? (s) | frame? | timecode?}", has_comp, set),
@ -161,27 +134,8 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("time.end", "Go to End", [], Some("End"), "{}", has_comp, |s, _| go(s, &json!({"to": "end"}))),
cmd!("time.layerIn", "Go to Layer In Point", [], Some("I"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerIn"}))),
cmd!("time.layerOut", "Go to Layer Out Point", [], Some("O"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerOut"}))),
cmd!(
"time.nextKey",
"Go to Next Keyframe or Marker",
[],
Some("K"),
"{visible?: [{layer, prop}] (only these properties' keys, as the Timeline shows them)}",
has_comp,
|s, p| go(s, &with_to(p, "nextKey"))
),
cmd!("time.previousKey", "Go to Previous Keyframe or Marker", [], Some("J"), "{visible?: [{layer, prop}]}", has_comp, |s, p| go(
s,
&with_to(p, "prevKey")
)),
cmd!(
"time.go",
"Go To",
[],
None,
"{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey, prop?: uid, visible?: [{layer, prop}]}",
has_comp,
go
),
cmd!("time.nextKey", "Go to Next Keyframe or Marker", [], Some("K"), "{}", has_comp, |s, _| go(s, &json!({"to": "nextKey"}))),
cmd!("time.previousKey", "Go to Previous Keyframe or Marker", [], Some("J"), "{}", has_comp, |s, _| go(s, &json!({"to": "prevKey"}))),
cmd!("time.go", "Go To", [], None, "{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey, prop?: uid}", has_comp, go),
]
}

View File

@ -954,8 +954,6 @@ mod tests_fidelity;
#[cfg(test)]
mod tests_frame_export;
#[cfg(test)]
mod tests_keylight;
#[cfg(test)]
mod tests_lottie;
#[cfg(test)]
mod tests_m137;
@ -970,8 +968,6 @@ mod tests_menu_cmds;
#[cfg(test)]
mod tests_model3d;
#[cfg(test)]
mod tests_motion_blur_depth;
#[cfg(test)]
mod tests_project_items;
#[cfg(test)]
mod tests_proxy;

View File

@ -12,7 +12,7 @@ pub const ISSUES: &str = "https://github.com/storytold/effectcraft/issues";
/// Sibling apps of the ArtCraft family: (name, what it is, GitHub repo, app page).
pub const SIBLINGS: &[(&str, &str)] = &[
("PhotoCraft", "photocraft"),
("VectorCraft", "vectorcraft"),
("DrawCraft", "drawcraft"),
("FilmCraft", "filmcraft"),
("LightCraft", "lightcraft"),
("PrintCraft", "printcraft"),

View File

@ -301,17 +301,6 @@ fn comp_settings_anchor_start_timecode_renderer() {
assert_eq!(c.renderer, crate::project::Renderer::Advanced3D);
}
#[test]
fn comp_rejects_zero_and_negative_frame_rates() {
let mut s = Session::default();
for fps in [0.0, -30.0, 0.0001] {
assert!(s.execute("comp.new", json!({"frameRate": fps})).is_err(), "{fps}");
}
s.execute("comp.new", json!({"frameRate": 30, "duration": 1})).unwrap();
assert!(s.execute("comp.settings", json!({"frameRate": 0})).is_err());
assert_eq!(s.active_comp().unwrap().frame_rate, crate::time::FrameRate::FPS_30);
}
#[test]
fn render_queue_add_accepts_documented_settings_when_checked() {
let mut s = demo();

View File

@ -1,135 +0,0 @@
//! Key Light end to end: a green-screen plate (uneven screen, a subject with green spill and a
//! soft edge) keyed through the commands an agent uses: `effect.apply` by its After Effects name,
//! `effect.pickColor` on the screen (the effect's input, as the eyedropper does) and a render.
use std::sync::Arc;
use effectcraft_project::{Footage, FootageKind, ItemId};
use effectcraft_raster::Image;
use effectcraft_time::{FrameRate, Tick};
use serde_json::json;
use crate::Session;
use crate::render::{FootageSource, RenderOpts};
const W: u32 = 96;
const H: u32 = 64;
const SKIN: [f32; 3] = [0.86, 0.62, 0.52];
/// The plate's straight colour and alpha (opaque everywhere) at a pixel.
fn plate(x: u32, y: u32) -> [f32; 3] {
// Screen: a slightly uneven, noisy green.
let noise = (((x * 7919 + y * 104_729) % 97) as f32 / 97.0 - 0.5) * 0.02;
let screen = [0.18 + noise, 0.66 + (x as f32 / W as f32 - 0.5) * 0.04 + noise, 0.30 - noise];
let r = ((x as f32 + 0.5 - 48.0).powi(2) + (y as f32 + 0.5 - 32.0).powi(2)).sqrt();
// Subject: skin, with green spill in its outer 3 px and a 3 px soft edge.
let mut subject = SKIN;
if r > 13.0 {
subject[1] += 0.12;
}
let a = ((19.0 - r) / 3.0).clamp(0.0, 1.0);
[0, 1, 2].map(|i| subject[i] * a + screen[i] * (1.0 - a))
}
struct Plate;
impl FootageSource for Plate {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
let mut img = Image::new(W, H);
for y in 0..H {
for x in 0..W {
let c = plate(x, y);
img.set(x, y, [c[0], c[1], c[2], 1.0]);
}
}
Some(Arc::new(img))
}
}
struct Probe;
impl crate::Importer for Probe {
fn probe(&self, path: &str) -> Result<Footage, String> {
Ok(Footage {
path: path.into(),
kind: FootageKind::Still,
width: W,
height: H,
has_video: true,
frame_rate: FrameRate::from_f64(24.0),
duration: Tick::ZERO,
..Default::default()
})
}
}
fn keyed() -> (Session, ItemId) {
let mut s = Session { footage: Arc::new(Plate), importer: Some(Arc::new(Probe)), ..Default::default() };
let item = s.execute("file.import", json!({"paths": ["/plates/greenscreen.png"]})).unwrap()["items"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Key", "width": W, "height": H, "frameRate": 24, "duration": 1})).unwrap();
let c = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": item})).unwrap();
// After Effects' name finds it; MCP's add_effect / list_effects go through the same lookup.
let r = s.execute_checked("effect.apply", json!({"effect": "Keylight (1.2)"})).unwrap();
assert_eq!(r["effect"], "ec.keying.keylight");
(s, c)
}
#[test]
fn key_light_removes_a_green_screen() {
let (mut s, c) = keyed();
let steps = s.history.undo.len();
// The default screen colour (pure green) already keys the screen, so the shown frame is
// transparent there; the eyedropper samples the effect's input instead.
let picked = s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": 6, "y": 6, "average": true})).unwrap();
let col: Vec<f64> = picked["color"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
let want = plate(6, 6);
assert!((0..3).all(|i| (col[i] - want[i] as f64).abs() < 0.02), "{col:?} vs {want:?}");
let tree = s.execute("layer.tree", json!({})).unwrap().to_string();
assert!(tree.contains("Screen Colour"), "{tree}");
// One undo step.
assert_eq!(s.history.undo.len(), steps + 1);
// Clean the matte as one does in Screen Matte view: Clip Black for the uneven screen, Clip
// White for the spill's partial transparency.
for (path, v) in [("effects/#1/screenMatte/clipBlack", 10), ("effects/#1/screenMatte/clipWhite", 85)] {
s.execute_checked("prop.set", json!({"path": path, "value": v})).unwrap();
}
let img = s.render(c, Tick::ZERO, RenderOpts::default());
let px = |x, y| img.get(x, y);
// The whole screen goes (corners, both ends of the gradient, noise).
for (x, y) in [(1, 1), (94, 1), (1, 62), (94, 62), (20, 32), (76, 32)] {
assert!(px(x, y)[3] < 0.03, "screen at {x},{y}: {:?}", px(x, y));
}
// The subject stays, with its colour.
let fg = px(48, 32);
assert!(fg[3] > 0.99 && (0..3).all(|i| (fg[i] - SKIN[i]).abs() < 0.02), "{fg:?}");
// Spill on the subject's edge is suppressed: no green cast left.
let sp = px(48, 32 - 14);
assert!(sp[3] > 0.99 && sp[1] <= 0.5 * (sp[0] + sp[2]) + 0.01, "spill {sp:?}");
// The soft edge keeps partial alpha, and its recovered colour has no green fringe.
let e = px(48, 32 - 18);
assert!(e[3] > 0.15 && e[3] < 0.85, "edge {e:?}");
let a = e[3];
assert!(e[1] / a <= 0.5 * (e[0] + e[2]) / a + 0.02, "edge fringe {e:?}");
}
#[test]
fn pick_color_rejects_bad_targets_and_screen_matte_view_shows_the_key() {
let (mut s, c) = keyed();
// Not a colour parameter / outside the layer.
assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenGain", "x": 6, "y": 6})).is_err());
assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": -50, "y": 6})).is_err());
assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": 1e300, "y": -1e300, "average": true})).is_err());
assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour"})).is_err());
s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": 90, "y": 60})).unwrap();
// View ▸ Screen Matte: black screen (once Clip Black takes the uneven screen), white subject.
for (path, v) in [("effects/#1/view", 4), ("effects/#1/screenMatte/clipBlack", 25)] {
s.execute_checked("prop.set", json!({"path": path, "value": v})).unwrap();
}
let img = s.render(c, Tick::ZERO, RenderOpts::default());
assert!(img.get(2, 2)[0] < 0.03 && img.get(48, 32)[0] > 0.97, "{:?} {:?}", img.get(2, 2), img.get(48, 32));
// The Effects & Presets / list_effects search finds it by After Effects' name too.
let list = s.execute("effect.list", json!({"filter": "Keylight (1.2)"})).unwrap();
assert_eq!(list.as_array().map(|a| a.len()), Some(1), "{list}");
assert_eq!(list[0]["id"], "ec.keying.keylight");
}

View File

@ -1030,27 +1030,3 @@ fn j_and_k_stop_at_keys_markers_and_the_work_area() {
let last = (3.0 * 30.0 - 1.0) / 30.0;
assert_eq!(stops, vec![1.0, 2.0, (last * 30.0f64).round() / 30.0, (last * 30.0f64).round() / 30.0]);
}
#[test]
fn j_k_and_select_all_use_only_the_properties_the_timeline_shows() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
opacity_keys(&mut s, a, &[1.0]);
s.execute("prop.addKey", json!({"layer": a, "path": "transform/rotation", "time": 2.0, "value": 45})).unwrap();
let op = layer(&s, a).props.prop("transform/opacity").unwrap().uid;
// Only Opacity is revealed: K skips the hidden Rotation key at 2 s, stopping at 1 s and then
// at the work area's end.
let visible = json!([{"layer": a, "prop": op}]);
s.execute_checked("time.nextKey", json!({"visible": visible})).unwrap();
assert_eq!(s.time().seconds(), 1.0);
s.execute_checked("time.nextKey", json!({"visible": visible})).unwrap();
assert!(s.time().seconds() > 3.9, "work area end, not the hidden key: {}", s.time().seconds());
// Without `visible` (agents), every key of the layers counts, as before.
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("time.nextKey", json!({})).unwrap();
assert_eq!(s.time().seconds(), 2.0);
// Select All Keyframes: the shown properties' keys only.
s.execute_checked("keys.selectAll", json!({"visible": visible})).unwrap();
assert_eq!(s.state.selected_keys.len(), 1);
assert_eq!(s.state.selected_keys[0].prop, op);
}

View File

@ -1,122 +0,0 @@
//! Motion blur of collapsed precomps (the precomp layer's own motion; the containing comp's
//! shutter) and of animated shape content, rendered through the commands.
use effectcraft_raster::Image;
use effectcraft_time::Tick;
use serde_json::{Value, json};
use crate::Session;
use crate::render::RenderOpts;
const W: u32 = 160;
const H: u32 = 90;
fn id(v: &Value, k: &str) -> u64 {
v[k].as_u64().unwrap()
}
fn render(s: &Session, comp: u64) -> Image {
s.render(effectcraft_project::ItemId(comp), Tick::from_seconds_f64(0.5), RenderOpts::default())
}
/// Pixels with partial alpha (motion-blur smear).
fn partial(img: &Image) -> usize {
img.data.iter().filter(|p| p[3] > 0.05 && p[3] < 0.95).count()
}
fn max_diff(a: &Image, b: &Image) -> f32 {
a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max)
}
/// "Main" holding "Pre" (a 20×20 white square in the middle), the precomp layer moving 480 px/s
/// (20 px a frame at 24 fps) with motion blur on. `inner_move`: the square moves inside Pre too
/// (with its own motion blur switch), and Pre's shutter is closed (0°).
fn scene(collapse: bool, inner_move: bool) -> (Session, u64) {
let mut s = Session::default();
let pre = id(&s.execute("comp.new", json!({"name": "Pre", "width": W, "height": H, "frameRate": 24, "duration": 2})).unwrap(), "comp");
let sq = id(&s.execute("layer.newSolid", json!({"name": "Square", "color": "#ffffff", "width": 20, "height": 20})).unwrap(), "layer");
if inner_move {
s.execute("comp.settings", json!({"shutterAngle": 0})).unwrap();
s.execute("comp.setSwitch", json!({"switch": "motionBlur", "value": true})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [sq], "switch": "motionBlur", "value": true})).unwrap();
for (t, x) in [(0.0, -160.0), (1.0, 320.0)] {
s.execute("prop.addKey", json!({"layer": sq, "path": "transform/position", "time": t, "value": [x, 45.0]})).unwrap();
}
}
let main = id(&s.execute("comp.new", json!({"name": "Main", "width": W, "height": H, "frameRate": 24, "duration": 2})).unwrap(), "comp");
s.execute("comp.setSwitch", json!({"switch": "motionBlur", "value": true})).unwrap();
let l = id(&s.execute("layer.addItem", json!({"item": pre})).unwrap(), "layer");
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "motionBlur", "value": true})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "collapse", "value": collapse})).unwrap();
if !inner_move {
for (t, x) in [(0.0, -160.0), (1.0, 320.0)] {
s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": t, "value": [x, 45.0]})).unwrap();
}
}
(s, main)
}
#[test]
fn a_collapsed_precomp_layer_blurs_with_its_own_motion() {
let (s, main) = scene(true, false);
let collapsed = render(&s, main);
// The square sits at x = 80 at 0.5 s and smears over 10 px (180° of 20 px).
assert!(partial(&collapsed) > 100, "smeared: {}", partial(&collapsed));
// As blurred as the same precomp rendered flat.
let (s, main) = scene(false, false);
let flat = render(&s, main);
assert!(max_diff(&collapsed, &flat) < 0.08, "{}", max_diff(&collapsed, &flat));
// Motion blur off for the render: sharp.
let sharp = s.render(effectcraft_project::ItemId(main), Tick::from_seconds_f64(0.5), RenderOpts { motion_blur: false, ..Default::default() });
assert!(partial(&sharp) < 10, "{}", partial(&sharp));
}
#[test]
fn collapsed_layers_use_the_containing_comps_shutter() {
// Pre's own shutter is closed; drawn into Main (180°) the square's own motion blurs.
let (s, main) = scene(true, true);
assert!(partial(&render(&s, main)) > 100, "{}", partial(&render(&s, main)));
// Rendered flat, Pre uses its own (closed) shutter.
let (s, main) = scene(false, true);
assert!(partial(&render(&s, main)) < 10, "{}", partial(&render(&s, main)));
}
/// A shape layer whose rectangle moves inside its contents (not its transform): motion blur
/// smears it like the same move made with the layer's Position.
#[test]
fn animated_shape_content_is_motion_blurred() {
let shape = |content: bool, blur: bool| {
let mut s = Session::default();
let c = id(&s.execute("comp.new", json!({"name": "S", "width": W, "height": H, "frameRate": 24, "duration": 2})).unwrap(), "comp");
s.execute("comp.setSwitch", json!({"switch": "motionBlur", "value": true})).unwrap();
let l = id(&s.execute("layer.newShape", json!({"kind": "rect", "size": [20, 20], "fill": "#ffffff", "position": [80, 45]})).unwrap(), "layer");
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "motionBlur", "value": blur})).unwrap();
let (target, base) = if content {
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap().clone();
let mut found = None;
layer.props.group("contents").unwrap().walk("contents", &mut |_, p| {
if found.is_none() && p.match_id == "position" {
found = Some((json!({"prop": p.uid}), p.value.as_vec2()));
}
});
found.unwrap()
} else {
(json!({"path": "transform/position"}), [80.0, 45.0])
};
for (t, dx) in [(0.0, -240.0), (1.0, 240.0)] {
let mut p = target.clone();
p["layer"] = json!(l);
p["time"] = json!(t);
p["value"] = json!([base[0] + dx, base[1]]);
s.execute("prop.addKey", p).unwrap();
}
render(&s, c)
};
let content = shape(true, true);
assert!(partial(&content) > 100, "smeared: {}", partial(&content));
// Like the layer moved by its transform…
let moved = shape(false, true);
assert!(max_diff(&content, &moved) < 0.15, "{}", max_diff(&content, &moved));
// …and sharp with the layer's motion blur switch off.
assert!(partial(&shape(true, false)) < 40, "{}", partial(&shape(true, false)));
}

View File

@ -34,15 +34,6 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
}
function __finish(r) {
// Rethrow from this frame so the runtime's stack unwinds (see `Runtime::script`).
try {
return __result(r);
} catch (e) {
throw e;
}
}
function __result(r) {
r = __v(r);
if (r !== null && typeof r === 'object' && r.__isLayer) return r.index;
if (r !== null && typeof r === 'object' && r.__isTextStyle) return r.__out();

View File

@ -238,11 +238,8 @@ impl Runtime {
self.scripts.clear();
}
// A block keeps `let`/`const` local to one evaluation; `{` shares line 1 with the code so
// error line numbers match the user's text. Errors are rethrown from the script's own
// frame: boa leaves the stack of the inner frames behind when an exception leaves the
// script from a nested call (every host request does), and the thread's runtime would
// fail every expression with "reached the maximum stack size" after a few hundred.
let src = format!("try{{{}\n}}catch(__e){{throw __e}}", crate::rewrite::rewrite(text));
// error line numbers match the user's text.
let src = format!("{{{}\n}}", crate::rewrite::rewrite(text));
let s = Script::parse(Source::from_bytes(src.as_bytes()), None, &mut self.ctx).map_err(|e| format_error(&e.to_string(), line_count(text)));
self.scripts.insert(text.to_string(), s.clone());
s

View File

@ -388,22 +388,6 @@ fn circular_references_terminate() {
assert!(matches!(r, Ok(Value::Scalar(_))) || r.is_err(), "{r:?}");
}
#[test]
fn errors_and_property_reads_keep_the_runtime_usable() {
// Errors thrown inside helpers, and the aborts every property read makes until the host
// answers, must not use up the thread's runtime (it used to fail every expression after a
// few hundred with "reached the maximum stack size").
let f = fx();
for _ in 0..3000 {
assert!(f.ev(f.b, ROT, "thisComp.layer('Ghost').position", 0.0).is_err());
}
for i in 0..1000 {
let t = i as f64 / 1000.0;
assert_close!(f.ev(f.b, ROT, "thisComp.layer('A').transform.rotation", t).unwrap().as_f64(), 90.0 * t);
}
assert_close!(f.num("time * 2", 1.0), 2.0);
}
#[test]
fn effect_slider_reference() {
let f = fx();

View File

@ -441,9 +441,7 @@ pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Val
let dur = (t1 - t0).max(1e-12);
let x = ((secs(t) - t0) / dur).clamp(0.0, 1.0);
let lin_out = a.out_interp == Interp::Linear;
// A Hold in-side only matters for the segment it ends, which the out side above decides:
// it shapes the incoming segment like a linear side, as in the Graph Editor and Lottie export.
let lin_in = b.in_interp != Interp::Bezier;
let lin_in = b.in_interp == Interp::Linear;
let is_spatial = spatial && matches!(a.value, Value::Vec2(_) | Value::Vec3(_));
if is_spatial {
let p0 = v3(&a.value);
@ -586,17 +584,6 @@ mod tests {
assert_eq!(evaluate(&held, s(1.999), false), Some(Value::Scalar(0.0)));
}
#[test]
fn hold_key_keeps_its_incoming_segment_linear() {
// Hold on the second key (in and out, as KeyframeInterpolationType.HOLD sets it) holds
// what comes after it, not the linear motion into it.
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0)).hold()];
assert_eq!(evaluate(&keys, s(0.5), false), Some(Value::Scalar(25.0)));
let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), Keyframe::new(s(2.0), Value::Vec2([100.0, 0.0])).hold()];
let p = evaluate(&keys, s(0.5), true).unwrap().as_vec2();
assert!((p[0] - 25.0).abs() < 0.01, "{p:?}");
}
#[test]
fn easy_ease_is_symmetric_and_slow_at_ends() {
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)).eased(), Keyframe::new(s(1.0), Value::Scalar(100.0)).eased()];

View File

@ -1,7 +1,7 @@
//! Curve-preserving boolean operations on filled [`BezPath`]s, built on `linesweeper`'s robust
//! sweep-line topology (MIT OR Apache-2.0).
//!
//! Adapted from VectorCraft's `vectorcraft-pathops` (`crates/pathops/src/boolean.rs`; our own code,
//! Adapted from DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/boolean.rs`; our own code,
//! MIT OR Apache-2.0), reworked to take and return kurbo paths directly.
//!
//! Inputs are closed implicitly (a filled open path is painted as if closed). The sweep splits

View File

@ -1,4 +1,4 @@
//! Adapted from VectorCraft's `vectorcraft-pathops` (`crates/pathops/src/fit.rs`; our own code,
//! Adapted from DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/fit.rs`; our own code,
//! MIT OR Apache-2.0).
//!
//! Least-squares cubic Bézier fitting of point sequences (the classic "fit a cubic with fixed end

View File

@ -2,7 +2,7 @@
//! offsetting (kurbo's stroker offsets curves analytically and refits them) and robust overlap
//! removal (the boolean sweep).
//!
//! Adapted from VectorCraft's `vectorcraft-pathops` (`crates/pathops/src/offset.rs`; our own code,
//! Adapted from DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/offset.rs`; our own code,
//! MIT OR Apache-2.0).
use kurbo::{BezPath, PathEl, Stroke, StrokeOpts};

View File

@ -444,49 +444,6 @@ pub struct Renderer<'a> {
/// This renderer draws into the top-level frame's canvas (the top comp, or a collapsed
/// precomp drawn straight into it): the region of interest's offset applies.
top: bool,
/// The collapsed precomp layers this comp is drawn through, outermost first: motion blur
/// re-evaluates their transforms at each sub-sample (see [`Renderer::buf_matrix_at`]).
through: [Option<Through<'a>>; MAX_THROUGH],
/// More collapsed levels than [`MAX_THROUGH`]: their motion is not blurred.
through_lost: bool,
}
/// One collapsed precomp layer a nested comp is drawn through.
#[derive(Clone, Copy)]
struct Through<'a> {
comp_id: ItemId,
comp: &'a Comp,
/// The containing comp's time at this frame.
time: Tick,
layer: &'a Layer,
/// The precomp layer is motion blurred: its own motion blurs the nested layers.
blur: bool,
}
/// Collapsed precomps within collapsed precomps that motion blur follows (more than
/// [`Renderer::collapsed`]'s nesting limit).
const MAX_THROUGH: usize = 16;
/// A motion-blur shutter: the comp's, or for a collapsed precomp's layers the outermost
/// containing comp's (they are drawn into its frames).
#[derive(Clone, Copy)]
struct Shutter {
/// Shutter angle and phase as fractions of a frame.
angle: f64,
phase: f64,
/// Frame duration (seconds).
frame: f64,
/// Samples Per Frame and Adaptive Sample Limit.
samples: u32,
limit: u32,
}
impl Shutter {
/// Seconds from the frame time to sub-sample `i` of `n`, spread over the open shutter.
fn offset(&self, i: usize, n: usize) -> f64 {
let f = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
(self.phase + self.angle * f) * self.frame
}
}
/// How a collapsed precomp's 3D layers are placed in the parent (see `Renderer::collapse_into`).
@ -533,8 +490,6 @@ impl<'a> Renderer<'a> {
inherited: None,
defer_dof: false,
top: true,
through: [None; MAX_THROUGH],
through_lost: false,
}
}
@ -569,17 +524,7 @@ impl<'a> Renderer<'a> {
/// A renderer for nested work (precomps, layers read by effects): one level deeper.
fn nested(&self) -> Renderer<'a> {
Renderer {
depth: self.depth + 1,
outer: None,
opacity_mul: 1.0,
collapse3d: None,
defer_dof: false,
top: false,
through: [None; MAX_THROUGH],
through_lost: false,
..*self
}
Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, defer_dof: false, top: false, ..*self }
}
/// The nested comp of a precomp layer whose transformations collapse into this comp: the
@ -613,7 +558,7 @@ impl<'a> Renderer<'a> {
/// their transforms are concatenated with the precomp layer's (one resample, no
/// rasterisation at the precomp's bounds), their opacity multiplied by `opacity`, and their
/// 3D layers use this comp's camera and lights.
pub fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> {
pub fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> {
let nc = self.project.comp(item)?;
let t = ctx.nested_time(layer);
if !nc.covers(t) {
@ -628,15 +573,7 @@ impl<'a> Renderer<'a> {
cam: three_d::compose::camera_for(self, ctx),
parent: self.collapse3d.map_or(*ctx, |c| c.parent),
};
// Motion blur follows the precomp layer's own motion too.
let link = Through { comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, layer, blur: self.mb_on(ctx, layer) };
let mut through = self.through;
let slot = through.iter_mut().find(|t| t.is_none());
let through_lost = self.through_lost || slot.is_none();
if let Some(slot) = slot {
*slot = Some(link);
}
Some((Renderer { depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), through, through_lost, ..*self }, nctx))
Some((Renderer { depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), ..*self }, nctx))
}
pub(crate) fn opacity_mul(&self) -> f32 {
@ -1143,10 +1080,7 @@ impl<'a> Renderer<'a> {
/// Source → masks, clamped/quantised to the project depth.
fn masked_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Buf> {
let mut buf = match self.content_times(ctx, layer) {
Some(times) => self.blurred_source(ctx, layer, &times)?,
None => self.source(ctx, layer)?,
};
let mut buf = self.source(ctx, layer)?;
// A solid is filled with a quantised colour: only masks can take it off the grid.
let solid = matches!(layer.source, LayerSource::Solid { .. });
if masks::apply(ctx, layer, &mut buf, self.mask_blur(ctx, layer)) || !solid {
@ -1217,7 +1151,6 @@ impl<'a> Renderer<'a> {
fn content_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<(Arc<Buf>, Option<u64>)> {
let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)));
let key = self.content_key(ctx, layer, key);
if let (Some(c), Some(k)) = (self.cache, key)
&& let Some(b) = c.get(k)
{
@ -1239,7 +1172,6 @@ impl<'a> Renderer<'a> {
/// under its own key, so scrubbing reuses frames rendered for earlier output frames.
pub fn layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option<Arc<Buf>> {
let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer), effects));
let key = self.content_key(ctx, layer, key);
if let (Some(c), Some(k)) = (self.cache, key)
&& let Some(b) = c.get(k)
{
@ -1341,13 +1273,9 @@ impl<'a> Renderer<'a> {
/// Buffer pixel → output pixel matrix for the layer at the context time.
fn buf_matrix(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Mat3 {
let (l2c, _) = ctx.layer_to_comp(layer);
self.place_matrix(self.outer.map_or(l2c, |o| o * l2c), buf)
}
/// Buffer pixel → output pixel matrix for a layer → (top-level) comp matrix.
fn place_matrix(&self, l2c: Mat3, buf: &Buf) -> Mat3 {
let s = self.opts.scale;
let (l2c, _) = ctx.layer_to_comp(layer);
let l2c = self.outer.map_or(l2c, |o| o * l2c);
self.out_matrix()
* Mat3::scale(vec2(s, s))
* l2c
@ -1355,112 +1283,6 @@ impl<'a> Renderer<'a> {
* Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
}
/// The collapsed precomp layers this comp is drawn through, outermost first.
fn links(&self) -> impl Iterator<Item = &Through<'a>> {
self.through.iter().map_while(Option::as_ref)
}
/// The shutter of a layer's motion blur: its comp's, or for a collapsed precomp's layers the
/// outermost containing comp's.
fn shutter(&self, ctx: &EvalCtx) -> Shutter {
let c: &Comp = match self.links().next() {
Some(l) => l.comp,
None => ctx.comp,
};
Shutter {
angle: c.shutter_angle / 360.0,
phase: c.shutter_phase / 360.0,
frame: c.frame_duration().seconds(),
samples: c.motion_blur_samples,
limit: c.motion_blur_adaptive_limit,
}
}
/// Whether a layer moves within the shutter: its own motion blur, or that of a collapsed
/// precomp layer it is drawn through.
fn blurred(&self, ctx: &EvalCtx, layer: &Layer) -> bool {
self.mb_on(ctx, layer) || (!self.through_lost && self.links().any(|l| l.blur))
}
/// The collapsed precomps' matrix (nested comp → the frame's comp) and the nested comp's time
/// `dt` seconds into the outermost comp's shutter: each precomp layer moves if it is motion
/// blurred, and times map down through each one (stretch, time remapping).
fn outer_at(&self, ctx: &EvalCtx, dt: f64) -> (Option<Mat3>, Tick) {
let shift = Tick::from_seconds_f64(dt);
let mut outer: Option<Mat3> = None;
let mut t: Option<Tick> = None;
for l in self.links() {
let at = EvalCtx { comp_id: l.comp_id, comp: l.comp, time: t.unwrap_or(l.time + shift), ..*ctx };
let (m, _) = if l.blur { at.layer_to_comp(l.layer) } else { EvalCtx { time: l.time, ..at }.layer_to_comp(l.layer) };
outer = Some(outer.map_or(m, |o| o * m));
t = Some(at.nested_time(l.layer));
}
(outer, t.unwrap_or(ctx.time + shift))
}
/// [`Self::buf_matrix`] for the motion-blur sub-sample `dt` seconds into the shutter: the
/// layer moves if it is motion blurred, the collapsed precomps above it if theirs are.
fn buf_matrix_at(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, dt: f64) -> Mat3 {
if self.through_lost || self.links().next().is_none() {
return self.buf_matrix(&ctx.at(ctx.time + Tick::from_seconds_f64(dt)), layer, buf);
}
let (outer, t) = self.outer_at(ctx, dt);
let lctx = if self.mb_on(ctx, layer) { ctx.at(t) } else { *ctx };
let (l2c, _) = lctx.layer_to_comp(layer);
self.place_matrix(outer.map_or(l2c, |o| o * l2c), buf)
}
/// Shape and text content that animates within the shutter (paths, Trim Paths, text
/// animators…) of a motion-blurred layer: the times its source is drawn at and averaged,
/// Samples Per Frame of them, as After Effects does for shape layers. `None` when the content
/// holds still (compared through its cache key, which leaves the transform out).
fn content_times(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Vec<Tick>> {
if !matches!(layer.source, LayerSource::Shape | LayerSource::Text) || !self.mb_on(ctx, layer) {
return None;
}
let sh = self.shutter(ctx);
if sh.angle <= 0.0 {
return None;
}
let n = if self.opts.draft { 4 } else { (sh.samples as usize).clamp(2, 64) };
let times: Vec<Tick> = (0..n).map(|i| self.outer_at(ctx, sh.offset(i, n)).1).collect();
let key = |t: Tick| cache::layer_key(&ctx.at(t), layer, 1.0, false, false);
let k0 = key(*times.first()?)?;
let moves = [times.get(n / 2), times.last()].into_iter().flatten().any(|t| key(*t) != Some(k0));
moves.then_some(times)
}
/// The layer's source drawn at each of `times` and averaged (premultiplied) on the union of
/// their bounds. Content that also changes its raster scale within the shutter, or would need
/// an outsized buffer, is drawn once.
fn blurred_source(&self, ctx: &EvalCtx, layer: &Layer, times: &[Tick]) -> Option<Buf> {
let bufs: Vec<Buf> = times.iter().filter_map(|t| self.source(&ctx.at(*t), layer)).collect();
let scale = bufs.first()?.scale;
// Pixel = layer point × scale + offset: with the largest offset every buffer lands at
// x + (off - its offset) ≥ 0.
let off = [0, 1].map(|i| bufs.iter().map(|b| b.offset[i]).fold(f64::MIN, f64::max));
let w = bufs.iter().map(|b| off[0] - b.offset[0] + b.img.width as f64).fold(0.0, f64::max).ceil();
let h = bufs.iter().map(|b| off[1] - b.offset[1] + b.img.height as f64).fold(0.0, f64::max).ceil();
if bufs.iter().any(|b| (b.scale - scale).abs() > 1e-9) || !(w * h).is_finite() || w * h > (1u64 << 26) as f64 {
return self.source(ctx, layer);
}
let mut acc = Image::new(w as u32, h as u32);
let k = 1.0 / bufs.len() as f32;
for b in &bufs {
let m = Mat3::translate(vec2(off[0] - b.offset[0], off[1] - b.offset[1]));
effectcraft_raster::accumulate_warp(&mut acc, &b.img, &m, effectcraft_raster::Sampling::Bilinear, k);
}
Some(Buf { img: acc, offset: off, scale })
}
/// A processed-layer cache key that also covers content motion blur (the averaged source
/// depends on the content at every sub-sample).
fn content_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option<u64>) -> Option<u64> {
let k = key?;
let Some(times) = self.content_times(ctx, layer) else { return Some(k) };
times.iter().try_fold(cache::derive(k, 0x6d62_6c72), |d, t| Some(cache::derive(d, cache::layer_key(&ctx.at(*t), layer, 1.0, false, false)?)))
}
/// Whether a layer is motion blurred: the render options, the comp's switch and the layer's
/// switch (limited by the precomp layers above when switches affect nested comps). Layer
/// motion, mask motion blur, effects that read the shutter and Advanced 3D all ask this.
@ -1482,7 +1304,7 @@ impl<'a> Renderer<'a> {
/// at least Samples Per Frame and at most the comp's Adaptive Sample Limit; a layer that
/// does not move within the shutter is drawn once.
fn mb_samples(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> usize {
if !self.blurred(ctx, layer) {
if !self.mb_on(ctx, layer) {
return 1;
}
let travel = self.shutter_travel(ctx, layer, buf);
@ -1492,21 +1314,22 @@ impl<'a> Renderer<'a> {
if self.opts.draft {
return 4;
}
let sh = self.shutter(ctx);
adaptive_samples(travel, sh.samples, sh.limit)
adaptive_samples(travel, ctx.comp.motion_blur_samples, ctx.comp.motion_blur_adaptive_limit)
}
/// Largest distance (output pixels) a corner of the layer buffer moves during the shutter,
/// measured along the path at a few points.
fn shutter_travel(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> f64 {
let sh = self.shutter(ctx);
let fd = ctx.comp.frame_duration().seconds();
let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0);
let (w, h) = (buf.img.width as f64, buf.img.height as f64);
let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h), (w * 0.5, h * 0.5)];
const PROBES: usize = 8;
let mut prev: Option<Vec<effectcraft_geom::Vec2>> = None;
let mut travel = vec![0.0f64; corners.len()];
for i in 0..PROBES {
let m = self.buf_matrix_at(ctx, layer, buf, sh.offset(i, PROBES));
let f = phase + angle * i as f64 / (PROBES - 1) as f64;
let m = self.buf_matrix(&ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)), layer, buf);
let pts: Vec<_> = corners.iter().map(|&(x, y)| m.apply(vec2(x, y))).collect();
if let Some(p) = &prev {
for (k, (a, b)) in p.iter().zip(&pts).enumerate() {
@ -1565,8 +1388,15 @@ impl<'a> Renderer<'a> {
if samples <= 1 {
return Placement { matrices: vec![self.buf_matrix(ctx, layer, buf)], sampling, seed };
}
let sh = self.shutter(ctx);
let matrices = (0..samples).map(|i| self.buf_matrix_at(ctx, layer, buf, sh.offset(i, samples))).collect();
let fd = ctx.comp.frame_duration().seconds();
let angle = ctx.comp.shutter_angle / 360.0;
let phase = ctx.comp.shutter_phase / 360.0;
let matrices = (0..samples)
.map(|i| {
let f = phase + angle * i as f64 / (samples - 1) as f64;
self.buf_matrix(&ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)), layer, buf)
})
.collect();
Placement { matrices, sampling, seed }
}

View File

@ -865,8 +865,8 @@ Property.prototype.property = function () { return null; };
P.setTemporalAutoBezierAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, autoBezier: !!b }); };
P.keyRoving = function (i) { return !!this.__key(i).roving; };
P.setRovingAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, roving: !!b }); };
P.keyInSpatialTangent = function (i) { return this.__key(i).spatialIn.slice(0, this.__info().dims); };
P.keyOutSpatialTangent = function (i) { return this.__key(i).spatialOut.slice(0, this.__info().dims); };
P.keySpatialInTangent = function (i) { return this.__key(i).spatialIn.slice(0, this.__info().dims); };
P.keySpatialOutTangent = function (i) { return this.__key(i).spatialOut.slice(0, this.__info().dims); };
P.keySpatialContinuous = function (i) { return !!this.__key(i).spatialContinuous; };
P.keySpatialAutoBezier = function (i) { return !!this.__key(i).spatialAutoBezier; };
P.setSpatialTangentsAtKey = function (i, inT, outT) {

View File

@ -241,22 +241,6 @@ fn keyframes_eases_and_interpolation() {
assert_eq!(v[9], json!(2));
}
#[test]
fn spatial_tangents_round_trip() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("T", 100, 100, 1, 2, 25);
var p = c.layers.addNull().transform.position;
p.setValuesAtTimes([0, 1], [[0, 0], [100, 0]]);
p.setSpatialTangentsAtKey(1, [0, 0], [30, 40]);
[p.keyOutSpatialTangent(1)[0], p.keyOutSpatialTangent(1)[1], p.keyInSpatialTangent(1)[0]]
"#,
);
assert_eq!(o.result, json!([30, 40, 0]));
}
#[test]
fn text_documents() {
let mut s = session();

View File

@ -607,39 +607,6 @@ impl DockNode {
}
}
/// Move panel `p` into the group that holds `anchor` as a tab before `before` (one of that
/// group's panels) or last, and show it. False (tree unchanged) if `anchor` isn't docked.
pub fn insert_tab(&mut self, p: PanelKind, anchor: PanelKind, before: Option<PanelKind>) -> bool {
if p == anchor || !self.contains(anchor) {
return false;
}
let saved = self.clone();
self.close(p);
fn rec(n: &mut DockNode, p: PanelKind, anchor: PanelKind, before: Option<PanelKind>) -> bool {
match n {
DockNode::Split { a, b, .. } => rec(a, p, anchor, before) || rec(b, p, anchor, before),
DockNode::Tabs { panels, active } if panels.contains(&anchor) => {
let i = before.and_then(|b| panels.iter().position(|x| *x == b)).unwrap_or(panels.len());
panels.insert(i, p);
*active = i;
true
}
DockNode::Stack { entries } if entries.iter().any(|e| e.panel == anchor) => {
let i = before.and_then(|b| entries.iter().position(|e| e.panel == b)).unwrap_or(entries.len());
entries.insert(i, StackEntry { panel: p, open: true, height: None });
true
}
_ => false,
}
}
if rec(self, p, anchor, before) {
true
} else {
*self = saved;
false
}
}
/// Path of the group containing `p` (as in [`Group::path`]; stacks add `sN`).
pub fn path_of(&self, p: PanelKind) -> Option<String> {
fn rec(n: &DockNode, p: PanelKind, path: &str) -> Option<String> {
@ -697,36 +664,6 @@ impl Layout {
found
}
/// Move `p` (docked or floating) into the group of `anchor` (docked or floating) as a tab
/// before `before` or last: where a tab dragged onto a tab strip lands.
pub fn insert_tab(&mut self, p: PanelKind, anchor: PanelKind, before: Option<PanelKind>) -> bool {
if p == anchor || before == Some(p) {
return false;
}
if self.floating.iter().any(|f| f.panels.contains(&anchor)) {
if self.root.contains(p) {
if self.root.panel_count() <= 1 {
return false;
}
self.root.close(p);
} else {
self.unfloat(p);
}
// (Removing `p` may have dropped an emptied group: look the anchor up again.)
let Some(f) = self.floating.iter_mut().find(|f| f.panels.contains(&anchor)) else { return false };
let i = before.and_then(|b| f.panels.iter().position(|x| *x == b)).unwrap_or(f.panels.len());
f.panels.insert(i, p);
f.active = i;
return true;
}
if !self.root.contains(anchor) {
return false;
}
// A floating panel isn't in the tree, so insert_tab's removal is a no-op for it.
self.unfloat(p);
self.root.insert_tab(p, anchor, before)
}
/// Dock `p` (docked or floating) next to `anchor`; `anchor` may be floating too (then Center
/// adds a tab to that floating group).
pub fn dock(&mut self, p: PanelKind, anchor: PanelKind, zone: Zone) -> bool {
@ -787,37 +724,6 @@ pub enum DockAction {
BeginDrag(PanelKind),
/// The tab's lock was toggled (Composition / Timeline viewer lock).
ToggleLock(PanelKind),
/// A document tab of a panel was clicked (the Timeline's tab of comp `id`).
ActivateDoc(PanelKind, u64),
/// A document tab's close button (or middle click): close that document (comp `id`).
CloseDoc(PanelKind, u64),
}
/// One document a panel shows as a tab of its own (the Timeline: one tab per open comp, as in
/// After Effects).
#[derive(Debug, Clone, PartialEq)]
pub struct DocTab {
pub id: u64,
pub title: String,
pub deco: Option<TabDeco>,
/// The document the panel shows now.
pub active: bool,
}
/// What a group's tabs show: labels, the comp swatch / close / lock decorations and the panels
/// that show one tab per document.
pub struct TabInfo<'a> {
pub title: &'a dyn Fn(PanelKind) -> String,
pub decos: &'a [(PanelKind, TabDeco)],
pub docs: &'a [(PanelKind, Vec<DocTab>)],
}
/// What drawing a group's chrome produced: actions and where each panel's tabs are (a panel with
/// document tabs spans all of them; tabs hidden behind the overflow chevron are left out).
#[derive(Default)]
pub struct Chrome {
pub actions: Vec<DockAction>,
pub tabs: Vec<(PanelKind, Rect)>,
}
/// After Effects-style extras on a tab that shows a composition (Composition, Timeline): a close
@ -904,21 +810,18 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa
}
}
/// Draw a group's frame + tab strip: one tab per panel, or per document for panels that show
/// several (the Timeline's comps). Tab labels may name the comp or layer they show (After
/// Effects style).
pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &Tokens, reg: &mut crate::automation::Registry, info: &TabInfo) -> Chrome {
struct Entry {
panel: PanelKind,
doc: Option<u64>,
label: String,
deco: Option<TabDeco>,
/// The group's shown tab.
active: bool,
/// The document its panel shows (even behind another tab).
shown_doc: bool,
}
let mut out = Chrome::default();
/// Draw a group's frame + tab strip. Returns actions (tab clicks, panel menu, focus).
/// `title` gives a tab's label, which may name the comp or layer it shows (After Effects style).
pub fn draw_group_chrome(
ui: &mut egui::Ui,
g: &Group,
focused: PanelKind,
t: &Tokens,
reg: &mut crate::automation::Registry,
title: &dyn Fn(PanelKind) -> String,
decos: &[(PanelKind, TabDeco)],
) -> Vec<DockAction> {
let mut actions = Vec::new();
let painter = ui.painter().clone();
painter.rect_filled(g.rect, t.radius, t.panel_bg);
let active_panel = g.panels.get(g.active).copied();
@ -930,102 +833,57 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &T
painter.circle_filled(c + vec2(dx, 0.0), 1.0, t.text_faint);
}
} else {
let mut entries: Vec<Entry> = vec![];
for (pi, p) in g.panels.iter().enumerate() {
let shown = pi == g.active;
match info.docs.iter().find(|(k, d)| k == p && !d.is_empty()) {
Some((_, docs)) => entries.extend(docs.iter().map(|d| Entry {
panel: *p,
doc: Some(d.id),
label: d.title.clone(),
deco: d.deco,
active: shown && d.active,
shown_doc: d.active,
})),
None => entries.push(Entry {
panel: *p,
doc: None,
label: (info.title)(*p),
deco: info.decos.iter().find(|(k, _)| k == p).map(|(_, d)| *d),
active: shown,
shown_doc: false,
}),
}
}
let ai = entries.iter().position(|e| e.active).unwrap_or(0);
let strip = Rect::from_min_size(g.rect.min, vec2(g.rect.width(), t.tab_h));
if t.gradients {
// Settings ▸ Appearance ▸ Use Gradients.
crate::theme::gradient_rect(&painter, strip.shrink2(vec2(t.radius, 0.0)), t.grad_top(t.panel_bg), t.panel_bg);
}
let activate = |e: &Entry| match e.doc {
Some(id) => DockAction::ActivateDoc(e.panel, id),
None => DockAction::Activate(e.panel),
};
let close = |e: &Entry| match e.doc {
Some(id) => DockAction::CloseDoc(e.panel, id),
None => DockAction::Close(e.panel),
};
let mut x = strip.min.x + 12.0;
let text_y = strip.min.y + 16.0;
for (k, e) in entries.iter().enumerate() {
let p = &e.panel;
for (i, p) in g.panels.iter().enumerate() {
// A collapsed stacked panel's header is plain text (no underline or panel menu).
let is_active = k == ai && g.stacked != Some(false);
let galley = painter.layout_no_wrap(e.label.clone(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text });
let is_active = i == g.active && g.stacked != Some(false);
let label = title(*p);
let galley = painter.layout_no_wrap(label.clone(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text });
let menu_w = if is_active { 20.0 } else { 0.0 };
// The lock belongs to the panel: on its shown document's tab only.
let lock = e.deco.is_some_and(|d| d.viewer) && (e.doc.is_none() || is_active);
let deco = decos.iter().find(|(k, _)| k == p).map(|(_, d)| *d);
// × (active tab only), swatch and lock before the label.
let deco_w = match e.deco {
Some(d) if d.viewer => (if is_active { 16.0 } else { 0.0 }) + 14.0 + if lock { 16.0 } else { 0.0 },
let deco_w = match deco {
Some(d) if d.viewer => (if is_active { 16.0 } else { 0.0 }) + 14.0 + 16.0,
Some(_) => 14.0,
None => 0.0,
};
let w = galley.size().x + 16.0 + menu_w + deco_w;
if x + w > strip.max.x - 20.0 && k > ai {
if x + w > strip.max.x - 20.0 && i > g.active {
let r = Rect::from_min_size(pos2(strip.max.x - 22.0, strip.min.y + 6.0), vec2(18.0, 20.0));
let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click());
icons::paint(&painter, r.shrink(4.0).translate(vec2(-2.0, 0.0)), Icon::ChevronRight, t.tab_text);
icons::paint(&painter, r.shrink(4.0).translate(vec2(2.0, 0.0)), Icon::ChevronRight, t.tab_text);
if resp.clicked()
&& let Some(next) = entries.get((ai + 1) % entries.len())
{
out.actions.push(activate(next));
if resp.clicked() {
let next = g.panels[(g.active + 1) % g.panels.len()];
actions.push(DockAction::Activate(next));
}
break;
}
let tab = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, t.tab_h));
match out.tabs.iter_mut().find(|(q, _)| q == p) {
Some((_, r)) => *r = r.union(tab),
None => out.tabs.push((*p, tab)),
}
let resp = ui.interact(tab, egui::Id::new(("tab", g.path.clone(), k)), Sense::click_and_drag());
let resp = ui.interact(tab, egui::Id::new(("tab", g.path.clone(), i)), Sense::click_and_drag());
if resp.drag_started() {
out.actions.push(DockAction::BeginDrag(*p));
}
// A document tab also answers to the panel's id while it is the shown one.
let auto_id = match e.doc {
Some(id) => format!("panel.tab.{}.{id}", p.id()),
None => format!("panel.tab.{}", p.id()),
};
reg.add(&auto_id, tab, &e.label);
if e.shown_doc {
reg.add(&format!("panel.tab.{}", p.id()), tab, &e.label);
actions.push(DockAction::BeginDrag(*p));
}
reg.add(&format!("panel.tab.{}", p.id()), tab, &label);
let mut label_x = tab.min.x + 8.0;
if let Some(d) = e.deco {
if let Some(d) = deco {
let mut dx = tab.min.x + 6.0;
if is_active && d.viewer {
let cr = Rect::from_center_size(pos2(dx + 5.0, text_y), vec2(10.0, 10.0));
let cresp = ui.interact(cr.expand(2.0), egui::Id::new(("tab-close", g.path.clone(), k)), Sense::click());
let cresp = ui.interact(cr.expand(2.0), egui::Id::new(("tab-close", g.path.clone(), i)), Sense::click());
let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text };
let kk = 3.5;
painter.line_segment([cr.center() + vec2(-kk, -kk), cr.center() + vec2(kk, kk)], Stroke::new(1.2, cc));
painter.line_segment([cr.center() + vec2(-kk, kk), cr.center() + vec2(kk, -kk)], Stroke::new(1.2, cc));
let k = 3.5;
painter.line_segment([cr.center() + vec2(-k, -k), cr.center() + vec2(k, k)], Stroke::new(1.2, cc));
painter.line_segment([cr.center() + vec2(-k, k), cr.center() + vec2(k, -k)], Stroke::new(1.2, cc));
reg.add(&format!("panel.tab.{}.close", p.id()), cr, "Close");
if cresp.clicked() {
out.actions.push(close(e));
actions.push(DockAction::Close(*p));
}
dx += 16.0;
}
@ -1033,9 +891,9 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &T
painter.rect_filled(sw, 1.0, d.swatch);
dx += 14.0;
label_x = dx;
if lock {
if d.viewer {
let lr = Rect::from_center_size(pos2(dx + 6.0, text_y), vec2(12.0, 12.0));
let lresp = ui.interact(lr.expand(2.0), egui::Id::new(("tab-lock", g.path.clone(), k)), Sense::click());
let lresp = ui.interact(lr.expand(2.0), egui::Id::new(("tab-lock", g.path.clone(), i)), Sense::click());
let lc = if d.locked {
t.tab_text_active
} else if lresp.hovered() {
@ -1046,7 +904,7 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &T
icons::paint(&painter, lr, Icon::Lock, lc);
reg.add(&format!("panel.tab.{}.lock", p.id()), lr, if d.locked { "Unlock" } else { "Lock" });
if lresp.clicked() {
out.actions.push(DockAction::ToggleLock(*p));
actions.push(DockAction::ToggleLock(*p));
}
label_x = dx + 16.0;
}
@ -1065,19 +923,19 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &T
let uy = strip.min.y + 23.0;
painter.line_segment([pos2(label_x, uy), pos2(mr.max.x, uy)], Stroke::new(1.0, t.tab_text_active));
if mresp.clicked() {
out.actions.push(DockAction::PanelMenu(*p, mr.left_bottom()));
actions.push(DockAction::PanelMenu(*p, mr.left_bottom()));
}
}
if resp.clicked() {
if g.stacked.is_some() {
out.actions.push(DockAction::ToggleStacked(*p));
actions.push(DockAction::ToggleStacked(*p));
} else {
out.actions.push(activate(e));
actions.push(DockAction::Activate(*p));
}
out.actions.push(DockAction::Focus(*p));
actions.push(DockAction::Focus(*p));
}
if resp.middle_clicked() {
out.actions.push(close(e));
actions.push(DockAction::Close(*p));
}
x += w + 8.0;
}
@ -1091,9 +949,9 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &T
&& ui.rect_contains_pointer(g.rect)
&& ui.input(|i| i.pointer.any_pressed())
{
out.actions.push(DockAction::Focus(p));
actions.push(DockAction::Focus(p));
}
out
actions
}
/// Placeholder body for panels that are not implemented yet.

View File

@ -15,8 +15,7 @@ fn drag_id() -> egui::Id {
impl EffectcraftApp {
/// Tab labels that name what the panel shows, as After Effects does: "Composition Intro",
/// "Effect Controls Title", "Properties: Title" (the Timeline's tabs are its comps, see
/// [`Self::tab_docs`]).
/// "Effect Controls Title", "Properties: Title", and the Timeline tab named after its comp.
fn tab_titles(&self) -> Vec<(PanelKind, String)> {
let mut out = Vec::new();
// ScriptUI panels are named after their script.
@ -28,6 +27,7 @@ impl EffectcraftApp {
let Some(comp) = self.session.active_comp() else { return out };
let cname = self.session.active_comp_id().and_then(|id| self.session.project.item(id)).map(|i| i.name.clone()).unwrap_or_default();
out.push((PanelKind::Composition, format!("Composition {cname}")));
out.push((PanelKind::Timeline, cname));
if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) {
out.push((PanelKind::EffectControls, format!("Effect Controls {}", l.name)));
out.push((PanelKind::Properties, format!("Properties: {}", l.name)));
@ -35,35 +35,13 @@ impl EffectcraftApp {
out
}
/// A comp's label colour as a tab swatch (faint without a label).
fn comp_swatch(&self, cid: effectcraft_engine::project::ItemId) -> Color32 {
match self.session.project.item(cid).map(|i| i.label) {
Some(l) if l != effectcraft_engine::color::Label::None => self.tokens.label(l),
_ => self.tokens.text_faint,
}
}
/// The Timeline's tabs: one per open comp, as in After Effects (opening a precomp adds a tab
/// next to its parent's instead of replacing it).
fn tab_docs(&self) -> Vec<(PanelKind, Vec<dock::DocTab>)> {
let st = &self.session.state;
let locked = self.ui.locked_tabs.contains(&PanelKind::Timeline.id());
let docs = st
.open_comps
.iter()
.filter_map(|id| {
let it = self.session.project.item(*id).filter(|i| i.as_comp().is_some())?;
let deco = dock::TabDeco { swatch: self.comp_swatch(*id), locked, viewer: true };
Some(dock::DocTab { id: id.0, title: it.name.clone(), deco: Some(deco), active: st.active_comp == Some(*id) })
})
.collect();
vec![(PanelKind::Timeline, docs)]
}
/// The Composition and Timeline tabs' close button, label-colour swatch and viewer lock.
fn tab_decos(&self) -> Vec<(PanelKind, dock::TabDeco)> {
let Some(cid) = self.session.active_comp_id() else { return vec![] };
let swatch = self.comp_swatch(cid);
let swatch = match self.session.project.item(cid).map(|i| i.label) {
Some(l) if l != effectcraft_engine::color::Label::None => self.tokens.label(l),
_ => self.tokens.text_faint,
};
let mut v: Vec<(PanelKind, dock::TabDeco)> = [PanelKind::Composition, PanelKind::Timeline]
.into_iter()
.map(|p| (p, dock::TabDeco { swatch, locked: self.ui.locked_tabs.contains(&p.id()), viewer: true }))
@ -160,13 +138,6 @@ impl EffectcraftApp {
pub(crate) fn dock_area(&mut self, ui: &mut egui::Ui, body: Rect) {
let t = self.tokens;
let ctx = ui.ctx().clone();
if self.ui.start_screen {
// Home covers the whole workspace, as in After Effects (the panels wait behind it).
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("home"));
child.set_clip_rect(body.intersect(ui.clip_rect()));
panels::home::show(self, &mut child, body);
return;
}
// The maximized panel fills the area; the tree is kept as it is.
let maximized = self.ui.maximized.filter(|m| self.ui.dock.contains(*m));
let mut dock = match maximized {
@ -178,15 +149,9 @@ impl EffectcraftApp {
let mut actions = Vec::new();
let titles = self.tab_titles();
let decos = self.tab_decos();
let docs = self.tab_docs();
let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string());
let info = dock::TabInfo { title: &title, decos: &decos, docs: &docs };
// Where each group's tabs are (tab drags show where a tab will land between them).
let mut tab_rects: Vec<(String, Vec<(PanelKind, Rect)>)> = vec![];
for g in &groups {
let c = dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &info);
actions.extend(c.actions);
tab_rects.push((g.path.clone(), c.tabs));
actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title, &decos));
}
self.label_tab_marks(ui);
if maximized.is_none() {
@ -203,8 +168,7 @@ impl EffectcraftApp {
// Floating groups over the dock.
let mut float_groups: Vec<Group> = vec![];
for i in 0..self.ui.floating.len() {
if let Some((g, tabs)) = self.floating_window(&ctx, i, &info, &mut actions) {
tab_rects.push((g.path.clone(), tabs));
if let Some(g) = self.floating_window(&ctx, i, &title, &mut actions) {
float_groups.push(g);
}
}
@ -233,35 +197,15 @@ impl EffectcraftApp {
self.ui.locked_tabs.insert(p.id().to_string());
}
}
// The Timeline's comp tabs: show that comp / close its Timeline.
DockAction::ActivateDoc(p, id) => {
if !self.ui.dock.activate(p)
&& let Some(f) = self.ui.floating.iter_mut().find(|f| f.panels.contains(&p))
{
f.active = f.panels.iter().position(|x| *x == p).unwrap_or(0);
}
if self.session.state.active_comp.map(|c| c.0) != Some(id) {
let _ = crate::menus::invoke(self, &ctx, "comp.open", serde_json::json!({"comp": id}));
}
}
DockAction::CloseDoc(_, id) => {
let _ = crate::menus::invoke(self, &ctx, "comp.close", serde_json::json!({"comp": id}));
}
}
}
self.tab_drag(&ctx, &groups, &float_groups, &tab_rects);
self.tab_drag(&ctx, &groups, &float_groups);
panels::panel_menu_popup(self, ui);
}
/// One floating group: a window with a tab strip (drag the empty strip to move it, the
/// corner to resize it) and the active panel's body.
fn floating_window(
&mut self,
ctx: &egui::Context,
i: usize,
info: &dock::TabInfo,
actions: &mut Vec<DockAction>,
) -> Option<(Group, Vec<(PanelKind, Rect)>)> {
fn floating_window(&mut self, ctx: &egui::Context, i: usize, title: &dyn Fn(PanelKind) -> String, actions: &mut Vec<DockAction>) -> Option<Group> {
let t = self.tokens;
let f = self.ui.floating.get(i)?.clone();
let screen = ctx.content_rect();
@ -292,8 +236,8 @@ impl EffectcraftApp {
active: f.active.min(f.panels.len().saturating_sub(1)),
stacked: None,
};
let c = dock::draw_group_chrome(ui, &g, self.ui.focused, &t, &mut self.auto, info);
actions.extend(c.actions);
let decos = self.tab_decos();
actions.extend(dock::draw_group_chrome(ui, &g, self.ui.focused, &t, &mut self.auto, title, &decos));
ui.painter().rect_stroke(r, t.radius, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
if let Some(p) = g.panels.get(g.active).copied() {
self.panel_body(ui, p, g.content);
@ -317,16 +261,14 @@ impl EffectcraftApp {
{
fl.rect = [nr.min.x, nr.min.y, nr.width(), nr.height()];
}
group = Some((g, c.tabs));
group = Some(g);
});
group
}
/// A tab being dragged. Over a tab strip (or the middle of a group) it shows where the tab
/// will land: an insertion mark between two tabs (a drop there reorders a group's own tabs
/// too); near a group's edge, the half it will split off. On release it lands there (or
/// floats with Cmd/Ctrl or outside every group).
fn tab_drag(&mut self, ctx: &egui::Context, groups: &[Group], floats: &[Group], tab_rects: &[(String, Vec<(PanelKind, Rect)>)]) {
/// A tab being dragged: highlight the drop zone under the pointer; on release, dock (or
/// float with Cmd/Ctrl or outside every group).
fn tab_drag(&mut self, ctx: &egui::Context, groups: &[Group], floats: &[Group]) {
let Some(p) = ctx.data(|d| d.get_temp::<PanelKind>(drag_id())) else { return };
let t = self.tokens;
let Some(pos) = ctx.pointer_latest_pos() else { return };
@ -334,55 +276,23 @@ impl EffectcraftApp {
let target = floats.iter().map(|g| (g, true)).chain(groups.iter().map(|g| (g, false))).find(|(g, _)| g.rect.contains(pos));
let float_drop = ctx.input(|i| i.modifiers.command);
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("dock-drop")));
let plan: Option<DropPlan> = match target {
let plan: Option<(PanelKind, Zone)> = match target {
Some((g, is_float)) if !float_drop => {
let others: Vec<PanelKind> = g.panels.iter().copied().filter(|x| *x != p).collect();
let anchor = g.panels.get(g.active).copied().unwrap_or(p);
let zone = if is_float { Zone::Center } else { dock::drop_zone(g.rect, t.tab_h, pos) };
match (others.first().copied(), zone) {
// The dragged tab alone in its group: nowhere to go here.
(None, _) => None,
(Some(first), Zone::Center) => {
let rects: Vec<(PanelKind, Rect)> = tab_rects
.iter()
.find(|(path, _)| *path == g.path)
.map(|(_, r)| r.iter().copied().filter(|(k, _)| *k != p).collect())
.unwrap_or_default();
let strip = Rect::from_min_size(g.rect.min, vec2(g.rect.width(), t.tab_h));
let before = if strip.contains(pos) {
// Before the first tab whose middle is right of the pointer.
rects.iter().find(|(_, r)| r.center().x > pos.x).map(|(k, _)| *k)
} else if g.panels.contains(&p) {
// The middle of its own group: it stays where it is.
Some(p)
} else {
// The middle of another group: right after its shown tab.
let shown = g.panels.get(g.active).and_then(|a| others.iter().position(|x| x == a)).unwrap_or(others.len());
others.get(shown + 1).copied()
};
if before == Some(p) {
None
} else {
let x = match before.and_then(|b| rects.iter().find(|(k, _)| *k == b)) {
Some((_, r)) => r.min.x - 4.0,
None => rects.last().map_or(strip.min.x + 8.0, |(_, r)| r.max.x + 4.0),
};
painter.rect_filled(strip.shrink2(vec2(2.0, 1.0)), t.radius, t.focus.gamma_multiply(0.12));
if !strip.contains(pos) {
painter.rect_stroke(g.rect, t.radius, Stroke::new(1.0, t.focus.gamma_multiply(0.6)), StrokeKind::Inside);
}
insertion_mark(&painter, x, strip, t.focus);
Some(DropPlan::Tab { anchor: first, before })
}
}
(Some(first), zone) => {
let shown = g.panels.get(g.active).copied().filter(|a| *a != p).unwrap_or(first);
let hr = dock::zone_rect(g.rect, zone).shrink(2.0);
painter.rect_filled(hr, t.radius, t.focus.gamma_multiply(0.25));
painter.rect_stroke(hr, t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside);
// Outline the whole group for the side zones, like AE's drop-zone frame.
let self_only = anchor == p && g.panels.len() == 1;
if self_only {
None
} else {
let anchor = if anchor == p { g.panels.iter().copied().find(|x| *x != p).unwrap_or(p) } else { anchor };
let hr = dock::zone_rect(g.rect, zone).shrink(2.0);
painter.rect_filled(hr, t.radius, t.focus.gamma_multiply(0.25));
painter.rect_stroke(hr, t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside);
// Outline the whole group for the side zones, like AE's drop-zone frame.
if zone != Zone::Center {
painter.rect_stroke(g.rect, t.radius, Stroke::new(1.0, t.focus.gamma_multiply(0.6)), StrokeKind::Inside);
Some(DropPlan::Side { anchor: shown, zone })
}
Some((anchor, zone))
}
}
_ => None,
@ -397,8 +307,7 @@ impl EffectcraftApp {
if ctx.input(|i| i.pointer.any_released()) {
ctx.data_mut(|d| d.remove::<PanelKind>(drag_id()));
let ok = match plan {
Some(DropPlan::Tab { anchor, before }) => self.edit_layout(|l| l.insert_tab(p, anchor, before)),
Some(DropPlan::Side { anchor, zone }) => self.edit_layout(|l| l.dock(p, anchor, zone)),
Some((anchor, zone)) => self.edit_layout(|l| l.dock(p, anchor, zone)),
None if float_drop || target.is_none() => {
let r = [pos.x - 40.0, pos.y - 12.0, 420.0, 320.0];
self.edit_layout(|l| l.float(p, r))
@ -413,23 +322,6 @@ impl EffectcraftApp {
}
}
/// Where a dragged tab lands.
enum DropPlan {
/// A tab in the group of `anchor`, before `before` (last when `None`).
Tab { anchor: PanelKind, before: Option<PanelKind> },
/// A new group split off one side of `anchor`'s group.
Side { anchor: PanelKind, zone: Zone },
}
/// The insertion mark of a tab drag: a bar between two tabs at `x`, with caps.
fn insertion_mark(painter: &egui::Painter, x: f32, strip: Rect, col: Color32) {
let (y0, y1) = (strip.min.y + 3.0, strip.max.y - 2.0);
painter.line_segment([pos2(x, y0), pos2(x, y1)], Stroke::new(2.5, col));
for (y, d) in [(y0, 1.0), (y1, -1.0)] {
painter.add(egui::Shape::convex_polygon(vec![pos2(x - 4.5, y), pos2(x + 4.5, y), pos2(x, y + 5.0 * d)], col, Stroke::NONE));
}
}
/// A new floating group's default rect near the centre of `screen`.
pub fn default_float_rect(screen: Rect) -> [f32; 4] {
let c = screen.center();

View File

@ -293,25 +293,21 @@ pub fn color_popup(ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f3
changed
}
/// The ArtCraft mark as supplied in `docs/brand` (the README's and getartcraft.com's logo; used
/// unmodified, see `docs/brand/LICENSE-brand.txt`).
static ARTCRAFT_MARK: &[u8] = include_bytes!("../../../docs/brand/artcraft-mark.png");
/// The ArtCraft mark in `r` (decoded once into a mipmapped texture, so it stays crisp from the
/// 24 px Tools bar to the About dialog). Nothing is drawn if it can't be decoded.
/// The EffectCraft mark: a rounded square with a gradient-like split and a keyframe diamond.
pub fn paint_logo(p: &egui::Painter, r: Rect) {
let ctx = p.ctx();
let id = egui::Id::new("artcraft-mark");
let tex = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(id)).unwrap_or_else(|| {
let tex = image::load_from_memory_with_format(ARTCRAFT_MARK, image::ImageFormat::Png).ok().map(|img| {
let img = img.to_rgba8();
let ci = egui::ColorImage::from_rgba_unmultiplied([img.width() as usize, img.height() as usize], img.as_raw());
ctx.load_texture("artcraft-mark", ci, egui::TextureOptions::LINEAR.with_mipmap_mode(Some(egui::TextureFilter::Linear)))
});
ctx.data_mut(|d| d.insert_temp(id, tex.clone()));
tex
});
if let Some(t) = tex {
p.image(t.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
p.rect_filled(r, 6.0, Color32::from_rgb(0x1d, 0x2b, 0x5e));
let inner = r.shrink(2.0);
p.rect_filled(
Rect::from_min_max(inner.min, pos2(inner.center().x, inner.max.y)),
egui::CornerRadius { nw: 5, sw: 5, ne: 0, se: 0 },
Color32::from_rgb(0x2d, 0x8c, 0xeb),
);
p.rect_filled(
Rect::from_min_max(pos2(inner.center().x, inner.min.y), inner.max),
egui::CornerRadius { nw: 0, sw: 0, ne: 5, se: 5 },
Color32::from_rgb(0x8e, 0x6b, 0xff),
);
let c = r.center();
let s = r.width() * 0.26;
p.add(egui::Shape::convex_polygon(vec![c + vec2(0.0, -s), c + vec2(s, 0.0), c + vec2(0.0, s), c + vec2(-s, 0.0)], Color32::WHITE, Stroke::NONE));
}

View File

@ -1121,11 +1121,8 @@ impl EffectcraftApp {
for ev in self.session.drain_events() {
match ev {
effectcraft_engine::Event::OpenComp(_) => {
// The comp's viewer and its Timeline tab come to the front.
for p in [PanelKind::Composition, PanelKind::Timeline] {
if !self.ui.locked_tabs.contains(&p.id()) {
self.ui.dock.activate(p);
}
if !self.ui.locked_tabs.contains(&PanelKind::Composition.id()) {
self.ui.dock.activate(PanelKind::Composition);
}
self.ui.timeline.pps = None;
}

View File

@ -196,15 +196,6 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
if crate::panels::unsaved::guard(app, id, &params) {
return Ok(json!({"dialog": "unsavedChanges"}));
}
// J / K and Select All Keyframes act on what the Timeline shows (its revealed properties).
if matches!(id, "time.nextKey" | "time.previousKey" | "keys.selectAll")
&& ["visible", "layers", "layer", "prop"].iter().all(|k| params.get(*k).is_none())
&& let Some(c) = app.session.active_comp_arc()
{
let mut p = if params.is_object() { params.clone() } else { json!({}) };
p["visible"] = json!(crate::panels::timeline::visible_props(app, &c));
return run_engine(app, ctx, id, p);
}
// New Camera/Light and Camera/Light Settings without parameters open their dialogs.
if crate::panels::dialogs_3d::route(app, id, &params)? {
return Ok(Value::Null);

View File

@ -1098,13 +1098,7 @@ pub fn viewer_hook(
app.ui.fx_pick = None;
return;
};
// Keyers pick from their input (the shown frame is already keyed): `effect.pickColor`.
let keyer = (pick.kind == "color")
.then(|| layer.effects()?.groups().find(|g| g.find(pick.prop).is_some()))
.flatten()
.filter(|g| effectcraft_engine::effects::find(&g.match_id).is_some_and(|s| s.category == "Keying"))
.map(|g| g.uid);
if pick.kind == "color" && keyer.is_none() {
if pick.kind == "color" {
// GPU frames: read the shown frame back for sampling.
app.viewer_pixels();
}
@ -1119,7 +1113,6 @@ pub fn viewer_hook(
let c = map.to_comp(hp);
app.pointer_comp = Some([c[0] as f32, c[1] as f32]);
if pick.kind == "color"
&& keyer.is_none()
&& let Some(img) = &app.viewer_image
&& let Some(s) = fw::sample_frame(img, map.comp, c)
{
@ -1136,15 +1129,6 @@ pub fn viewer_hook(
if let Some(lp) = fw::comp_to_layer(&l2c(ectx, &layer), c) {
actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [lp[0], lp[1]]})));
}
} else if let Some(fx) = keyer {
// Ctrl/Cmd+click averages the 5 × 5 pixels around the point.
if let Some(lp) = fw::comp_to_layer(&l2c(ectx, &layer), c) {
let average = ui.input(|i| i.modifiers.command);
actions.push((
"effect.pickColor".into(),
json!({"layer": pick.layer, "effect": fx, "prop": pick.prop, "x": lp[0], "y": lp[1], "average": average}),
));
}
} else if let Some(img) = &app.viewer_image
&& let Some(s) = fw::sample_frame(img, map.comp, c)
{

View File

@ -323,8 +323,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let mut y = list.min.y - scroll;
let mut apply: Option<Apply> = None;
let reg = effectcraft_engine::effects::all();
let keep =
|e: &effectcraft_engine::effects::EffectSpec| depth_keeps(&view.depth, e) && (query.is_empty() || effectcraft_engine::effects::name_matches(e, &query));
let keep = |e: &effectcraft_engine::effects::EffectSpec| depth_keeps(&view.depth, e) && (query.is_empty() || e.name.to_lowercase().contains(&query));
let lookup = |id: &str| reg.iter().find(|e| e.id == id);
// Favorites and Recently Used.

View File

@ -63,22 +63,6 @@ struct Curve<'a> {
}
/// Properties shown: the selected ones, or every animated property of the selected layers.
/// Whether the speed graph shows: Edit Speed Graph, or Auto-Select Graph Type with only spatial
/// properties shown (Position, Anchor Point… move along a path: their speed is what to edit).
fn speed_graph(app: &EffectcraftApp, comp: &Comp) -> bool {
match app.ui.timeline.graph_mode.as_str() {
"speed" => true,
"value" => false,
_ => {
let props = shown_props(app, comp);
!props.is_empty()
&& props.iter().all(|(lid, uid)| {
comp.layer(*lid).and_then(|l| l.props.find(*uid)).is_some_and(|pr| pr.spatial && matches!(pr.value, Value::Vec2(_) | Value::Vec3(_)))
})
}
}
}
fn shown_props(app: &EffectcraftApp, comp: &Comp) -> Vec<(LayerId, u64)> {
let st = &app.session.state;
let numeric = |pr: &Property| !pr.value.components().is_empty() && pr.value.interpolates();
@ -195,7 +179,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
let bg = ui.interact(plot, egui::Id::new("graph-bg"), Sense::click_and_drag());
p.rect_filled(area, 0.0, Color32::from_rgb(0x1d, 0x1d, 0x1d));
app.auto.add("timeline.graph", plot, &app.ui.timeline.graph_mode.clone());
let speed = speed_graph(app, comp);
let speed = app.ui.timeline.graph_mode == "speed";
let cs = curves(app, comp, ectx, tm, plot, speed);
// Vertical range: auto-zoom to the curves + handles, or the stored manual range.
@ -424,25 +408,11 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
let y = bar.center().y;
let mut x = bar.min.x + 8.0;
let tl = &app.ui.timeline;
let (auto_type, show_sel, auto_on) = (tl.graph_mode == "auto", tl.graph_show_selected, tl.graph_auto_zoom);
if text_button(
app,
ui,
p,
&mut x,
y,
"Auto",
auto_type,
"autoSelectGraphType",
"Auto-Select Graph Type (speed graph for spatial properties, else value graph)",
) {
app.ui.timeline.graph_mode = if auto_type { if speed { "speed" } else { "value" } } else { "auto" }.into();
}
// Choosing a graph type turns Auto-Select off, as in After Effects.
if text_button(app, ui, p, &mut x, y, "Value", !speed, "valueGraph", "Edit Value Graph") {
let (is_value, show_sel, auto_on) = (tl.graph_mode != "speed", tl.graph_show_selected, tl.graph_auto_zoom);
if text_button(app, ui, p, &mut x, y, "Value", is_value, "valueGraph", "Edit Value Graph") {
app.ui.timeline.graph_mode = "value".into();
}
if text_button(app, ui, p, &mut x, y, "Speed", speed, "speedGraph", "Edit Speed Graph") {
if text_button(app, ui, p, &mut x, y, "Speed", !is_value, "speedGraph", "Edit Speed Graph") {
app.ui.timeline.graph_mode = "speed".into();
}
x += 6.0;

View File

@ -1,10 +1,8 @@
//! The Home screen, laid out like After Effects' (shown over the whole workspace at launch, from
//! the Tools bar's Home button and by the Learn workspace). A left rail holds New Project / Open
//! Project, the Home / Templates / Learn pages and, at its foot, the ArtCraft community links.
//! The Home page welcomes you with quick-start tiles (New Composition, the demo project, Import,
//! New from Template) over the recent projects (File ▸ Open Recent, from Settings): a filterable
//! table with thumbnails and Name / Opened / Size / Kind columns. Templates is the New from
//! Template gallery (`templates`), Learn the guided tutorials (`learn`).
//! The Home screen (shown in the Composition panel at launch, from the header's Home button, and
//! by the Learn workspace): New Project / Open Project / New Composition / Open Demo, the recent
//! projects (File ▸ Open Recent, from Settings) with thumbnails, folders and dates, the
//! Templates tab (New from Template gallery, `templates`), the Learn tab, and where After
//! Effects has its Learn / What's New area, the ArtCraft community links.
//!
//! Thumbnails: when a project is opened or saved and the viewer has its frame, a 96×54 RGB
//! thumbnail is stored in the config store (`thumb-<hash>.txt`, hex) and shown here.
@ -31,10 +29,6 @@ pub struct RecentEntry {
pub folder: String,
/// Last modified (`2026-10-02 14:05`, UTC), when the file is readable.
pub modified: Option<String>,
/// Last modified, Unix seconds.
pub modified_secs: Option<i64>,
/// File size in bytes.
pub size: Option<u64>,
pub exists: bool,
}
@ -47,19 +41,17 @@ pub fn recent_entries(prefs: &effectcraft_engine::prefs::Prefs) -> Vec<RecentEnt
.map(|(index, p)| {
let path = std::path::Path::new(p);
let meta = std::fs::metadata(path).ok();
let modified_secs = meta
let modified = meta
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
.map(|d| format_utc(d.as_secs() as i64));
RecentEntry {
index,
path: p.clone(),
name: path.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()),
folder: path.parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(),
modified: modified_secs.map(format_utc),
modified_secs,
size: meta.as_ref().filter(|m| m.is_file()).map(|m| m.len()),
modified,
exists: meta.is_some(),
}
})
@ -83,45 +75,6 @@ pub fn format_utc(secs: i64) -> String {
format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", rem / 3600, (rem % 3600) / 60)
}
/// How long ago `then` was, as the Home screen's Opened column shows it ("3 hours ago", then the
/// date after a week).
pub fn format_ago(now: i64, then: i64) -> String {
let d = now.saturating_sub(then);
let plural = |n: i64, unit: &str| format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" });
match d {
i64::MIN..60 => "Just now".to_string(),
60..3_600 => plural(d / 60, "minute"),
3_600..86_400 => plural(d / 3_600, "hour"),
86_400..172_800 => "Yesterday".to_string(),
172_800..604_800 => plural(d / 86_400, "day"),
_ => format_utc(then).get(..10).unwrap_or_default().to_string(),
}
}
/// A file size as the Home screen shows it (`12 KB`, `3.4 MB`).
pub fn format_size(bytes: u64) -> String {
let b = bytes as f64;
if b < 1024.0 {
format!("{bytes} B")
} else if b < 1024.0 * 1024.0 {
format!("{:.0} KB", b / 1024.0)
} else if b < 1024.0 * 1024.0 * 1024.0 {
format!("{:.1} MB", b / (1024.0 * 1024.0))
} else {
format!("{:.1} GB", b / (1024.0 * 1024.0 * 1024.0))
}
}
/// The Kind column: what a recent file is, by its extension.
fn kind_of(path: &str) -> &'static str {
match std::path::Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() {
Some("ecproj") => "EffectCraft project",
Some("aep" | "aepx") => "After Effects project",
Some("json" | "lottie") => "Lottie animation",
_ => "Project",
}
}
/// The config-store name of a project's thumbnail.
pub fn thumb_name(path: &str) -> String {
let mut h = std::collections::hash_map::DefaultHasher::new();
@ -204,261 +157,191 @@ fn thumb_texture(app: &mut EffectcraftApp, ctx: &egui::Context, path: &str) -> O
tex
}
/// Width of the Home screen's left rail.
const RAIL_W: f32 = 236.0;
/// Draw the Home screen over `rect` (the whole workspace).
/// Draw the Home screen.
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let p = ui.painter().with_clip_rect(rect);
p.rect_filled(rect, 0.0, t.app_bg);
p.rect_filled(rect, 0.0, Color32::from_rgb(0x1b, 0x1d, 0x24));
let wide = rect.width() >= 820.0;
let pad = 32.0;
let left_w = if wide { 250.0 } else { rect.width() - pad * 2.0 };
let mut actions: Vec<(&str, serde_json::Value)> = vec![];
let rail = Rect::from_min_size(rect.min, vec2(RAIL_W.min(rect.width() * 0.4), rect.height()));
p.rect_filled(rail, 0.0, t.panel_bg);
p.line_segment([rail.right_top(), rail.right_bottom()], Stroke::new(1.0, t.separator));
rail_ui(app, ui, &p, rail, &mut actions);
let main = Rect::from_min_max(pos2(rail.max.x + 40.0, rect.min.y + 32.0), pos2(rect.max.x - 40.0, rect.max.y - 16.0));
if app.ui.home_learn {
super::learn::home_tab(app, ui, main);
} else if app.ui.home_templates {
super::templates::home_tab(app, ui, main);
} else {
home_page(app, ui, &p, main, &ctx, &mut actions);
}
run_actions(app, &ctx, actions);
}
/// The left rail: New / Open Project, the pages, and the community links at its foot.
fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail: Rect, actions: &mut Vec<(&'static str, serde_json::Value)>) {
let t = app.tokens;
let x0 = rail.min.x + 16.0;
let w = rail.width() - 32.0;
let mut y = rail.min.y + 20.0;
for (label, id, primary) in [("New Project", "file.newProject", true), ("Open Project…", "file.open", false)] {
let r = Rect::from_min_size(pos2(x0, y), vec2(w, 34.0));
if widgets::text_button(ui, r, label, primary, &t, egui::Id::new(("home", id))).clicked() {
actions.push((id, json!({})));
}
app.auto.add(&format!("home.{id}"), r, label);
y += 42.0;
}
y += 10.0;
// Pages.
let page = if app.ui.home_learn {
// ---- left: brand + start actions.
let x0 = rect.min.x + pad;
let mut y = rect.min.y + pad;
crate::header::paint_logo(&p, Rect::from_min_size(pos2(x0, y), vec2(40.0, 40.0)));
p.text(pos2(x0 + 52.0, y + 12.0), Align2::LEFT_CENTER, "EffectCraft", Tokens::semibold(22.0), Color32::WHITE);
p.text(pos2(x0 + 52.0, y + 32.0), Align2::LEFT_CENTER, format!("Version {}", env!("CARGO_PKG_VERSION")), Tokens::ui(11.0), t.text_faint);
y += 56.0;
// Home / Templates / Learn tabs.
let tab = if app.ui.home_learn {
2
} else if app.ui.home_templates {
1
} else {
0
};
for (k, (label, icon)) in [("Home", Icon::Home), ("Templates", Icon::Grid), ("Learn", Icon::Sparkle)].into_iter().enumerate() {
let r = Rect::from_min_size(pos2(rail.min.x + 8.0, y), vec2(rail.width() - 16.0, 34.0));
for (k, label) in ["Home", "Templates", "Learn"].into_iter().enumerate() {
let r = Rect::from_min_size(pos2(x0 + k as f32 * 84.0, y), vec2(78.0, 28.0));
let resp = ui.interact(r, egui::Id::new(("home-tab", label)), Sense::click());
let on = page == k;
let on = tab == k;
if on || resp.hovered() {
p.rect_filled(r, 6.0, if on { t.row_selected } else { t.hover });
p.rect_filled(r, 6.0, if on { Color32::from_rgb(0x2e, 0x31, 0x3b) } else { t.hover });
}
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::medium(13.0), if on { Color32::WHITE } else { t.text_dim });
if on {
p.line_segment([pos2(r.min.x + 12.0, r.max.y - 1.0), pos2(r.max.x - 12.0, r.max.y - 1.0)], Stroke::new(2.0, t.accent));
}
let col = if on { t.tab_text_active } else { t.text_dim };
icons::paint(p, Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(16.0, 16.0)), icon, col);
p.text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(13.0), col);
app.auto.add(&format!("home.tab.{}", label.to_ascii_lowercase()), r, label);
if resp.clicked() {
app.ui.home_learn = k == 2;
app.ui.home_templates = k == 1;
}
y += 38.0;
}
// Community links at the foot (below the pages when the window is short).
y += 40.0;
for (label, id, primary) in [
("New Project", "file.newProject", true),
("Open Project…", "file.open", false),
("New Composition", "app.newComp", false),
("Open Demo Project", "file.openDemoProject", false),
("Import Footage…", "file.import", false),
] {
let r = Rect::from_min_size(pos2(x0, y), vec2(left_w.min(250.0), 32.0));
if widgets::text_button(ui, r, label, primary, &t, egui::Id::new(("home", id))).clicked() {
actions.push((id, json!({})));
}
app.auto.add(&format!("home.{id}"), r, label);
y += 40.0;
}
// ---- community (After Effects' Learn / What's New area).
let (cx, mut cy, cw) = if wide { (x0, y + 18.0, left_w) } else { (x0, y + 10.0, left_w) };
p.text(pos2(cx, cy), Align2::LEFT_CENTER, "Community", Tokens::semibold(13.0), t.text);
cy += 16.0;
p.text(pos2(cx, cy), Align2::LEFT_CENTER, "Tutorials, help and what's new", Tokens::ui(11.0), t.text_faint);
cy += 16.0;
let links = [
(Icon::Chat, "Join the ArtCraft Discord", "help.discord"),
(Icon::Globe, "getartcraft.com", "help.website"),
(Icon::Globe, "EffectCraft home page", "help.appPage"),
(Icon::Code, "EffectCraft on GitHub", "help.github"),
];
let sib = effectcraft_engine::links::SIBLINGS;
let sib_rows = sib.len().div_ceil(2) as f32;
let foot_h = 22.0 + links.len() as f32 * 30.0 + 22.0 + sib_rows * 26.0;
let mut cy = (rail.max.y - 16.0 - foot_h).max(y + 16.0);
p.line_segment([pos2(x0, cy - 10.0), pos2(x0 + w, cy - 10.0)], Stroke::new(1.0, t.separator));
p.text(pos2(x0, cy + 6.0), Align2::LEFT_CENTER, "Community", Tokens::semibold(12.0), t.text_dim);
cy += 22.0;
for (icon, label, cmd) in links {
let r = Rect::from_min_size(pos2(x0, cy), vec2(w, 26.0));
let r = Rect::from_min_size(pos2(cx, cy), vec2(cw.min(250.0), 30.0));
let resp = ui.interact(r, egui::Id::new(("home-link", cmd)), Sense::click());
let discord = cmd == "help.discord";
if discord {
p.rect_filled(r, 13.0, Color32::from_rgb(0x58, 0x65, 0xf2));
let bg = if discord {
Color32::from_rgb(0x58, 0x65, 0xf2)
} else if resp.hovered() {
p.rect_filled(r, 13.0, t.hover);
}
let col = if discord { Color32::WHITE } else { t.text };
icons::paint(p, Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(13.0, 13.0)), icon, col);
p.text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.0), col);
t.hover
} else {
Color32::from_rgb(0x2a, 0x2c, 0x34)
};
p.rect_filled(r, 15.0, bg);
icons::paint(&p, Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(14.0, 14.0)), icon, Color32::WHITE);
p.text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.0), Color32::WHITE);
app.auto.add(&format!("home.{cmd}"), r, label);
if resp.clicked() {
let _ = app.session.execute(cmd, json!({}));
}
cy += 30.0;
cy += 36.0;
}
p.text(pos2(x0, cy + 8.0), Align2::LEFT_CENTER, "More ArtCraft apps", Tokens::ui(11.0), t.text_faint);
cy += 20.0;
let sw = (w - 6.0) / 2.0;
cy += 8.0;
p.text(pos2(cx, cy), Align2::LEFT_CENTER, "More ArtCraft apps", Tokens::ui(11.0), t.text_faint);
cy += 12.0;
let sib = effectcraft_engine::links::SIBLINGS;
let per_row = 2usize;
let sw = (cw.min(250.0) - 6.0) / per_row as f32;
for (i, (name, slug)) in sib.iter().enumerate() {
let r = Rect::from_min_size(pos2(x0 + (i % 2) as f32 * (sw + 6.0), cy + (i / 2) as f32 * 26.0), vec2(sw, 22.0));
let r = Rect::from_min_size(pos2(cx + (i % per_row) as f32 * (sw + 6.0), cy + (i / per_row) as f32 * 28.0), vec2(sw, 24.0));
let resp = ui.interact(r, egui::Id::new(("sib", *slug)), Sense::click());
p.rect_filled(r, 11.0, if resp.hovered() { t.hover } else { t.field_bg });
p.text(r.center(), Align2::CENTER_CENTER, *name, Tokens::ui(11.0), t.text);
p.rect_filled(r, 12.0, if resp.hovered() { t.hover } else { Color32::from_rgb(0x24, 0x26, 0x2e) });
p.text(r.center(), Align2::CENTER_CENTER, *name, Tokens::ui(11.5), t.text);
app.auto.add(&format!("home.sibling.{slug}"), r, name);
if resp.clicked() {
let _ = app.session.execute("help.sibling", json!({"app": slug}));
}
}
}
let community_bottom = cy + sib.len().div_ceil(per_row) as f32 * 28.0;
/// The Home page: the welcome, quick-start tiles and the recent projects table.
fn home_page(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
area: Rect,
ctx: &egui::Context,
actions: &mut Vec<(&'static str, serde_json::Value)>,
) {
let t = app.tokens;
let (x0, w) = (area.min.x, area.width());
let mut y = area.min.y;
p.text(pos2(x0, y + 14.0), Align2::LEFT_CENTER, "Welcome to EffectCraft", Tokens::semibold(26.0), t.tab_text_active);
y += 40.0;
p.text(
pos2(x0, y + 8.0),
Align2::LEFT_CENTER,
format!("Motion graphics and visual effects · version {}", env!("CARGO_PKG_VERSION")),
Tokens::ui(12.5),
t.text_dim,
);
y += 36.0;
// Quick start.
let tiles = [
("New Composition", "app.newComp", Icon::NewComp),
("Open Demo Project", "file.openDemoProject", Icon::Play),
("Import Footage…", "file.import", Icon::Footage),
("New from Template", "templates", Icon::Grid),
];
let gap = 12.0;
let tw = ((w - gap * 3.0) / 4.0).clamp(120.0, 220.0);
for (k, (label, id, icon)) in tiles.into_iter().enumerate() {
let r = Rect::from_min_size(pos2(x0 + k as f32 * (tw + gap), y), vec2(tw, 64.0));
let resp = ui.interact(r, egui::Id::new(("home-tile", id)), Sense::click());
p.rect_filled(r, 8.0, if resp.hovered() { t.hover } else { t.panel_bg });
p.rect_stroke(r, 8.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.separator }), StrokeKind::Inside);
let ir = Rect::from_center_size(pos2(r.min.x + 30.0, r.center().y), vec2(32.0, 32.0));
p.rect_filled(ir, 7.0, t.accent.gamma_multiply(0.18));
icons::paint(p, ir.shrink(8.0), icon, t.accent);
p.with_clip_rect(r.shrink(2.0)).text(pos2(ir.max.x + 12.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.5), t.text);
app.auto.add(&format!("home.{id}"), r, label);
if resp.clicked() {
if id == "templates" {
app.ui.home_templates = true;
app.ui.home_learn = false;
} else {
actions.push((id, json!({})));
}
}
// ---- recent projects (or the Learn tab's tutorials).
let (rx, mut ry, rw) = if wide {
let rx = x0 + left_w + 40.0;
(rx, rect.min.y + pad + 6.0, rect.max.x - pad - rx)
} else {
(x0, community_bottom + 24.0, left_w)
};
if app.ui.home_learn {
super::learn::home_tab(app, ui, Rect::from_min_max(pos2(rx, ry), pos2(rx + rw, rect.max.y - 8.0)));
run_actions(app, &ctx, actions);
return;
}
y += 64.0 + 36.0;
// Recent: heading, filter, clear.
p.text(pos2(x0, y + 12.0), Align2::LEFT_CENTER, "Recent", Tokens::semibold(17.0), t.tab_text_active);
if app.ui.home_templates {
super::templates::home_tab(app, ui, Rect::from_min_max(pos2(rx, ry), pos2(rx + rw, rect.max.y - 8.0)));
run_actions(app, &ctx, actions);
return;
}
p.text(pos2(rx, ry + 6.0), Align2::LEFT_CENTER, "Recent", Tokens::semibold(16.0), Color32::WHITE);
let entries = recent_entries(&app.session.prefs);
let fid = egui::Id::new("home-filter");
let mut filter: String = ctx.data(|d| d.get_temp(fid)).unwrap_or_default();
let fr = Rect::from_min_size(pos2(x0 + w - 240.0, y), vec2(240.0, 26.0));
widgets::search_field(ui, fr, &mut filter, "Filter recent files", &t);
app.auto.add("home.filter", fr, "Filter recent files");
ctx.data_mut(|d| d.insert_temp(fid, filter.clone()));
if !entries.is_empty() {
let cr = Rect::from_min_size(pos2(fr.min.x - 96.0, y + 3.0), vec2(84.0, 20.0));
let cr = Rect::from_min_size(pos2(rx + rw - 100.0, ry - 4.0), vec2(100.0, 20.0));
let resp = ui.interact(cr, egui::Id::new("home-clear-recent"), Sense::click());
p.text(cr.right_center(), Align2::RIGHT_CENTER, "Clear list", Tokens::ui(11.5), if resp.hovered() { t.text } else { t.text_faint });
p.text(cr.right_center(), Align2::RIGHT_CENTER, "Clear list", Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_faint });
app.auto.add("home.clearRecent", cr, "Clear Recent Projects");
if resp.clicked() {
actions.push(("file.clearRecent", json!({})));
}
}
y += 42.0;
// Column headings (Name / Opened / Size / Kind, as in After Effects).
let (opened_x, size_x, kind_x) = (x0 + w * 0.52, x0 + w * 0.68, x0 + w * 0.80);
for (label, x) in [("NAME", x0 + 76.0), ("OPENED", opened_x), ("SIZE", size_x), ("KIND", kind_x)] {
p.text(pos2(x, y), Align2::LEFT_CENTER, label, Tokens::medium(10.5), t.text_faint);
ry += 26.0;
// Column headings.
let date_w = 130.0;
p.text(pos2(rx + 112.0, ry), Align2::LEFT_CENTER, "Name", Tokens::ui(11.0), t.text_faint);
p.text(pos2(rx + rw - date_w, ry), Align2::LEFT_CENTER, "Modified", Tokens::ui(11.0), t.text_faint);
ry += 10.0;
p.line_segment([pos2(rx, ry), pos2(rx + rw, ry)], Stroke::new(1.0, t.separator));
ry += 6.0;
if entries.is_empty() {
p.text(pos2(rx, ry + 20.0), Align2::LEFT_CENTER, "No recent projects. Projects you open or save appear here.", Tokens::ui(12.0), t.text_faint);
}
y += 12.0;
p.line_segment([pos2(x0, y), pos2(x0 + w, y)], Stroke::new(1.0, t.separator));
y += 4.0;
let q = filter.trim().to_lowercase();
let shown: Vec<&RecentEntry> =
entries.iter().filter(|e| q.is_empty() || e.name.to_lowercase().contains(&q) || e.folder.to_lowercase().contains(&q)).collect();
if shown.is_empty() {
let c = pos2(area.center().x, y + 90.0);
icons::paint(p, Rect::from_center_size(c, vec2(40.0, 40.0)), Icon::Folder, t.text_faint);
let (head, sub) = if entries.is_empty() {
("Your recent files will show here", "Create a new project, or open the demo project to look around.")
} else {
("No recent files match the filter", "Try another name or folder.")
};
p.text(c + vec2(0.0, 40.0), Align2::CENTER_CENTER, head, Tokens::semibold(14.0), t.text);
p.text(c + vec2(0.0, 62.0), Align2::CENTER_CENTER, sub, Tokens::ui(12.0), t.text_faint);
}
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
let row_h = 48.0;
for e in shown {
let r = Rect::from_min_size(pos2(x0, y), vec2(w, row_h - 2.0));
if r.min.y > area.max.y {
let row_h = 66.0;
for e in &entries {
let r = Rect::from_min_size(pos2(rx, ry), vec2(rw, row_h - 4.0));
if r.min.y > rect.max.y {
break;
}
let resp = ui.interact(r, egui::Id::new(("home-recent", e.index)), Sense::click()).on_hover_text(&e.path);
if resp.hovered() {
p.rect_filled(r, 6.0, t.hover);
}
let tr = Rect::from_min_size(pos2(r.min.x + 6.0, r.center().y - 18.0), vec2(64.0, 36.0));
match thumb_texture(app, ctx, &e.path) {
let tr = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 4.0), vec2(THUMB_W as f32, THUMB_H as f32));
match thumb_texture(app, &ctx, &e.path) {
Some(tex) => {
p.image(tex.id(), tr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
None => {
p.rect_filled(tr, 3.0, t.field_bg);
icons::paint(p, Rect::from_center_size(tr.center(), vec2(16.0, 16.0)), Icon::Comp, t.text_faint);
p.rect_filled(tr, 3.0, Color32::from_rgb(0x24, 0x26, 0x2e));
icons::paint(&p, Rect::from_center_size(tr.center(), vec2(20.0, 20.0)), Icon::Comp, t.text_faint);
}
}
p.rect_stroke(tr, 3.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
let name_clip = p.with_clip_rect(Rect::from_min_max(pos2(tr.max.x + 6.0, r.min.y), pos2(opened_x - 12.0, r.max.y)));
name_clip.text(
pos2(tr.max.x + 6.0, r.center().y - 8.0),
Align2::LEFT_CENTER,
&e.name,
Tokens::medium(13.0),
if e.exists { t.tab_text_active } else { t.text_dim },
);
let tx = tr.max.x + 12.0;
let text_clip = p.with_clip_rect(Rect::from_min_max(pos2(tx, r.min.y), pos2(r.max.x - date_w - 8.0, r.max.y)));
text_clip.text(pos2(tx, r.min.y + 20.0), Align2::LEFT_CENTER, &e.name, Tokens::semibold(13.0), if e.exists { Color32::WHITE } else { t.text_dim });
let sub = if e.exists { e.folder.clone() } else { format!("{} (missing)", e.folder) };
name_clip.text(pos2(tr.max.x + 6.0, r.center().y + 9.0), Align2::LEFT_CENTER, sub, Tokens::ui(11.0), t.text_faint);
let cell = |x: f32, x1: f32, text: String| {
p.with_clip_rect(Rect::from_min_max(pos2(x, r.min.y), pos2(x1 - 8.0, r.max.y))).text(
pos2(x, r.center().y),
Align2::LEFT_CENTER,
text,
Tokens::ui(12.0),
t.text_dim,
);
};
cell(opened_x, size_x, e.modified_secs.map(|m| format_ago(now, m)).unwrap_or_else(|| "—".into()));
cell(size_x, kind_x, e.size.map(format_size).unwrap_or_else(|| "—".into()));
cell(kind_x, r.max.x, kind_of(&e.path).to_string());
text_clip.text(pos2(tx, r.min.y + 40.0), Align2::LEFT_CENTER, sub, Tokens::ui(11.0), t.text_faint);
if let Some(d) = &e.modified {
p.text(pos2(r.max.x - date_w, r.min.y + 20.0), Align2::LEFT_CENTER, d, Tokens::ui(11.0), t.text_dim);
}
app.auto.add(&format!("home.recent.{}", e.index), r, &e.path);
if resp.clicked() {
actions.push(("file.openRecent", json!({"index": e.index})));
}
y += row_h;
ry += row_h;
}
run_actions(app, &ctx, actions);
}
fn run_actions(app: &mut EffectcraftApp, ctx: &egui::Context, actions: Vec<(&str, serde_json::Value)>) {
@ -520,20 +403,6 @@ mod tests {
assert_ne!(thumb_name("/a.ecproj"), thumb_name("/b.ecproj"));
}
#[test]
fn opened_and_size_columns() {
let now = 1_790_000_000;
assert_eq!(format_ago(now, now - 5), "Just now");
assert_eq!(format_ago(now, now - 60), "1 minute ago");
assert_eq!(format_ago(now, now - 3 * 3_600), "3 hours ago");
assert_eq!(format_ago(now, now - 90_000), "Yesterday");
assert_eq!(format_ago(now, now - 3 * 86_400), "3 days ago");
assert_eq!(format_ago(now, now - 30 * 86_400), "2026-08-22");
assert_eq!(format_ago(now, now + 100), "Just now");
assert_eq!((format_size(900), format_size(12_800), format_size(3_500_000)), ("900 B".into(), "13 KB".into(), "3.3 MB".into()));
assert_eq!((kind_of("/a/B.ecproj"), kind_of("c.AEP"), kind_of("x")), ("EffectCraft project", "After Effects project", "Project"));
}
#[test]
fn home_screen_registers_its_controls() {
let mut s = effectcraft_engine::Session::default();
@ -556,9 +425,6 @@ mod tests {
"home.help.discord",
"home.help.github",
"home.recent.0",
"home.filter",
"home.file.import",
"home.templates",
"home.tab.home",
"home.tab.templates",
"home.tab.learn",

View File

@ -82,7 +82,13 @@ pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String {
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) {
match p {
PanelKind::Composition => viewer::show(app, ui, rect),
PanelKind::Composition => {
if app.ui.start_screen {
home::show(app, ui, rect);
} else {
viewer::show(app, ui, rect)
}
}
PanelKind::Timeline => timeline::show(app, ui, rect),
PanelKind::Project => project::show(app, ui, rect),
PanelKind::EffectControls => effect_controls::show(app, ui, rect),

View File

@ -535,7 +535,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.auto.add(&format!("project.item.{}", id.0), r, &it.name);
let nresp = ui.interact(name_clip, egui::Id::new(("pname", id.0)), Sense::click_and_drag());
app.auto.add(&format!("project.item.{}.name", id.0), name_clip, &it.name);
let resp = resp.union(nresp);
let resp = resp.union(nresp.clone());
if resp.clicked() {
let add = ui.input(|i| i.modifiers.command || i.modifiers.shift);
if add {
@ -546,9 +546,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.session.state.project_selection = vec![*id];
}
}
// Double-click opens the item, as in After Effects (a comp gets its own Timeline tab and
// the viewer); Enter or the context menu renames.
if resp.double_clicked() {
if nresp.double_clicked() {
// Double-click the name: rename in place.
ctx.data_mut(|d| d.insert_temp::<Editing>(edit_id(), (id.0, "name".into(), it.name.clone())));
} else if resp.double_clicked() {
match &it.kind {
ItemKind::Comp(_) => actions.push(("comp.open".into(), json!({"comp": id.0}))),
ItemKind::Folder if !app.ui.project_open_folders.remove(&id.0) => {

View File

@ -515,18 +515,6 @@ fn reveal_targets(kind: &str) -> Option<(&'static str, &'static [&'static str])>
})
}
/// The properties the Timeline shows (revealed rows), as `{layer, prop}`: J / K and Select All
/// Keyframes act on these, as in After Effects.
pub(crate) fn visible_props(app: &EffectcraftApp, comp: &Comp) -> Vec<serde_json::Value> {
build_rows(app, comp)
.iter()
.filter_map(|r| match r.kind {
RowKind::Prop { uid } => Some(json!({"layer": r.layer.0, "prop": uid})),
_ => None,
})
.collect()
}
fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
let tl = &app.ui.timeline;
let search = tl.search.trim().to_lowercase();

View File

@ -363,9 +363,8 @@ pub struct TimelineState {
/// Properties / groups (uids) shown by the `props` reveal (Animation ▸ Reveal Properties…).
#[serde(default)]
pub reveal_props: BTreeSet<u64>,
/// Graph Editor: `auto` (Auto-Select Graph Type, the default: the speed graph when every
/// property shown is spatial, else the value graph), `value` or `speed`.
#[serde(default = "auto_graph")]
/// Graph Editor: `value` or `speed` graph.
#[serde(default = "value_graph")]
pub graph_mode: String,
/// Show only the selected properties (else every animated property of the selected layers).
#[serde(default = "yes")]
@ -402,8 +401,8 @@ pub fn default_tl_columns() -> BTreeSet<String> {
["av", "label", "num", "switches", "parent"].map(String::from).into_iter().collect()
}
fn auto_graph() -> String {
"auto".into()
fn value_graph() -> String {
"value".into()
}
fn yes() -> bool {
true
@ -424,7 +423,7 @@ impl Default for TimelineState {
open_groups: BTreeSet::new(),
reveal: vec![],
reveal_props: BTreeSet::new(),
graph_mode: auto_graph(),
graph_mode: value_graph(),
graph_show_selected: true,
graph_auto_zoom: true,
graph_range: None,

View File

@ -165,34 +165,3 @@ fn crosshair_and_eyedropper_pick_from_the_viewer() {
}
assert_eq!(c[3], 1.0);
}
/// Key Light's Screen Colour eyedropper picks from the effect's input: the shown frame is
/// already keyed (the default screen colour takes most of a green), so sampling it would miss.
#[test]
fn keyer_eyedropper_picks_the_screen_from_the_effect_input() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Key", "width": 320, "height": 180, "frameRate": 30, "duration": 1})).unwrap();
let screen = s.execute("layer.newSolid", json!({"name": "Screen", "color": "#30b050"})).unwrap()["layer"].as_u64().unwrap();
let fx = s.execute("effect.apply", json!({"layers": [screen], "effect": "Key Light"})).unwrap()["effects"][0].as_u64().unwrap();
let prop = layer(&s, screen).effects().unwrap().groups().find(|g| g.uid == fx).and_then(|g| g.get("screenColour")).map(|p| p.uid).unwrap();
s.state.selected_layers = vec![LayerId(screen)];
let mut app = EffectcraftApp::new(s);
app.show_panel(PanelKind::EffectControls);
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app);
settle(&mut h);
h.state_mut().ui.fx_pick = Some(FxPick { kind: "color".into(), layer: screen, prop, name: "Screen Colour".into() });
h.step();
let pos = effectcraft_ui_egui::panels::viewer::comp_to_screen(&h.ctx, [160.0, 90.0]).unwrap();
click(&mut h, pos);
assert!(h.state().ui.fx_pick.is_none(), "the pick is used up");
let v = layer(&h.state().session, screen).effects().unwrap().find(prop).unwrap().value.clone();
let KV::Color(c) = v else { panic!("{v:?}") };
let want = [0x30 as f64 / 255.0, 0xb0 as f64 / 255.0, 0x50 as f64 / 255.0];
for k in 0..3 {
assert!((c[k] - want[k]).abs() < 0.01, "{c:?} vs {want:?}");
}
// The picked screen keys the whole solid out.
let s = &h.state().session;
let img = s.render(s.active_comp_id().unwrap(), s.time(), Default::default());
assert!(img.get(160, 90)[3] < 0.01, "{:?}", img.get(160, 90));
}

View File

@ -264,9 +264,6 @@ fn composition_tabs_and_scrolling_preview() {
assert!(h.state().ui.locked_tabs.contains("Composition"));
click_id(&mut h, "panel.tab.Composition.lock");
assert!(h.state().ui.locked_tabs.is_empty());
// The Timeline's × closes its comp's tab (the panel stays, as in After Effects).
let shown = h.state().session.state.active_comp.unwrap();
click_id(&mut h, "panel.tab.Timeline.close");
assert!(!h.state().session.state.open_comps.contains(&shown));
assert!(h.state().ui.dock.contains(PanelKind::Timeline));
assert!(!h.state().ui.dock.contains(PanelKind::Timeline));
}

View File

@ -208,31 +208,3 @@ fn double_click_a_key_to_edit_its_value() {
let k = h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().keys[1].clone();
assert_eq!((k.time.seconds(), k.value.as_f64()), (1.0, 100.0));
}
#[test]
fn k_follows_the_revealed_properties_and_auto_select_picks_the_speed_graph_for_position() {
let (mut h, id, _) = harness();
// A Rotation key at 2 s on a property that isn't revealed: K skips it.
h.state_mut().session.execute("prop.addKey", json!({"layer": id.0, "path": "transform/rotation", "time": 2.0, "value": 45})).unwrap();
h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.0));
h.run_steps(2);
h.input_mut().events.push(Event::Key { key: egui::Key::K, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
h.run_steps(2);
assert!(h.state().session.time().seconds() > 3.9, "past the hidden key to the work area end: {}", h.state().session.time().seconds());
// Auto-Select Graph Type: Position shows its speed (one curve), Opacity its value.
let s = &mut h.state_mut().session;
for (t, v) in [(0.0, [40.0, 90.0]), (1.0, [280.0, 90.0])] {
s.execute("prop.addKey", json!({"layer": id.0, "path": "transform/position", "time": t, "value": v})).unwrap();
}
let pos = s.active_comp().unwrap().layer(id).unwrap().props.prop("transform/position").unwrap().uid;
s.execute("prop.select", json!({"layer": id.0, "prop": pos})).unwrap();
h.state_mut().ui.timeline.graph_editor = true;
h.run_steps(4);
assert_eq!(h.state().ui.timeline.graph_mode, "auto");
assert!(h.state().auto.find(&format!("timeline.graph.key.{pos}.0.0")).is_some());
assert!(h.state().auto.find(&format!("timeline.graph.key.{pos}.1.0")).is_none(), "speed graph: one curve");
assert!(h.state().auto.find("timeline.graph.autoSelectGraphType").is_some());
h.state_mut().ui.timeline.graph_mode = "value".into();
h.run_steps(3);
assert!(h.state().auto.find(&format!("timeline.graph.key.{pos}.1.0")).is_some(), "value graph: X and Y");
}

View File

@ -1,146 +0,0 @@
//! Comp tabs and tab drags (egui_kittest): double-clicking a comp in the Project panel opens it
//! as its own Timeline tab (not a rename, and not in place of the comp shown), the tabs switch and
//! close comps, and a dragged tab lands where its insertion mark shows.
use effectcraft_engine::Session;
use effectcraft_engine::project::ItemId;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::{DockNode, PanelKind};
use egui::{Event, Pos2, pos2};
use egui_kittest::Harness;
use serde_json::json;
/// "Pre" and "Main" (Main holds Pre); only Main is open.
fn harness() -> (Harness<'static, EffectcraftApp>, u64, u64) {
let mut s = Session::default();
let pre = s.execute("comp.new", json!({"name": "Pre", "width": 320, "height": 180, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
let main = s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
s.execute("layer.addItem", json!({"item": pre})).unwrap();
s.state.open_comps = vec![ItemId(main)];
s.state.active_comp = Some(ItemId(main));
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
(h, pre, main)
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect {
let e = h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).find(|e| e.id == id).cloned().unwrap_or_else(|| {
let have: Vec<&String> = h.state().auto.elements.iter().map(|e| &e.id).filter(|i| i.starts_with("panel.") || i.starts_with("project.")).collect();
panic!("no element {id}; have {have:?}")
});
egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))
}
fn click_n(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, n: u32) {
h.event(Event::PointerMoved(p));
h.step();
// All in one frame, straight into the input (the harness' event queue spreads presses over
// frames, longer apart than a double-click).
for _ in 0..n {
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
}
h.step();
h.run_steps(2);
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
h.event(Event::PointerMoved(from));
h.step();
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
for k in 1..=6 {
let f = k as f32 / 6.0;
h.event(Event::PointerMoved(from + (to - from) * f));
h.step();
}
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.step();
h.run_steps(2);
}
/// The tabs of the docked group holding `p`, in order.
fn group_of(n: &DockNode, p: PanelKind) -> Option<Vec<PanelKind>> {
match n {
DockNode::Split { a, b, .. } => group_of(a, p).or_else(|| group_of(b, p)),
DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| panels.clone()),
DockNode::Stack { entries } => entries.iter().any(|e| e.panel == p).then(|| entries.iter().map(|e| e.panel).collect()),
}
}
fn open_comps(h: &Harness<'_, EffectcraftApp>) -> Vec<u64> {
h.state().session.state.open_comps.iter().map(|c| c.0).collect()
}
#[test]
fn project_double_click_opens_a_comp_as_its_own_timeline_tab() {
let (mut h, pre, main) = harness();
// Double-click the precomp's name in the Project panel: it opens (no rename) next to Main.
let name = rect(&h, &format!("project.item.{pre}.name"));
click_n(&mut h, name.center(), 2);
assert_eq!(open_comps(&h), vec![main, pre]);
assert_eq!(h.state().session.state.active_comp, Some(ItemId(pre)));
assert_eq!(h.state().session.project.item(ItemId(pre)).map(|i| i.name.clone()), Some("Pre".into()));
// One Timeline tab per comp, the newest last; the shown one answers to the panel's id.
let (tm, tp) = (rect(&h, &format!("panel.tab.Timeline.{main}")), rect(&h, &format!("panel.tab.Timeline.{pre}")));
assert!(tp.min.x > tm.max.x, "{tm:?} {tp:?}");
assert_eq!(rect(&h, "panel.tab.Timeline"), tp);
// Clicking Main's tab shows Main again; both stay open.
click_n(&mut h, tm.center(), 1);
assert_eq!(h.state().session.state.active_comp, Some(ItemId(main)));
assert_eq!(open_comps(&h), vec![main, pre]);
// × closes the shown comp's Timeline only: the panel stays with Pre.
let close = rect(&h, "panel.tab.Timeline.close");
click_n(&mut h, close.center(), 1);
assert_eq!(open_comps(&h), vec![pre]);
assert_eq!(h.state().session.state.active_comp, Some(ItemId(pre)));
assert!(h.state().ui.dock.contains(PanelKind::Timeline));
// Double-clicking an open comp again adds no second tab.
let name = rect(&h, &format!("project.item.{pre}.name"));
click_n(&mut h, name.center(), 2);
assert_eq!(open_comps(&h), vec![pre]);
}
#[test]
fn dragged_tabs_land_where_the_insertion_mark_is() {
let (mut h, _, _) = harness();
let dock = |h: &Harness<'_, EffectcraftApp>| h.state().ui.dock.clone();
assert_eq!(group_of(&dock(&h), PanelKind::Timeline), Some(vec![PanelKind::Timeline, PanelKind::RenderQueue]));
// Reorder within the group: Render Queue dropped on the left half of the Timeline tab goes
// before it.
let tl = rect(&h, "panel.tab.Timeline");
let rq = rect(&h, "panel.tab.RenderQueue");
drag(&mut h, rq.center(), pos2(tl.min.x + tl.width() * 0.2, tl.center().y));
assert_eq!(group_of(&dock(&h), PanelKind::Timeline), Some(vec![PanelKind::RenderQueue, PanelKind::Timeline]));
// Another group's tab dropped between them lands between them.
let (rq, tl) = (rect(&h, "panel.tab.RenderQueue"), rect(&h, "panel.tab.Timeline"));
let fx = rect(&h, "panel.tab.EffectControls");
drag(&mut h, fx.center(), pos2((rq.max.x + tl.min.x) / 2.0 + 2.0, tl.center().y));
assert_eq!(group_of(&dock(&h), PanelKind::Timeline), Some(vec![PanelKind::RenderQueue, PanelKind::EffectControls, PanelKind::Timeline]));
// Past the last tab: last.
let (ec, tl) = (rect(&h, "panel.tab.EffectControls"), rect(&h, "panel.tab.Timeline"));
drag(&mut h, ec.center(), pos2(tl.max.x + 30.0, tl.center().y));
assert_eq!(group_of(&dock(&h), PanelKind::Timeline), Some(vec![PanelKind::RenderQueue, PanelKind::Timeline, PanelKind::EffectControls]));
}
/// `cargo test -p effectcraft-ui-egui --test ui_tabs -- --ignored`: the insertion mark mid-drag
/// (`target/test-out/tab-drag.png`).
#[test]
#[ignore]
fn tab_drag_snapshot() {
let (mut h, _, _) = harness();
let (tl, rq) = (rect(&h, "panel.tab.Timeline"), rect(&h, "panel.tab.RenderQueue"));
let fx = rect(&h, "panel.tab.EffectControls");
let to = pos2((tl.max.x + rq.min.x) / 2.0, tl.center().y);
h.event(Event::PointerMoved(fx.center()));
h.step();
h.event(Event::PointerButton { pos: fx.center(), button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
for k in 1..=6 {
h.event(Event::PointerMoved(fx.center() + (to - fx.center()) * (k as f32 / 6.0)));
h.step();
}
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out");
std::fs::create_dir_all(dir).unwrap();
h.render().expect("render").save(format!("{dir}/tab-drag.png")).unwrap();
}

View File

@ -94,7 +94,6 @@ Short recipes; every step is one tool call.
| Title card | `execute_command comp.new {...}` → `execute_command layer.newText {"text":"Hi","size":120}` → `render_frame {"time":0}` |
| Animate | `add_keyframe {"layer":2,"path":"transform/scale","keys":[{"time":0,"value":[0,0]},{"time":0.5,"value":[100,100]}],"interpolation":"easyEase"}` |
| Effect with settings | `list_effects {"filter":"glow"}` → `add_effect {"layer":2,"effect":"Glow","values":{"threshold":40,"radius":30}}` → `set_property {"layer":2,"path":"effects/#1/intensity","value":2}` |
| Key a green screen | `add_effect {"layer":2,"effect":"Keylight (1.2)"}` → `execute_command effect.pickColor {"layer":2,"effect":1,"param":"screenColour","x":20,"y":20,"average":true}` (a screen pixel, layer space; samples the effect's input) → `set_property {"layer":2,"path":"effects/#1/screenMatte/clipBlack","value":10}` → `render_frame {"transparent":true}` |
| Drive with an expression | `set_property {"layer":2,"path":"transform/rotation","expression":"time*90"}` |
| Inspect a layer | `get_layer {"layer":2,"flat":true}` (every property with its `path`, value, key count) |
| Check the result | `render_frame {"time":1.5,"max_side":640}`; in bridge mode `screenshot {"panel":"Timeline"}` |
@ -140,8 +139,6 @@ build real projects through these interfaces; they are worked examples of everyt
`keys.transform {…, merge, fromStart: true}` (values are the whole transform since the drag
started). Keys of locked layers can't be selected. Copy / paste: `keys.copy`, then `keys.paste`
at the current time (keys copied from several layers go to as many selected layers in order).
`time.nextKey` / `time.previousKey` / `keys.selectAll` take `visible: [{layer, prop}]` to act
only on those properties (the Timeline passes its revealed ones, as J / K and Ctrl+Alt+A do).
* **Easing with Keyframe Velocity**: select keys by path, `keys.select {"keys":[{"layer":"Ring","path":"contents/trim/end","time":0}]}`,
then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`.
* **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or

View File

@ -85,9 +85,7 @@ Points are logical window coordinates. A target is `{id}` (the element's centre;
| `ui.screenshot` | `{path?, panel?, id?}` | `{path, width, height}`: a PNG of the window, or cropped to one panel or element. |
Element ids are stable, for example `tools.Selection`, `panel.Timeline`, `panel.tab.EffectControls`,
`panel.tab.Timeline.<comp>` (one Timeline tab per open comp; `panel.tab.Timeline` is the shown
one), `header.workspace.Animation`, `project.item.<id>` (a double-click opens the item, Enter
renames it), `viewer.comp`, `viewer.handle.<layer>.<i>`,
`header.workspace.Animation`, `project.item.<id>`, `viewer.comp`, `viewer.handle.<layer>.<i>`,
`timeline.layer.<id>.bar`, `timeline.layer.<id>.twirl`, `timeline.layer.<id>.row` (click selects,
Enter or a double-click renames, drag reorders), `timeline.prop.<uid>.stopwatch`,
`timeline.key.<uid>.<frame>`, `timeline.cti`, `effectControls.prop.<uid>.value` and

View File

@ -189,7 +189,7 @@ Mocha and Cineware are third-party and not counted.
| Blur & Sharpen | Camera-Shake Deblur, CC Radial Blur | Deblur substitutes aligned patches from sharper neighbouring frames. |
| Audio | Compressor, Distortion, Gate | Applied in the mixdown like the other audio effects. |
| Simulation | CC Hair, Particle Playground | Particle Playground: cannon, grid, layer exploder, layer map, gravity, repel, wall, persistent property mapper (no Particle Exploder, text particles or ephemeral mapper yet). |
| Keying | Key Light | The full Keylight 1.2 control set under a generic name ("Keylight" is a vendor trademark); `lookup("Keylight (1.2)")` and the Effects & Presets search ("keylight") find it. Keyers' colour eyedroppers sample the effect's input (`effect.pickColor`), so a screen that is already keyed can still be picked. |
| Keying | Key Light | The full Keylight 1.2 control set under a generic name ("Keylight" is a vendor trademark); `lookup("Keylight (1.2)")` finds it. |
| Utility | Color Profile Converter | Our colour spaces and ACES; rendering intents (perceptual gamut compression, relative / absolute colorimetric, saturation). |
| Matte | Mocha shape | Mocha's export format is not public: reads a documented JSON shape format instead. |
@ -415,40 +415,8 @@ Browser in the web app (landed in M13.10: browser storage and File System Access
- **J / K and Info** (M5.14): J / K also stop at the work area; the Info panel shows a selected
key's property, time and value.
Since (M5.15): J / K and Select All Keyframes use what the Timeline shows — the revealed
properties' keys, layer and comp markers and the work area (`visible: [{layer, prop}]` on
`time.nextKey` / `previousKey` / `keys.selectAll`; without it, agents get every property of the
layers); the Graph Editor has Auto-Select Graph Type (the default: the speed graph when only
spatial properties are shown, else the value graph; choosing Value or Speed turns it off). Graph
Editor keys are small squares whatever their interpolation, as in After Effects.
Since (M13.30–M13.31): the Tools bar and About dialog carry the ArtCraft mark. The
Home screen is laid out like After Effects' and covers the whole workspace. A left rail holds New
Project / Open Project, the Home, Templates and Learn pages and, at its foot, the community
links. The Home page has a "Welcome to EffectCraft" heading, quick-start tiles (New Composition,
Open Demo Project, Import Footage, New from Template) and the recent projects as a filterable
table with thumbnails and Name / Opened ("3 hours ago") / Size / Kind columns. New automation ids:
`home.filter`, `home.templates`, `home.file.import`.
Since (M4.13): motion blur follows a collapsed precomp layer's own motion. Its nested layers are
drawn with the precomp layer's transform at every sub-sample, and with the containing comp's
shutter, as they are drawn into its frames. Shape and text layers whose content animates within
the shutter (paths, Trim Paths, shape transforms, text animators) are drawn at Samples Per Frame
times and averaged, as After Effects does for shape layers; content that holds still is drawn
once.
Since (M3.15): the Timeline shows one tab per open comp, as in After Effects. Double-clicking a
comp in the Project panel opens it as a new tab next to the comps already open; it no longer
renames (Enter or the context menu still do). A tab's × closes that comp's Timeline, not the
panel. A tab dragged over a tab strip shows an insertion mark between the tabs it will land
between, and lands there; this reorders a group's own tabs too.
Since (M5.16): Key Light (After Effects' Keylight 1.2) is checked end to end on a green-screen
plate with an uneven screen, spill and soft edges (`effect.apply "Keylight (1.2)"`, pick, Clip
Black / White, render). The colour eyedroppers of Keying effects sample the effect's input
rather than the keyed frame (Ctrl/Cmd+click averages 5 × 5 pixels); agents do the same with
`effect.pickColor {effect, param, x, y, average?}` (layer pixels). Searching "keylight" in
Effects & Presets, or `list_effects`, finds it.
Not yet: J / K and Select All Keyframes use every property of the layers, not only the revealed
ones; Graph Editor keys are drawn as squares whatever their interpolation; Auto-Select Graph Type.
## Update: M4.9–M4.11 nested comps and motion blur
@ -730,8 +698,8 @@ are pinned bit for bit by golden hashes (`crates/effects/tests/particle_golden.r
13. ~~Lottie, WebM, SVG and PSD import~~ (landed; a disk cache too).
14. ~~Puppet and paint tools~~ (second wave; puppet rigging and follow-through in M13.14–M13.16).
15. ~~Preferences and a shortcut editor that can rebind; real Wiggler, Smoother and Motion Sketch; the marker dialog.~~ (second and third waves)
16. ~~Motion blur of collapsed precomps and of animated content inside a layer (shape paths, text
animators).~~ (M4.13)
16. Motion blur of collapsed precomps (their own motion, the parent camera's) and of animated
content inside a layer (shape paths, text animators, nested frames).
17. Content-keyed RAM preview frames, so an edit keeps the frames of comps it doesn't touch.
18. View ▸ New Viewer (several unlocked Composition viewers), nested comp markers on the
precomp layer bar, audio scrubbing.