Compare commits
19 Commits
graph-auto
...
ci-packagi
| Author | SHA1 | Date |
|---|---|---|
|
|
a48de85015 | |
|
|
0bb8baf4da | |
|
|
3f7250043d | |
|
|
fc0462a855 | |
|
|
d31094692e | |
|
|
2039688245 | |
|
|
053c445465 | |
|
|
05188d036a | |
|
|
9e79271893 | |
|
|
58dd94e212 | |
|
|
1e8bcb519a | |
|
|
26f577cb1a | |
|
|
9b5652f686 | |
|
|
c7c90cfa74 | |
|
|
a008a904af | |
|
|
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();
|
||||
|
|
|
|||
|
|
@ -607,6 +607,39 @@ impl DockNode {
|
|||
}
|
||||
}
|
||||
|
||||
/// Move panel `p` into the group that holds `anchor` as a tab before `before` (one of that
|
||||
/// group's panels) or last, and show it. False (tree unchanged) if `anchor` isn't docked.
|
||||
pub fn insert_tab(&mut self, p: PanelKind, anchor: PanelKind, before: Option<PanelKind>) -> bool {
|
||||
if p == anchor || !self.contains(anchor) {
|
||||
return false;
|
||||
}
|
||||
let saved = self.clone();
|
||||
self.close(p);
|
||||
fn rec(n: &mut DockNode, p: PanelKind, anchor: PanelKind, before: Option<PanelKind>) -> bool {
|
||||
match n {
|
||||
DockNode::Split { a, b, .. } => rec(a, p, anchor, before) || rec(b, p, anchor, before),
|
||||
DockNode::Tabs { panels, active } if panels.contains(&anchor) => {
|
||||
let i = before.and_then(|b| panels.iter().position(|x| *x == b)).unwrap_or(panels.len());
|
||||
panels.insert(i, p);
|
||||
*active = i;
|
||||
true
|
||||
}
|
||||
DockNode::Stack { entries } if entries.iter().any(|e| e.panel == anchor) => {
|
||||
let i = before.and_then(|b| entries.iter().position(|e| e.panel == b)).unwrap_or(entries.len());
|
||||
entries.insert(i, StackEntry { panel: p, open: true, height: None });
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
if rec(self, p, anchor, before) {
|
||||
true
|
||||
} else {
|
||||
*self = saved;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Path of the group containing `p` (as in [`Group::path`]; stacks add `sN`).
|
||||
pub fn path_of(&self, p: PanelKind) -> Option<String> {
|
||||
fn rec(n: &DockNode, p: PanelKind, path: &str) -> Option<String> {
|
||||
|
|
@ -664,6 +697,36 @@ impl Layout {
|
|||
found
|
||||
}
|
||||
|
||||
/// Move `p` (docked or floating) into the group of `anchor` (docked or floating) as a tab
|
||||
/// before `before` or last: where a tab dragged onto a tab strip lands.
|
||||
pub fn insert_tab(&mut self, p: PanelKind, anchor: PanelKind, before: Option<PanelKind>) -> bool {
|
||||
if p == anchor || before == Some(p) {
|
||||
return false;
|
||||
}
|
||||
if self.floating.iter().any(|f| f.panels.contains(&anchor)) {
|
||||
if self.root.contains(p) {
|
||||
if self.root.panel_count() <= 1 {
|
||||
return false;
|
||||
}
|
||||
self.root.close(p);
|
||||
} else {
|
||||
self.unfloat(p);
|
||||
}
|
||||
// (Removing `p` may have dropped an emptied group: look the anchor up again.)
|
||||
let Some(f) = self.floating.iter_mut().find(|f| f.panels.contains(&anchor)) else { return false };
|
||||
let i = before.and_then(|b| f.panels.iter().position(|x| *x == b)).unwrap_or(f.panels.len());
|
||||
f.panels.insert(i, p);
|
||||
f.active = i;
|
||||
return true;
|
||||
}
|
||||
if !self.root.contains(anchor) {
|
||||
return false;
|
||||
}
|
||||
// A floating panel isn't in the tree, so insert_tab's removal is a no-op for it.
|
||||
self.unfloat(p);
|
||||
self.root.insert_tab(p, anchor, before)
|
||||
}
|
||||
|
||||
/// Dock `p` (docked or floating) next to `anchor`; `anchor` may be floating too (then Center
|
||||
/// adds a tab to that floating group).
|
||||
pub fn dock(&mut self, p: PanelKind, anchor: PanelKind, zone: Zone) -> bool {
|
||||
|
|
@ -724,6 +787,37 @@ pub enum DockAction {
|
|||
BeginDrag(PanelKind),
|
||||
/// The tab's lock was toggled (Composition / Timeline viewer lock).
|
||||
ToggleLock(PanelKind),
|
||||
/// A document tab of a panel was clicked (the Timeline's tab of comp `id`).
|
||||
ActivateDoc(PanelKind, u64),
|
||||
/// A document tab's close button (or middle click): close that document (comp `id`).
|
||||
CloseDoc(PanelKind, u64),
|
||||
}
|
||||
|
||||
/// One document a panel shows as a tab of its own (the Timeline: one tab per open comp, as in
|
||||
/// After Effects).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DocTab {
|
||||
pub id: u64,
|
||||
pub title: String,
|
||||
pub deco: Option<TabDeco>,
|
||||
/// The document the panel shows now.
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
/// What a group's tabs show: labels, the comp swatch / close / lock decorations and the panels
|
||||
/// that show one tab per document.
|
||||
pub struct TabInfo<'a> {
|
||||
pub title: &'a dyn Fn(PanelKind) -> String,
|
||||
pub decos: &'a [(PanelKind, TabDeco)],
|
||||
pub docs: &'a [(PanelKind, Vec<DocTab>)],
|
||||
}
|
||||
|
||||
/// What drawing a group's chrome produced: actions and where each panel's tabs are (a panel with
|
||||
/// document tabs spans all of them; tabs hidden behind the overflow chevron are left out).
|
||||
#[derive(Default)]
|
||||
pub struct Chrome {
|
||||
pub actions: Vec<DockAction>,
|
||||
pub tabs: Vec<(PanelKind, Rect)>,
|
||||
}
|
||||
|
||||
/// After Effects-style extras on a tab that shows a composition (Composition, Timeline): a close
|
||||
|
|
@ -810,18 +904,21 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa
|
|||
}
|
||||
}
|
||||
|
||||
/// Draw a group's frame + tab strip. Returns actions (tab clicks, panel menu, focus).
|
||||
/// `title` gives a tab's label, which may name the comp or layer it shows (After Effects style).
|
||||
pub fn draw_group_chrome(
|
||||
ui: &mut egui::Ui,
|
||||
g: &Group,
|
||||
focused: PanelKind,
|
||||
t: &Tokens,
|
||||
reg: &mut crate::automation::Registry,
|
||||
title: &dyn Fn(PanelKind) -> String,
|
||||
decos: &[(PanelKind, TabDeco)],
|
||||
) -> Vec<DockAction> {
|
||||
let mut actions = Vec::new();
|
||||
/// Draw a group's frame + tab strip: one tab per panel, or per document for panels that show
|
||||
/// several (the Timeline's comps). Tab labels may name the comp or layer they show (After
|
||||
/// Effects style).
|
||||
pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &Tokens, reg: &mut crate::automation::Registry, info: &TabInfo) -> Chrome {
|
||||
struct Entry {
|
||||
panel: PanelKind,
|
||||
doc: Option<u64>,
|
||||
label: String,
|
||||
deco: Option<TabDeco>,
|
||||
/// The group's shown tab.
|
||||
active: bool,
|
||||
/// The document its panel shows (even behind another tab).
|
||||
shown_doc: bool,
|
||||
}
|
||||
let mut out = Chrome::default();
|
||||
let painter = ui.painter().clone();
|
||||
painter.rect_filled(g.rect, t.radius, t.panel_bg);
|
||||
let active_panel = g.panels.get(g.active).copied();
|
||||
|
|
@ -833,57 +930,102 @@ pub fn draw_group_chrome(
|
|||
painter.circle_filled(c + vec2(dx, 0.0), 1.0, t.text_faint);
|
||||
}
|
||||
} else {
|
||||
let mut entries: Vec<Entry> = vec![];
|
||||
for (pi, p) in g.panels.iter().enumerate() {
|
||||
let shown = pi == g.active;
|
||||
match info.docs.iter().find(|(k, d)| k == p && !d.is_empty()) {
|
||||
Some((_, docs)) => entries.extend(docs.iter().map(|d| Entry {
|
||||
panel: *p,
|
||||
doc: Some(d.id),
|
||||
label: d.title.clone(),
|
||||
deco: d.deco,
|
||||
active: shown && d.active,
|
||||
shown_doc: d.active,
|
||||
})),
|
||||
None => entries.push(Entry {
|
||||
panel: *p,
|
||||
doc: None,
|
||||
label: (info.title)(*p),
|
||||
deco: info.decos.iter().find(|(k, _)| k == p).map(|(_, d)| *d),
|
||||
active: shown,
|
||||
shown_doc: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
let ai = entries.iter().position(|e| e.active).unwrap_or(0);
|
||||
let strip = Rect::from_min_size(g.rect.min, vec2(g.rect.width(), t.tab_h));
|
||||
if t.gradients {
|
||||
// Settings ▸ Appearance ▸ Use Gradients.
|
||||
crate::theme::gradient_rect(&painter, strip.shrink2(vec2(t.radius, 0.0)), t.grad_top(t.panel_bg), t.panel_bg);
|
||||
}
|
||||
let activate = |e: &Entry| match e.doc {
|
||||
Some(id) => DockAction::ActivateDoc(e.panel, id),
|
||||
None => DockAction::Activate(e.panel),
|
||||
};
|
||||
let close = |e: &Entry| match e.doc {
|
||||
Some(id) => DockAction::CloseDoc(e.panel, id),
|
||||
None => DockAction::Close(e.panel),
|
||||
};
|
||||
let mut x = strip.min.x + 12.0;
|
||||
let text_y = strip.min.y + 16.0;
|
||||
for (i, p) in g.panels.iter().enumerate() {
|
||||
for (k, e) in entries.iter().enumerate() {
|
||||
let p = &e.panel;
|
||||
// A collapsed stacked panel's header is plain text (no underline or panel menu).
|
||||
let is_active = i == g.active && g.stacked != Some(false);
|
||||
let label = title(*p);
|
||||
let galley = painter.layout_no_wrap(label.clone(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text });
|
||||
let is_active = k == ai && g.stacked != Some(false);
|
||||
let galley = painter.layout_no_wrap(e.label.clone(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text });
|
||||
let menu_w = if is_active { 20.0 } else { 0.0 };
|
||||
let deco = decos.iter().find(|(k, _)| k == p).map(|(_, d)| *d);
|
||||
// The lock belongs to the panel: on its shown document's tab only.
|
||||
let lock = e.deco.is_some_and(|d| d.viewer) && (e.doc.is_none() || is_active);
|
||||
// × (active tab only), swatch and lock before the label.
|
||||
let deco_w = match deco {
|
||||
Some(d) if d.viewer => (if is_active { 16.0 } else { 0.0 }) + 14.0 + 16.0,
|
||||
let deco_w = match e.deco {
|
||||
Some(d) if d.viewer => (if is_active { 16.0 } else { 0.0 }) + 14.0 + if lock { 16.0 } else { 0.0 },
|
||||
Some(_) => 14.0,
|
||||
None => 0.0,
|
||||
};
|
||||
let w = galley.size().x + 16.0 + menu_w + deco_w;
|
||||
if x + w > strip.max.x - 20.0 && i > g.active {
|
||||
if x + w > strip.max.x - 20.0 && k > ai {
|
||||
let r = Rect::from_min_size(pos2(strip.max.x - 22.0, strip.min.y + 6.0), vec2(18.0, 20.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click());
|
||||
icons::paint(&painter, r.shrink(4.0).translate(vec2(-2.0, 0.0)), Icon::ChevronRight, t.tab_text);
|
||||
icons::paint(&painter, r.shrink(4.0).translate(vec2(2.0, 0.0)), Icon::ChevronRight, t.tab_text);
|
||||
if resp.clicked() {
|
||||
let next = g.panels[(g.active + 1) % g.panels.len()];
|
||||
actions.push(DockAction::Activate(next));
|
||||
if resp.clicked()
|
||||
&& let Some(next) = entries.get((ai + 1) % entries.len())
|
||||
{
|
||||
out.actions.push(activate(next));
|
||||
}
|
||||
break;
|
||||
}
|
||||
let tab = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, t.tab_h));
|
||||
let resp = ui.interact(tab, egui::Id::new(("tab", g.path.clone(), i)), Sense::click_and_drag());
|
||||
if resp.drag_started() {
|
||||
actions.push(DockAction::BeginDrag(*p));
|
||||
match out.tabs.iter_mut().find(|(q, _)| q == p) {
|
||||
Some((_, r)) => *r = r.union(tab),
|
||||
None => out.tabs.push((*p, tab)),
|
||||
}
|
||||
let resp = ui.interact(tab, egui::Id::new(("tab", g.path.clone(), k)), Sense::click_and_drag());
|
||||
if resp.drag_started() {
|
||||
out.actions.push(DockAction::BeginDrag(*p));
|
||||
}
|
||||
// A document tab also answers to the panel's id while it is the shown one.
|
||||
let auto_id = match e.doc {
|
||||
Some(id) => format!("panel.tab.{}.{id}", p.id()),
|
||||
None => format!("panel.tab.{}", p.id()),
|
||||
};
|
||||
reg.add(&auto_id, tab, &e.label);
|
||||
if e.shown_doc {
|
||||
reg.add(&format!("panel.tab.{}", p.id()), tab, &e.label);
|
||||
}
|
||||
reg.add(&format!("panel.tab.{}", p.id()), tab, &label);
|
||||
let mut label_x = tab.min.x + 8.0;
|
||||
if let Some(d) = deco {
|
||||
if let Some(d) = e.deco {
|
||||
let mut dx = tab.min.x + 6.0;
|
||||
if is_active && d.viewer {
|
||||
let cr = Rect::from_center_size(pos2(dx + 5.0, text_y), vec2(10.0, 10.0));
|
||||
let cresp = ui.interact(cr.expand(2.0), egui::Id::new(("tab-close", g.path.clone(), i)), Sense::click());
|
||||
let cresp = ui.interact(cr.expand(2.0), egui::Id::new(("tab-close", g.path.clone(), k)), Sense::click());
|
||||
let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text };
|
||||
let k = 3.5;
|
||||
painter.line_segment([cr.center() + vec2(-k, -k), cr.center() + vec2(k, k)], Stroke::new(1.2, cc));
|
||||
painter.line_segment([cr.center() + vec2(-k, k), cr.center() + vec2(k, -k)], Stroke::new(1.2, cc));
|
||||
let kk = 3.5;
|
||||
painter.line_segment([cr.center() + vec2(-kk, -kk), cr.center() + vec2(kk, kk)], Stroke::new(1.2, cc));
|
||||
painter.line_segment([cr.center() + vec2(-kk, kk), cr.center() + vec2(kk, -kk)], Stroke::new(1.2, cc));
|
||||
reg.add(&format!("panel.tab.{}.close", p.id()), cr, "Close");
|
||||
if cresp.clicked() {
|
||||
actions.push(DockAction::Close(*p));
|
||||
out.actions.push(close(e));
|
||||
}
|
||||
dx += 16.0;
|
||||
}
|
||||
|
|
@ -891,9 +1033,9 @@ pub fn draw_group_chrome(
|
|||
painter.rect_filled(sw, 1.0, d.swatch);
|
||||
dx += 14.0;
|
||||
label_x = dx;
|
||||
if d.viewer {
|
||||
if lock {
|
||||
let lr = Rect::from_center_size(pos2(dx + 6.0, text_y), vec2(12.0, 12.0));
|
||||
let lresp = ui.interact(lr.expand(2.0), egui::Id::new(("tab-lock", g.path.clone(), i)), Sense::click());
|
||||
let lresp = ui.interact(lr.expand(2.0), egui::Id::new(("tab-lock", g.path.clone(), k)), Sense::click());
|
||||
let lc = if d.locked {
|
||||
t.tab_text_active
|
||||
} else if lresp.hovered() {
|
||||
|
|
@ -904,7 +1046,7 @@ pub fn draw_group_chrome(
|
|||
icons::paint(&painter, lr, Icon::Lock, lc);
|
||||
reg.add(&format!("panel.tab.{}.lock", p.id()), lr, if d.locked { "Unlock" } else { "Lock" });
|
||||
if lresp.clicked() {
|
||||
actions.push(DockAction::ToggleLock(*p));
|
||||
out.actions.push(DockAction::ToggleLock(*p));
|
||||
}
|
||||
label_x = dx + 16.0;
|
||||
}
|
||||
|
|
@ -923,19 +1065,19 @@ pub fn draw_group_chrome(
|
|||
let uy = strip.min.y + 23.0;
|
||||
painter.line_segment([pos2(label_x, uy), pos2(mr.max.x, uy)], Stroke::new(1.0, t.tab_text_active));
|
||||
if mresp.clicked() {
|
||||
actions.push(DockAction::PanelMenu(*p, mr.left_bottom()));
|
||||
out.actions.push(DockAction::PanelMenu(*p, mr.left_bottom()));
|
||||
}
|
||||
}
|
||||
if resp.clicked() {
|
||||
if g.stacked.is_some() {
|
||||
actions.push(DockAction::ToggleStacked(*p));
|
||||
out.actions.push(DockAction::ToggleStacked(*p));
|
||||
} else {
|
||||
actions.push(DockAction::Activate(*p));
|
||||
out.actions.push(activate(e));
|
||||
}
|
||||
actions.push(DockAction::Focus(*p));
|
||||
out.actions.push(DockAction::Focus(*p));
|
||||
}
|
||||
if resp.middle_clicked() {
|
||||
actions.push(DockAction::Close(*p));
|
||||
out.actions.push(close(e));
|
||||
}
|
||||
x += w + 8.0;
|
||||
}
|
||||
|
|
@ -949,9 +1091,9 @@ pub fn draw_group_chrome(
|
|||
&& ui.rect_contains_pointer(g.rect)
|
||||
&& ui.input(|i| i.pointer.any_pressed())
|
||||
{
|
||||
actions.push(DockAction::Focus(p));
|
||||
out.actions.push(DockAction::Focus(p));
|
||||
}
|
||||
actions
|
||||
out
|
||||
}
|
||||
|
||||
/// Placeholder body for panels that are not implemented yet.
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ fn drag_id() -> egui::Id {
|
|||
|
||||
impl EffectcraftApp {
|
||||
/// Tab labels that name what the panel shows, as After Effects does: "Composition Intro",
|
||||
/// "Effect Controls Title", "Properties: Title", and the Timeline tab named after its comp.
|
||||
/// "Effect Controls Title", "Properties: Title" (the Timeline's tabs are its comps, see
|
||||
/// [`Self::tab_docs`]).
|
||||
fn tab_titles(&self) -> Vec<(PanelKind, String)> {
|
||||
let mut out = Vec::new();
|
||||
// ScriptUI panels are named after their script.
|
||||
|
|
@ -27,7 +28,6 @@ impl EffectcraftApp {
|
|||
let Some(comp) = self.session.active_comp() else { return out };
|
||||
let cname = self.session.active_comp_id().and_then(|id| self.session.project.item(id)).map(|i| i.name.clone()).unwrap_or_default();
|
||||
out.push((PanelKind::Composition, format!("Composition {cname}")));
|
||||
out.push((PanelKind::Timeline, cname));
|
||||
if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) {
|
||||
out.push((PanelKind::EffectControls, format!("Effect Controls {}", l.name)));
|
||||
out.push((PanelKind::Properties, format!("Properties: {}", l.name)));
|
||||
|
|
@ -35,13 +35,35 @@ impl EffectcraftApp {
|
|||
out
|
||||
}
|
||||
|
||||
/// A comp's label colour as a tab swatch (faint without a label).
|
||||
fn comp_swatch(&self, cid: effectcraft_engine::project::ItemId) -> Color32 {
|
||||
match self.session.project.item(cid).map(|i| i.label) {
|
||||
Some(l) if l != effectcraft_engine::color::Label::None => self.tokens.label(l),
|
||||
_ => self.tokens.text_faint,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Timeline's tabs: one per open comp, as in After Effects (opening a precomp adds a tab
|
||||
/// next to its parent's instead of replacing it).
|
||||
fn tab_docs(&self) -> Vec<(PanelKind, Vec<dock::DocTab>)> {
|
||||
let st = &self.session.state;
|
||||
let locked = self.ui.locked_tabs.contains(&PanelKind::Timeline.id());
|
||||
let docs = st
|
||||
.open_comps
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let it = self.session.project.item(*id).filter(|i| i.as_comp().is_some())?;
|
||||
let deco = dock::TabDeco { swatch: self.comp_swatch(*id), locked, viewer: true };
|
||||
Some(dock::DocTab { id: id.0, title: it.name.clone(), deco: Some(deco), active: st.active_comp == Some(*id) })
|
||||
})
|
||||
.collect();
|
||||
vec![(PanelKind::Timeline, docs)]
|
||||
}
|
||||
|
||||
/// The Composition and Timeline tabs' close button, label-colour swatch and viewer lock.
|
||||
fn tab_decos(&self) -> Vec<(PanelKind, dock::TabDeco)> {
|
||||
let Some(cid) = self.session.active_comp_id() else { return vec![] };
|
||||
let swatch = match self.session.project.item(cid).map(|i| i.label) {
|
||||
Some(l) if l != effectcraft_engine::color::Label::None => self.tokens.label(l),
|
||||
_ => self.tokens.text_faint,
|
||||
};
|
||||
let swatch = self.comp_swatch(cid);
|
||||
let mut v: Vec<(PanelKind, dock::TabDeco)> = [PanelKind::Composition, PanelKind::Timeline]
|
||||
.into_iter()
|
||||
.map(|p| (p, dock::TabDeco { swatch, locked: self.ui.locked_tabs.contains(&p.id()), viewer: true }))
|
||||
|
|
@ -138,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 {
|
||||
|
|
@ -149,9 +178,15 @@ impl EffectcraftApp {
|
|||
let mut actions = Vec::new();
|
||||
let titles = self.tab_titles();
|
||||
let decos = self.tab_decos();
|
||||
let docs = self.tab_docs();
|
||||
let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string());
|
||||
let info = dock::TabInfo { title: &title, decos: &decos, docs: &docs };
|
||||
// Where each group's tabs are (tab drags show where a tab will land between them).
|
||||
let mut tab_rects: Vec<(String, Vec<(PanelKind, Rect)>)> = vec![];
|
||||
for g in &groups {
|
||||
actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title, &decos));
|
||||
let c = dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &info);
|
||||
actions.extend(c.actions);
|
||||
tab_rects.push((g.path.clone(), c.tabs));
|
||||
}
|
||||
self.label_tab_marks(ui);
|
||||
if maximized.is_none() {
|
||||
|
|
@ -168,7 +203,8 @@ impl EffectcraftApp {
|
|||
// Floating groups over the dock.
|
||||
let mut float_groups: Vec<Group> = vec![];
|
||||
for i in 0..self.ui.floating.len() {
|
||||
if let Some(g) = self.floating_window(&ctx, i, &title, &mut actions) {
|
||||
if let Some((g, tabs)) = self.floating_window(&ctx, i, &info, &mut actions) {
|
||||
tab_rects.push((g.path.clone(), tabs));
|
||||
float_groups.push(g);
|
||||
}
|
||||
}
|
||||
|
|
@ -197,15 +233,35 @@ impl EffectcraftApp {
|
|||
self.ui.locked_tabs.insert(p.id().to_string());
|
||||
}
|
||||
}
|
||||
// The Timeline's comp tabs: show that comp / close its Timeline.
|
||||
DockAction::ActivateDoc(p, id) => {
|
||||
if !self.ui.dock.activate(p)
|
||||
&& let Some(f) = self.ui.floating.iter_mut().find(|f| f.panels.contains(&p))
|
||||
{
|
||||
f.active = f.panels.iter().position(|x| *x == p).unwrap_or(0);
|
||||
}
|
||||
if self.session.state.active_comp.map(|c| c.0) != Some(id) {
|
||||
let _ = crate::menus::invoke(self, &ctx, "comp.open", serde_json::json!({"comp": id}));
|
||||
}
|
||||
}
|
||||
DockAction::CloseDoc(_, id) => {
|
||||
let _ = crate::menus::invoke(self, &ctx, "comp.close", serde_json::json!({"comp": id}));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.tab_drag(&ctx, &groups, &float_groups);
|
||||
self.tab_drag(&ctx, &groups, &float_groups, &tab_rects);
|
||||
panels::panel_menu_popup(self, ui);
|
||||
}
|
||||
|
||||
/// One floating group: a window with a tab strip (drag the empty strip to move it, the
|
||||
/// corner to resize it) and the active panel's body.
|
||||
fn floating_window(&mut self, ctx: &egui::Context, i: usize, title: &dyn Fn(PanelKind) -> String, actions: &mut Vec<DockAction>) -> Option<Group> {
|
||||
fn floating_window(
|
||||
&mut self,
|
||||
ctx: &egui::Context,
|
||||
i: usize,
|
||||
info: &dock::TabInfo,
|
||||
actions: &mut Vec<DockAction>,
|
||||
) -> Option<(Group, Vec<(PanelKind, Rect)>)> {
|
||||
let t = self.tokens;
|
||||
let f = self.ui.floating.get(i)?.clone();
|
||||
let screen = ctx.content_rect();
|
||||
|
|
@ -236,8 +292,8 @@ impl EffectcraftApp {
|
|||
active: f.active.min(f.panels.len().saturating_sub(1)),
|
||||
stacked: None,
|
||||
};
|
||||
let decos = self.tab_decos();
|
||||
actions.extend(dock::draw_group_chrome(ui, &g, self.ui.focused, &t, &mut self.auto, title, &decos));
|
||||
let c = dock::draw_group_chrome(ui, &g, self.ui.focused, &t, &mut self.auto, info);
|
||||
actions.extend(c.actions);
|
||||
ui.painter().rect_stroke(r, t.radius, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
|
||||
if let Some(p) = g.panels.get(g.active).copied() {
|
||||
self.panel_body(ui, p, g.content);
|
||||
|
|
@ -261,14 +317,16 @@ impl EffectcraftApp {
|
|||
{
|
||||
fl.rect = [nr.min.x, nr.min.y, nr.width(), nr.height()];
|
||||
}
|
||||
group = Some(g);
|
||||
group = Some((g, c.tabs));
|
||||
});
|
||||
group
|
||||
}
|
||||
|
||||
/// A tab being dragged: highlight the drop zone under the pointer; on release, dock (or
|
||||
/// float with Cmd/Ctrl or outside every group).
|
||||
fn tab_drag(&mut self, ctx: &egui::Context, groups: &[Group], floats: &[Group]) {
|
||||
/// A tab being dragged. Over a tab strip (or the middle of a group) it shows where the tab
|
||||
/// will land: an insertion mark between two tabs (a drop there reorders a group's own tabs
|
||||
/// too); near a group's edge, the half it will split off. On release it lands there (or
|
||||
/// floats with Cmd/Ctrl or outside every group).
|
||||
fn tab_drag(&mut self, ctx: &egui::Context, groups: &[Group], floats: &[Group], tab_rects: &[(String, Vec<(PanelKind, Rect)>)]) {
|
||||
let Some(p) = ctx.data(|d| d.get_temp::<PanelKind>(drag_id())) else { return };
|
||||
let t = self.tokens;
|
||||
let Some(pos) = ctx.pointer_latest_pos() else { return };
|
||||
|
|
@ -276,23 +334,55 @@ impl EffectcraftApp {
|
|||
let target = floats.iter().map(|g| (g, true)).chain(groups.iter().map(|g| (g, false))).find(|(g, _)| g.rect.contains(pos));
|
||||
let float_drop = ctx.input(|i| i.modifiers.command);
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("dock-drop")));
|
||||
let plan: Option<(PanelKind, Zone)> = match target {
|
||||
let plan: Option<DropPlan> = match target {
|
||||
Some((g, is_float)) if !float_drop => {
|
||||
let anchor = g.panels.get(g.active).copied().unwrap_or(p);
|
||||
let others: Vec<PanelKind> = g.panels.iter().copied().filter(|x| *x != p).collect();
|
||||
let zone = if is_float { Zone::Center } else { dock::drop_zone(g.rect, t.tab_h, pos) };
|
||||
let self_only = anchor == p && g.panels.len() == 1;
|
||||
if self_only {
|
||||
None
|
||||
} else {
|
||||
let anchor = if anchor == p { g.panels.iter().copied().find(|x| *x != p).unwrap_or(p) } else { anchor };
|
||||
let hr = dock::zone_rect(g.rect, zone).shrink(2.0);
|
||||
painter.rect_filled(hr, t.radius, t.focus.gamma_multiply(0.25));
|
||||
painter.rect_stroke(hr, t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside);
|
||||
// Outline the whole group for the side zones, like AE's drop-zone frame.
|
||||
if zone != Zone::Center {
|
||||
painter.rect_stroke(g.rect, t.radius, Stroke::new(1.0, t.focus.gamma_multiply(0.6)), StrokeKind::Inside);
|
||||
match (others.first().copied(), zone) {
|
||||
// The dragged tab alone in its group: nowhere to go here.
|
||||
(None, _) => None,
|
||||
(Some(first), Zone::Center) => {
|
||||
let rects: Vec<(PanelKind, Rect)> = tab_rects
|
||||
.iter()
|
||||
.find(|(path, _)| *path == g.path)
|
||||
.map(|(_, r)| r.iter().copied().filter(|(k, _)| *k != p).collect())
|
||||
.unwrap_or_default();
|
||||
let strip = Rect::from_min_size(g.rect.min, vec2(g.rect.width(), t.tab_h));
|
||||
let before = if strip.contains(pos) {
|
||||
// Before the first tab whose middle is right of the pointer.
|
||||
rects.iter().find(|(_, r)| r.center().x > pos.x).map(|(k, _)| *k)
|
||||
} else if g.panels.contains(&p) {
|
||||
// The middle of its own group: it stays where it is.
|
||||
Some(p)
|
||||
} else {
|
||||
// The middle of another group: right after its shown tab.
|
||||
let shown = g.panels.get(g.active).and_then(|a| others.iter().position(|x| x == a)).unwrap_or(others.len());
|
||||
others.get(shown + 1).copied()
|
||||
};
|
||||
if before == Some(p) {
|
||||
None
|
||||
} else {
|
||||
let x = match before.and_then(|b| rects.iter().find(|(k, _)| *k == b)) {
|
||||
Some((_, r)) => r.min.x - 4.0,
|
||||
None => rects.last().map_or(strip.min.x + 8.0, |(_, r)| r.max.x + 4.0),
|
||||
};
|
||||
painter.rect_filled(strip.shrink2(vec2(2.0, 1.0)), t.radius, t.focus.gamma_multiply(0.12));
|
||||
if !strip.contains(pos) {
|
||||
painter.rect_stroke(g.rect, t.radius, Stroke::new(1.0, t.focus.gamma_multiply(0.6)), StrokeKind::Inside);
|
||||
}
|
||||
insertion_mark(&painter, x, strip, t.focus);
|
||||
Some(DropPlan::Tab { anchor: first, before })
|
||||
}
|
||||
}
|
||||
(Some(first), zone) => {
|
||||
let shown = g.panels.get(g.active).copied().filter(|a| *a != p).unwrap_or(first);
|
||||
let hr = dock::zone_rect(g.rect, zone).shrink(2.0);
|
||||
painter.rect_filled(hr, t.radius, t.focus.gamma_multiply(0.25));
|
||||
painter.rect_stroke(hr, t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside);
|
||||
// Outline the whole group for the side zones, like AE's drop-zone frame.
|
||||
painter.rect_stroke(g.rect, t.radius, Stroke::new(1.0, t.focus.gamma_multiply(0.6)), StrokeKind::Inside);
|
||||
Some(DropPlan::Side { anchor: shown, zone })
|
||||
}
|
||||
Some((anchor, zone))
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
|
|
@ -307,7 +397,8 @@ impl EffectcraftApp {
|
|||
if ctx.input(|i| i.pointer.any_released()) {
|
||||
ctx.data_mut(|d| d.remove::<PanelKind>(drag_id()));
|
||||
let ok = match plan {
|
||||
Some((anchor, zone)) => self.edit_layout(|l| l.dock(p, anchor, zone)),
|
||||
Some(DropPlan::Tab { anchor, before }) => self.edit_layout(|l| l.insert_tab(p, anchor, before)),
|
||||
Some(DropPlan::Side { anchor, zone }) => self.edit_layout(|l| l.dock(p, anchor, zone)),
|
||||
None if float_drop || target.is_none() => {
|
||||
let r = [pos.x - 40.0, pos.y - 12.0, 420.0, 320.0];
|
||||
self.edit_layout(|l| l.float(p, r))
|
||||
|
|
@ -322,6 +413,23 @@ impl EffectcraftApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Where a dragged tab lands.
|
||||
enum DropPlan {
|
||||
/// A tab in the group of `anchor`, before `before` (last when `None`).
|
||||
Tab { anchor: PanelKind, before: Option<PanelKind> },
|
||||
/// A new group split off one side of `anchor`'s group.
|
||||
Side { anchor: PanelKind, zone: Zone },
|
||||
}
|
||||
|
||||
/// The insertion mark of a tab drag: a bar between two tabs at `x`, with caps.
|
||||
fn insertion_mark(painter: &egui::Painter, x: f32, strip: Rect, col: Color32) {
|
||||
let (y0, y1) = (strip.min.y + 3.0, strip.max.y - 2.0);
|
||||
painter.line_segment([pos2(x, y0), pos2(x, y1)], Stroke::new(2.5, col));
|
||||
for (y, d) in [(y0, 1.0), (y1, -1.0)] {
|
||||
painter.add(egui::Shape::convex_polygon(vec![pos2(x - 4.5, y), pos2(x + 4.5, y), pos2(x, y + 5.0 * d)], col, Stroke::NONE));
|
||||
}
|
||||
}
|
||||
|
||||
/// A new floating group's default rect near the centre of `screen`.
|
||||
pub fn default_float_rect(screen: Rect) -> [f32; 4] {
|
||||
let c = screen.center();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1121,8 +1121,11 @@ impl EffectcraftApp {
|
|||
for ev in self.session.drain_events() {
|
||||
match ev {
|
||||
effectcraft_engine::Event::OpenComp(_) => {
|
||||
if !self.ui.locked_tabs.contains(&PanelKind::Composition.id()) {
|
||||
self.ui.dock.activate(PanelKind::Composition);
|
||||
// The comp's viewer and its Timeline tab come to the front.
|
||||
for p in [PanelKind::Composition, PanelKind::Timeline] {
|
||||
if !self.ui.locked_tabs.contains(&p.id()) {
|
||||
self.ui.dock.activate(p);
|
||||
}
|
||||
}
|
||||
self.ui.timeline.pps = None;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -535,7 +535,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
app.auto.add(&format!("project.item.{}", id.0), r, &it.name);
|
||||
let nresp = ui.interact(name_clip, egui::Id::new(("pname", id.0)), Sense::click_and_drag());
|
||||
app.auto.add(&format!("project.item.{}.name", id.0), name_clip, &it.name);
|
||||
let resp = resp.union(nresp.clone());
|
||||
let resp = resp.union(nresp);
|
||||
if resp.clicked() {
|
||||
let add = ui.input(|i| i.modifiers.command || i.modifiers.shift);
|
||||
if add {
|
||||
|
|
@ -546,10 +546,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
app.session.state.project_selection = vec![*id];
|
||||
}
|
||||
}
|
||||
if nresp.double_clicked() {
|
||||
// Double-click the name: rename in place.
|
||||
ctx.data_mut(|d| d.insert_temp::<Editing>(edit_id(), (id.0, "name".into(), it.name.clone())));
|
||||
} else if resp.double_clicked() {
|
||||
// Double-click opens the item, as in After Effects (a comp gets its own Timeline tab and
|
||||
// the viewer); Enter or the context menu renames.
|
||||
if resp.double_clicked() {
|
||||
match &it.kind {
|
||||
ItemKind::Comp(_) => actions.push(("comp.open".into(), json!({"comp": id.0}))),
|
||||
ItemKind::Folder if !app.ui.project_open_folders.remove(&id.0) => {
|
||||
|
|
|
|||
|
|
@ -264,6 +264,9 @@ fn composition_tabs_and_scrolling_preview() {
|
|||
assert!(h.state().ui.locked_tabs.contains("Composition"));
|
||||
click_id(&mut h, "panel.tab.Composition.lock");
|
||||
assert!(h.state().ui.locked_tabs.is_empty());
|
||||
// The Timeline's × closes its comp's tab (the panel stays, as in After Effects).
|
||||
let shown = h.state().session.state.active_comp.unwrap();
|
||||
click_id(&mut h, "panel.tab.Timeline.close");
|
||||
assert!(!h.state().ui.dock.contains(PanelKind::Timeline));
|
||||
assert!(!h.state().session.state.open_comps.contains(&shown));
|
||||
assert!(h.state().ui.dock.contains(PanelKind::Timeline));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
//! Comp tabs and tab drags (egui_kittest): double-clicking a comp in the Project panel opens it
|
||||
//! as its own Timeline tab (not a rename, and not in place of the comp shown), the tabs switch and
|
||||
//! close comps, and a dragged tab lands where its insertion mark shows.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::ItemId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::{DockNode, PanelKind};
|
||||
use egui::{Event, Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
/// "Pre" and "Main" (Main holds Pre); only Main is open.
|
||||
fn harness() -> (Harness<'static, EffectcraftApp>, u64, u64) {
|
||||
let mut s = Session::default();
|
||||
let pre = s.execute("comp.new", json!({"name": "Pre", "width": 320, "height": 180, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
|
||||
let main = s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": pre})).unwrap();
|
||||
s.state.open_comps = vec![ItemId(main)];
|
||||
s.state.active_comp = Some(ItemId(main));
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
(h, pre, main)
|
||||
}
|
||||
|
||||
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect {
|
||||
let e = h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).find(|e| e.id == id).cloned().unwrap_or_else(|| {
|
||||
let have: Vec<&String> = h.state().auto.elements.iter().map(|e| &e.id).filter(|i| i.starts_with("panel.") || i.starts_with("project.")).collect();
|
||||
panic!("no element {id}; have {have:?}")
|
||||
});
|
||||
egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))
|
||||
}
|
||||
|
||||
fn click_n(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, n: u32) {
|
||||
h.event(Event::PointerMoved(p));
|
||||
h.step();
|
||||
// All in one frame, straight into the input (the harness' event queue spreads presses over
|
||||
// frames, longer apart than a double-click).
|
||||
for _ in 0..n {
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
}
|
||||
h.step();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
for k in 1..=6 {
|
||||
let f = k as f32 / 6.0;
|
||||
h.event(Event::PointerMoved(from + (to - from) * f));
|
||||
h.step();
|
||||
}
|
||||
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.step();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// The tabs of the docked group holding `p`, in order.
|
||||
fn group_of(n: &DockNode, p: PanelKind) -> Option<Vec<PanelKind>> {
|
||||
match n {
|
||||
DockNode::Split { a, b, .. } => group_of(a, p).or_else(|| group_of(b, p)),
|
||||
DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| panels.clone()),
|
||||
DockNode::Stack { entries } => entries.iter().any(|e| e.panel == p).then(|| entries.iter().map(|e| e.panel).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_comps(h: &Harness<'_, EffectcraftApp>) -> Vec<u64> {
|
||||
h.state().session.state.open_comps.iter().map(|c| c.0).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_double_click_opens_a_comp_as_its_own_timeline_tab() {
|
||||
let (mut h, pre, main) = harness();
|
||||
// Double-click the precomp's name in the Project panel: it opens (no rename) next to Main.
|
||||
let name = rect(&h, &format!("project.item.{pre}.name"));
|
||||
click_n(&mut h, name.center(), 2);
|
||||
assert_eq!(open_comps(&h), vec![main, pre]);
|
||||
assert_eq!(h.state().session.state.active_comp, Some(ItemId(pre)));
|
||||
assert_eq!(h.state().session.project.item(ItemId(pre)).map(|i| i.name.clone()), Some("Pre".into()));
|
||||
// One Timeline tab per comp, the newest last; the shown one answers to the panel's id.
|
||||
let (tm, tp) = (rect(&h, &format!("panel.tab.Timeline.{main}")), rect(&h, &format!("panel.tab.Timeline.{pre}")));
|
||||
assert!(tp.min.x > tm.max.x, "{tm:?} {tp:?}");
|
||||
assert_eq!(rect(&h, "panel.tab.Timeline"), tp);
|
||||
// Clicking Main's tab shows Main again; both stay open.
|
||||
click_n(&mut h, tm.center(), 1);
|
||||
assert_eq!(h.state().session.state.active_comp, Some(ItemId(main)));
|
||||
assert_eq!(open_comps(&h), vec![main, pre]);
|
||||
// × closes the shown comp's Timeline only: the panel stays with Pre.
|
||||
let close = rect(&h, "panel.tab.Timeline.close");
|
||||
click_n(&mut h, close.center(), 1);
|
||||
assert_eq!(open_comps(&h), vec![pre]);
|
||||
assert_eq!(h.state().session.state.active_comp, Some(ItemId(pre)));
|
||||
assert!(h.state().ui.dock.contains(PanelKind::Timeline));
|
||||
// Double-clicking an open comp again adds no second tab.
|
||||
let name = rect(&h, &format!("project.item.{pre}.name"));
|
||||
click_n(&mut h, name.center(), 2);
|
||||
assert_eq!(open_comps(&h), vec![pre]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragged_tabs_land_where_the_insertion_mark_is() {
|
||||
let (mut h, _, _) = harness();
|
||||
let dock = |h: &Harness<'_, EffectcraftApp>| h.state().ui.dock.clone();
|
||||
assert_eq!(group_of(&dock(&h), PanelKind::Timeline), Some(vec![PanelKind::Timeline, PanelKind::RenderQueue]));
|
||||
// Reorder within the group: Render Queue dropped on the left half of the Timeline tab goes
|
||||
// before it.
|
||||
let tl = rect(&h, "panel.tab.Timeline");
|
||||
let rq = rect(&h, "panel.tab.RenderQueue");
|
||||
drag(&mut h, rq.center(), pos2(tl.min.x + tl.width() * 0.2, tl.center().y));
|
||||
assert_eq!(group_of(&dock(&h), PanelKind::Timeline), Some(vec![PanelKind::RenderQueue, PanelKind::Timeline]));
|
||||
// Another group's tab dropped between them lands between them.
|
||||
let (rq, tl) = (rect(&h, "panel.tab.RenderQueue"), rect(&h, "panel.tab.Timeline"));
|
||||
let fx = rect(&h, "panel.tab.EffectControls");
|
||||
drag(&mut h, fx.center(), pos2((rq.max.x + tl.min.x) / 2.0 + 2.0, tl.center().y));
|
||||
assert_eq!(group_of(&dock(&h), PanelKind::Timeline), Some(vec![PanelKind::RenderQueue, PanelKind::EffectControls, PanelKind::Timeline]));
|
||||
// Past the last tab: last.
|
||||
let (ec, tl) = (rect(&h, "panel.tab.EffectControls"), rect(&h, "panel.tab.Timeline"));
|
||||
drag(&mut h, ec.center(), pos2(tl.max.x + 30.0, tl.center().y));
|
||||
assert_eq!(group_of(&dock(&h), PanelKind::Timeline), Some(vec![PanelKind::RenderQueue, PanelKind::Timeline, PanelKind::EffectControls]));
|
||||
}
|
||||
|
||||
/// `cargo test -p effectcraft-ui-egui --test ui_tabs -- --ignored`: the insertion mark mid-drag
|
||||
/// (`target/test-out/tab-drag.png`).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn tab_drag_snapshot() {
|
||||
let (mut h, _, _) = harness();
|
||||
let (tl, rq) = (rect(&h, "panel.tab.Timeline"), rect(&h, "panel.tab.RenderQueue"));
|
||||
let fx = rect(&h, "panel.tab.EffectControls");
|
||||
let to = pos2((tl.max.x + rq.min.x) / 2.0, tl.center().y);
|
||||
h.event(Event::PointerMoved(fx.center()));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: fx.center(), button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
for k in 1..=6 {
|
||||
h.event(Event::PointerMoved(fx.center() + (to - fx.center()) * (k as f32 / 6.0)));
|
||||
h.step();
|
||||
}
|
||||
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out");
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
h.render().expect("render").save(format!("{dir}/tab-drag.png")).unwrap();
|
||||
}
|
||||
|
|
@ -85,7 +85,9 @@ Points are logical window coordinates. A target is `{id}` (the element's centre;
|
|||
| `ui.screenshot` | `{path?, panel?, id?}` | `{path, width, height}`: a PNG of the window, or cropped to one panel or element. |
|
||||
|
||||
Element ids are stable, for example `tools.Selection`, `panel.Timeline`, `panel.tab.EffectControls`,
|
||||
`header.workspace.Animation`, `project.item.<id>`, `viewer.comp`, `viewer.handle.<layer>.<i>`,
|
||||
`panel.tab.Timeline.<comp>` (one Timeline tab per open comp; `panel.tab.Timeline` is the shown
|
||||
one), `header.workspace.Animation`, `project.item.<id>` (a double-click opens the item, Enter
|
||||
renames it), `viewer.comp`, `viewer.handle.<layer>.<i>`,
|
||||
`timeline.layer.<id>.bar`, `timeline.layer.<id>.twirl`, `timeline.layer.<id>.row` (click selects,
|
||||
Enter or a double-click renames, drag reorders), `timeline.prop.<uid>.stopwatch`,
|
||||
`timeline.key.<uid>.<frame>`, `timeline.cti`, `effectControls.prop.<uid>.value` and
|
||||
|
|
|
|||
|
|
@ -422,6 +422,27 @@ layers); the Graph Editor has Auto-Select Graph Type (the default: the speed gra
|
|||
spatial properties are shown, else the value graph; choosing Value or Speed turns it off). Graph
|
||||
Editor keys are small squares whatever their interpolation, as in After Effects.
|
||||
|
||||
Since (M13.30–M13.31): the Tools bar and About dialog carry the ArtCraft mark. The
|
||||
Home screen is laid out like After Effects' and covers the whole workspace. A left rail holds New
|
||||
Project / Open Project, the Home, Templates and Learn pages and, at its foot, the community
|
||||
links. The Home page has a "Welcome to EffectCraft" heading, quick-start tiles (New Composition,
|
||||
Open Demo Project, Import Footage, New from Template) and the recent projects as a filterable
|
||||
table with thumbnails and Name / Opened ("3 hours ago") / Size / Kind columns. New automation ids:
|
||||
`home.filter`, `home.templates`, `home.file.import`.
|
||||
|
||||
Since (M4.13): motion blur follows a collapsed precomp layer's own motion. Its nested layers are
|
||||
drawn with the precomp layer's transform at every sub-sample, and with the containing comp's
|
||||
shutter, as they are drawn into its frames. Shape and text layers whose content animates within
|
||||
the shutter (paths, Trim Paths, shape transforms, text animators) are drawn at Samples Per Frame
|
||||
times and averaged, as After Effects does for shape layers; content that holds still is drawn
|
||||
once.
|
||||
|
||||
Since (M3.15): the Timeline shows one tab per open comp, as in After Effects. Double-clicking a
|
||||
comp in the Project panel opens it as a new tab next to the comps already open; it no longer
|
||||
renames (Enter or the context menu still do). A tab's × closes that comp's Timeline, not the
|
||||
panel. A tab dragged over a tab strip shows an insertion mark between the tabs it will land
|
||||
between, and lands there; this reorders a group's own tabs too.
|
||||
|
||||
Since (M5.16): Key Light (After Effects' Keylight 1.2) is checked end to end on a green-screen
|
||||
plate with an uneven screen, spill and soft edges (`effect.apply "Keylight (1.2)"`, pick, Clip
|
||||
Black / White, render). The colour eyedroppers of Keying effects sample the effect's input
|
||||
|
|
@ -709,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