Compare commits
17 Commits
timeline-c
...
ci-packagi
| Author | SHA1 | Date |
|---|---|---|
|
|
a48de85015 | |
|
|
0bb8baf4da | |
|
|
3f7250043d | |
|
|
fc0462a855 | |
|
|
d31094692e | |
|
|
2039688245 | |
|
|
053c445465 | |
|
|
05188d036a | |
|
|
9e79271893 | |
|
|
58dd94e212 | |
|
|
1e8bcb519a | |
|
|
26f577cb1a | |
|
|
9b5652f686 | |
|
|
0a8cdfc67e | |
|
|
3411fc80ab | |
|
|
780419c235 | |
|
|
0ae7a1925a |
|
|
@ -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 != ''
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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, ×)?,
|
||||
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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue