Compare commits

..

17 Commits

Author SHA1 Message Date
bflatastic a48de85015 CI: packaging lint passes again (wasm-bindgen install step as an if, not A && B || C)
actionlint's shellcheck flagged SC2015 on release.yml's Install wasm-bindgen CLI step, failing
the Packaging lint workflow on main and release since 2 October.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:50:37 +07:00
Brandon Thomas 0bb8baf4da
Merge pull request #52 from storytold/pr/release-0.2.0
Release: EffectCraft v0.2.0
2026-10-05 02:46:06 -04:00
Brandon Thomas 3f7250043d Release: EffectCraft v0.2.0
Bump the workspace version 0.1.1 -> 0.2.0 (cargo xtask version set 0.2.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:45:53 -04:00
BFlat fc0462a855
Merge pull request #51 from storytold/home-screen-ae
M13.30–M13.31: ArtCraft logo in the Tools bar; Home screen laid out like After Effects'
2026-10-05 12:52:18 +07:00
bflatastic d31094692e M13.31: Home screen laid out like After Effects'
- Home covers the whole workspace below the Tools bar (the panels wait behind it) instead of
  the Composition panel.
- Left rail: New Project (primary) and Open Project, the Home / Templates / Learn pages with
  icons, and the community links and ArtCraft apps at its foot.
- Home page: "Welcome to EffectCraft", quick-start tiles (New Composition, Open Demo Project,
  Import Footage, New from Template) and Recent as a table — thumbnail, name and folder,
  Opened (relative), Size, Kind — with a filter field, Clear list and an empty state.
- Theme tokens throughout (light themes follow); web_time for "now" (works on the web).
- Tests: formatters (opened / size / kind) and the new automation ids. Docs: parity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:51:26 +07:00
bflatastic 2039688245 M13.30: the ArtCraft mark in the Tools bar, About and Home; VectorCraft sibling link
- paint_logo draws docs/brand/artcraft-mark.png (the README's / getartcraft.com's ArtCraft
  mark) as supplied, decoded once into a mipmapped texture, instead of a drawn placeholder.
- The "More ArtCraft apps" sibling and help.sibling named DrawCraft (storytold/drawcraft, which
  doesn't exist): it is VectorCraft (storytold/vectorcraft); provenance comments in the path
  crate follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:49:47 +07:00
BFlat 053c445465
Merge pull request #50 from storytold/m4-motion-blur-depth
M4.13: motion blur of collapsed precomps and of animated shape / text content
2026-10-05 12:36:07 +07:00
bflatastic 05188d036a M4.13: motion blur of collapsed precomps and of animated shape / text content
- Collapsed precomps: the renderer records the precomp layers a nested comp is drawn through;
  motion-blur sub-samples re-evaluate their transforms (when their motion blur is on) and map
  times down through each one, so the precomp layer's own motion blurs its nested layers. The
  containing (outermost) comp's shutter applies to collapsed layers. The GPU compositor gets
  this through Renderer::placement.
- Shape and text layers whose content changes within the shutter (compared by its cache key)
  draw their source at Samples Per Frame times and average them; the cache key covers every
  sub-sample's content.
- Tests: tests_motion_blur_depth (collapsed vs flat, the containing shutter, shape content vs
  the same move by Position); each fails without the change.
- Docs: roadmap, parity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:35:30 +07:00
BFlat 9e79271893
Merge pull request #10 from storybored379/pr/spatial-tangent-getter-names
Scripting: Property.keyInSpatialTangent / keyOutSpatialTangent under their AE names
2026-10-05 12:11:18 +07:00
BFlat 58dd94e212
Merge pull request #9 from storybored379/pr/hold-key-incoming-segment
Keyframes: a Hold key no longer eases the motion into it
2026-10-05 12:11:10 +07:00
BFlat 1e8bcb519a
Merge pull request #7 from storybored379/pr/comp-zero-frame-rate
Compositions: reject zero and negative frame rates instead of panicking
2026-10-05 12:11:02 +07:00
BFlat 26f577cb1a
Merge pull request #6 from storybored379/pr/expr-runtime-stack-leak
Expressions: keep the runtime usable after errors and property reads
2026-10-05 12:10:53 +07:00
BFlat 9b5652f686
Merge pull request #49 from storytold/timeline-comp-tabs
M3.15: Timeline tabs per open comp; Project double-click opens; tab drag insertion mark
2026-10-05 11:59:07 +07:00
storybored379 0a8cdfc67e Scripting: Property.keyInSpatialTangent / keyOutSpatialTangent under their AE names
The getters were registered as keySpatialInTangent / keySpatialOutTangent,
so scripts using the documented After Effects names failed with
"TypeError: not a callable function" (setSpatialTangentsAtKey and the
other keySpatial* methods already use AE's names).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:17:34 -07:00
storybored379 3411fc80ab Keyframes: a Hold key no longer eases the motion into it
segment_value treated any non-Linear in-side of the next key as Bezier,
so a key set to Hold (KeyframeInterpolationType.HOLD, add_keyframe
"interpolation": "hold", or the Keyframe Interpolation dialog, which set
both sides) turned the linear segment into it into an ease-in with the
default ease: 0 -> 100 over 2 s read 27.0 instead of 25 at 0.5 s. Hold
only affects what follows the key, which the out side already handles.
The Graph Editor and the Lottie exporter already treat a Hold in-side as
linear, so exports didn't match the preview either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:17:34 -07:00
storybored379 780419c235 Compositions: reject zero and negative frame rates instead of panicking
comp.new / comp.settings passed `frameRate` straight to
FrameRate::from_f64, so 0 (or anything below 0.0005) became a 0/1 rate
and the next frame-duration computation divided by zero, killing the
process (and an MCP session with its unsaved project); a negative rate
was accepted as-is. Scripts hit it via addComp(..., 0) or
comp.frameRate = 0. Return a parameter error like renderQueue.add's
frameRate check does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 09:31:41 -07:00
storybored379 0ae7a1925a Expressions: keep the runtime usable after errors and property reads
boa leaves the value stack of inner call frames behind when an exception
leaves a script (or a function called from Rust) from a nested call. Every
property read throws the pending-host-request abort from inside a prelude
helper, and so does any helper error (`thisComp.layer("Ghost")`), so the
thread's runtime filled up after ~100 evaluations of a pick-whip
expression: from then on every expression on that thread failed with
"RuntimeLimitError: reached the maximum stack size" and silently fell back
to its keyframed value (e.g. a follower layer stops following after ~90
render_frame calls).

Wrap the script and `__finish` in try/catch and rethrow from their own
frame, which unwinds the stack; backtraces (error line numbers) survive.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 09:31:41 -07:00
25 changed files with 743 additions and 217 deletions

View File

@ -221,8 +221,9 @@ jobs:
- name: Install wasm-bindgen CLI
run: |
# Must match the wasm-bindgen version in Cargo.lock exactly (cargo xtask web checks it).
command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION" \
|| cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
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
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.1.1"
version = "0.2.0"
dependencies = [
"cpal",
"effectcraft-engine",
@ -1155,7 +1155,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@ -1167,14 +1167,14 @@ dependencies = [
[[package]]
name = "effectcraft-av1enc"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"cpu-time",
"effectcraft-automation",
@ -1190,7 +1190,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"proptest",
"serde",
@ -1198,7 +1198,7 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1214,7 +1214,7 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1246,7 +1246,7 @@ dependencies = [
[[package]]
name = "effectcraft-export"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
@ -1275,7 +1275,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"boa_engine",
"effectcraft-color",
@ -1292,7 +1292,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"proptest",
"serde",
@ -1300,7 +1300,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"bytemuck",
"effectcraft-color",
@ -1319,14 +1319,14 @@ dependencies = [
[[package]]
name = "effectcraft-hevcenc"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@ -1348,7 +1348,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@ -1367,7 +1367,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1379,7 +1379,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1396,7 +1396,7 @@ dependencies = [
[[package]]
name = "effectcraft-media"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-model",
"effectcraft-pdf",
@ -1421,7 +1421,7 @@ dependencies = [
[[package]]
name = "effectcraft-model"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-geom",
"image",
@ -1432,14 +1432,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@ -1451,7 +1451,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-raster",
"effectcraft-svg",
@ -1465,7 +1465,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@ -1477,7 +1477,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1490,14 +1490,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "effectcraft-raster"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1506,7 +1506,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1528,7 +1528,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"boa_engine",
"effectcraft-engine",
@ -1543,7 +1543,7 @@ dependencies = [
[[package]]
name = "effectcraft-svg"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@ -1555,7 +1555,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@ -1567,7 +1567,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"proptest",
"serde",
@ -1575,7 +1575,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-raster",
"rayon",
@ -1586,7 +1586,7 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-engine",
@ -1614,11 +1614,11 @@ dependencies = [
[[package]]
name = "effectcraft-vp9enc"
version = "0.1.1"
version = "0.2.0"
[[package]]
name = "effectcraft-web"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@ -6318,7 +6318,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"serde_json",
]

View File

@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
version = "0.1.1"
version = "0.2.0"
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; left: motion blur of collapsed precomps and of animated layer content) |
| 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) |
| 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,7 +33,6 @@ 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, 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)).
Brush and face tracking; in previews, 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,6 +12,14 @@ 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) {
@ -99,6 +107,7 @@ 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)));
@ -125,6 +134,7 @@ 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|drawcraft|filmcraft|lightcraft|printcraft|designcraft, kind?: page|github}",
"{app: photocraft|vectorcraft|filmcraft|lightcraft|printcraft|designcraft, kind?: page|github}",
always,
sibling
),

View File

@ -970,6 +970,8 @@ 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"),
("DrawCraft", "drawcraft"),
("VectorCraft", "vectorcraft"),
("FilmCraft", "filmcraft"),
("LightCraft", "lightcraft"),
("PrintCraft", "printcraft"),

View File

@ -301,6 +301,17 @@ 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

@ -0,0 +1,122 @@
//! 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,6 +34,15 @@ 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,8 +238,11 @@ 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.
let src = format!("{{{}\n}}", crate::rewrite::rewrite(text));
// 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));
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,6 +388,22 @@ 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,7 +441,9 @@ 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;
let lin_in = b.in_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 is_spatial = spatial && matches!(a.value, Value::Vec2(_) | Value::Vec3(_));
if is_spatial {
let p0 = v3(&a.value);
@ -584,6 +586,17 @@ 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 DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/boolean.rs`; our own code,
//! Adapted from VectorCraft'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 DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/fit.rs`; our own code,
//! Adapted from VectorCraft'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 DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/offset.rs`; our own code,
//! Adapted from VectorCraft'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,6 +444,49 @@ 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`).
@ -490,6 +533,8 @@ impl<'a> Renderer<'a> {
inherited: None,
defer_dof: false,
top: true,
through: [None; MAX_THROUGH],
through_lost: false,
}
}
@ -524,7 +569,17 @@ 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, ..*self }
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
}
}
/// The nested comp of a precomp layer whose transformations collapse into this comp: the
@ -558,7 +613,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: &Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> {
pub fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &'a 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) {
@ -573,7 +628,15 @@ impl<'a> Renderer<'a> {
cam: three_d::compose::camera_for(self, ctx),
parent: self.collapse3d.map_or(*ctx, |c| c.parent),
};
Some((Renderer { depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), ..*self }, nctx))
// 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))
}
pub(crate) fn opacity_mul(&self) -> f32 {
@ -1080,7 +1143,10 @@ 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 = self.source(ctx, layer)?;
let mut buf = match self.content_times(ctx, layer) {
Some(times) => self.blurred_source(ctx, layer, &times)?,
None => 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 {
@ -1151,6 +1217,7 @@ 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)
{
@ -1172,6 +1239,7 @@ 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)
{
@ -1273,9 +1341,13 @@ 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 s = self.opts.scale;
let (l2c, _) = ctx.layer_to_comp(layer);
let l2c = self.outer.map_or(l2c, |o| o * l2c);
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;
self.out_matrix()
* Mat3::scale(vec2(s, s))
* l2c
@ -1283,6 +1355,112 @@ 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.
@ -1304,7 +1482,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.mb_on(ctx, layer) {
if !self.blurred(ctx, layer) {
return 1;
}
let travel = self.shutter_travel(ctx, layer, buf);
@ -1314,22 +1492,21 @@ impl<'a> Renderer<'a> {
if self.opts.draft {
return 4;
}
adaptive_samples(travel, ctx.comp.motion_blur_samples, ctx.comp.motion_blur_adaptive_limit)
let sh = self.shutter(ctx);
adaptive_samples(travel, sh.samples, sh.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 fd = ctx.comp.frame_duration().seconds();
let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0);
let sh = self.shutter(ctx);
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 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 m = self.buf_matrix_at(ctx, layer, buf, sh.offset(i, PROBES));
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() {
@ -1388,15 +1565,8 @@ impl<'a> Renderer<'a> {
if samples <= 1 {
return Placement { matrices: vec![self.buf_matrix(ctx, layer, buf)], sampling, seed };
}
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();
let sh = self.shutter(ctx);
let matrices = (0..samples).map(|i| self.buf_matrix_at(ctx, layer, buf, sh.offset(i, samples))).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.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.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.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,6 +241,22 @@ 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

@ -160,6 +160,13 @@ 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 {

View File

@ -293,21 +293,25 @@ pub fn color_popup(ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f3
changed
}
/// The EffectCraft mark: a rounded square with a gradient-like split and a keyframe diamond.
/// 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.
pub fn paint_logo(p: &egui::Painter, r: Rect) {
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));
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);
}
}

View File

@ -1,8 +1,10 @@
//! 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.
//! 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`).
//!
//! 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.
@ -29,6 +31,10 @@ 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,
}
@ -41,17 +47,19 @@ 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 = meta
let modified_secs = meta
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| format_utc(d.as_secs() as i64));
.map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
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: modified_secs.map(format_utc),
modified_secs,
size: meta.as_ref().filter(|m| m.is_file()).map(|m| m.len()),
exists: meta.is_some(),
}
})
@ -75,6 +83,45 @@ 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();
@ -157,191 +204,261 @@ fn thumb_texture(app: &mut EffectcraftApp, ctx: &egui::Context, path: &str) -> O
tex
}
/// Draw the Home screen.
/// Width of the Home screen's left rail.
const RAIL_W: f32 = 236.0;
/// Draw the Home screen over `rect` (the whole workspace).
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, 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 };
p.rect_filled(rect, 0.0, t.app_bg);
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);
}
// ---- 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 {
/// 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 {
2
} else if app.ui.home_templates {
1
} else {
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));
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));
let resp = ui.interact(r, egui::Id::new(("home-tab", label)), Sense::click());
let on = tab == k;
let on = page == k;
if on || resp.hovered() {
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));
p.rect_filled(r, 6.0, if on { t.row_selected } else { t.hover });
}
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;
}
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;
// Community links at the foot (below the pages when the window is short).
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(cx, cy), vec2(cw.min(250.0), 30.0));
let r = Rect::from_min_size(pos2(x0, cy), vec2(w, 26.0));
let resp = ui.interact(r, egui::Id::new(("home-link", cmd)), Sense::click());
let discord = cmd == "help.discord";
let bg = if discord {
Color32::from_rgb(0x58, 0x65, 0xf2)
if discord {
p.rect_filled(r, 13.0, Color32::from_rgb(0x58, 0x65, 0xf2));
} else if resp.hovered() {
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);
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);
app.auto.add(&format!("home.{cmd}"), r, label);
if resp.clicked() {
let _ = app.session.execute(cmd, json!({}));
}
cy += 36.0;
cy += 30.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;
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;
for (i, (name, slug)) in sib.iter().enumerate() {
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 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 resp = ui.interact(r, egui::Id::new(("sib", *slug)), Sense::click());
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);
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);
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;
}
// ---- 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;
/// 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!({})));
}
}
}
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);
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);
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(rx + rw - 100.0, ry - 4.0), vec2(100.0, 20.0));
let cr = Rect::from_min_size(pos2(fr.min.x - 96.0, y + 3.0), vec2(84.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.0), if resp.hovered() { t.text } else { t.text_faint });
p.text(cr.right_center(), Align2::RIGHT_CENTER, "Clear list", Tokens::ui(11.5), 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!({})));
}
}
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 += 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);
}
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 {
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 {
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.min.y + 4.0), vec2(THUMB_W as f32, THUMB_H as f32));
match thumb_texture(app, &ctx, &e.path) {
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) {
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, 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_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_stroke(tr, 3.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
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 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 sub = if e.exists { e.folder.clone() } else { format!("{} (missing)", e.folder) };
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);
}
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());
app.auto.add(&format!("home.recent.{}", e.index), r, &e.path);
if resp.clicked() {
actions.push(("file.openRecent", json!({"index": e.index})));
}
ry += row_h;
y += row_h;
}
run_actions(app, &ctx, actions);
}
fn run_actions(app: &mut EffectcraftApp, ctx: &egui::Context, actions: Vec<(&str, serde_json::Value)>) {
@ -403,6 +520,20 @@ 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();
@ -425,6 +556,9 @@ 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,13 +82,7 @@ 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 => {
if app.ui.start_screen {
home::show(app, ui, rect);
} else {
viewer::show(app, ui, rect)
}
}
PanelKind::Composition => 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

@ -422,6 +422,21 @@ 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
@ -715,8 +730,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 (their own motion, the parent camera's) and of animated
content inside a layer (shape paths, text animators, nested frames).
16. ~~Motion blur of collapsed precomps and of animated content inside a layer (shape paths, text
animators).~~ (M4.13)
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.