Compare commits

..

No commits in common. "ci-packaging-lint" and "graph-auto-visible-keys" have entirely different histories.

31 changed files with 298 additions and 1226 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

@ -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

@ -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

@ -970,8 +970,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,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

@ -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

@ -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

@ -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

@ -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

@ -422,27 +422,6 @@ layers); the Graph Editor has Auto-Select Graph Type (the default: the speed gra
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
@ -730,8 +709,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.