Compare commits
33 Commits
docs-m3-m4
...
ci-packagi
| Author | SHA1 | Date |
|---|---|---|
|
|
a48de85015 | |
|
|
0bb8baf4da | |
|
|
3f7250043d | |
|
|
fc0462a855 | |
|
|
d31094692e | |
|
|
2039688245 | |
|
|
053c445465 | |
|
|
05188d036a | |
|
|
9e79271893 | |
|
|
58dd94e212 | |
|
|
1e8bcb519a | |
|
|
26f577cb1a | |
|
|
9b5652f686 | |
|
|
c7c90cfa74 | |
|
|
a008a904af | |
|
|
73b2607c9a | |
|
|
47f2a5a3e6 | |
|
|
c90fd35ff8 | |
|
|
ef856d8f67 | |
|
|
dfb0227468 | |
|
|
43bf2906d3 | |
|
|
9ea1f1325d | |
|
|
daed03305a | |
|
|
9929b5f9ac | |
|
|
3b6b390f68 | |
|
|
e3778b1afd | |
|
|
f3db1ebe38 | |
|
|
2d4a0902ea | |
|
|
4b37443481 | |
|
|
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).
|
||||
|
|
|
|||
|
|
@ -610,6 +610,9 @@ mod tests {
|
|||
let g = run_fx("ec.keying.keylight", &[("screenColour", green()), ("screenGain", num(150.0))], plate(), 0.0, EffectEnv::default());
|
||||
assert!(g.img.get(27, 5)[3] < out.img.get(27, 5)[3]);
|
||||
assert_eq!(crate::lookup("Keylight (1.2)").map(|s| s.id), Some("ec.keying.keylight"));
|
||||
// Searches find it by After Effects' name as well as its own.
|
||||
let spec = crate::find("ec.keying.keylight").unwrap();
|
||||
assert!(crate::name_matches(spec, "keylight") && crate::name_matches(spec, "key light") && !crate::name_matches(spec, "blur"));
|
||||
}
|
||||
|
||||
/// Instances saved with the flat layout get their parameters moved into the twirl-downs.
|
||||
|
|
|
|||
|
|
@ -504,14 +504,25 @@ pub fn categories() -> Vec<&'static str> {
|
|||
v
|
||||
}
|
||||
|
||||
/// After Effects' third-party display names of effects we register under a generic name.
|
||||
const ALIASES: &[(&str, &[&str])] = &[("ec.keying.keylight", keylight::KEYLIGHT_ALIASES)];
|
||||
|
||||
/// Other names effect `id` answers to (in [`lookup`] and searches).
|
||||
pub fn aliases(id: &str) -> &'static [&'static str] {
|
||||
ALIASES.iter().find(|(i, _)| *i == id).map_or(&[], |(_, a)| a)
|
||||
}
|
||||
|
||||
/// Search (Effects & Presets, `effect.list`): the display name or one of its [`aliases`]
|
||||
/// contains `query` (lowercase).
|
||||
pub fn name_matches(spec: &EffectSpec, query: &str) -> bool {
|
||||
std::iter::once(spec.name).chain(aliases(spec.id).iter().copied()).any(|n| n.to_lowercase().contains(query))
|
||||
}
|
||||
|
||||
/// Find by id or (case-insensitive) display name.
|
||||
pub fn lookup(name_or_id: &str) -> Option<&'static EffectSpec> {
|
||||
find(name_or_id)
|
||||
.or_else(|| all().into_iter().find(|s| s.name.eq_ignore_ascii_case(name_or_id)))
|
||||
.or_else(|| {
|
||||
// After Effects' third-party display names we register under a generic name.
|
||||
keylight::KEYLIGHT_ALIASES.iter().any(|a| a.eq_ignore_ascii_case(name_or_id)).then(|| find("ec.keying.keylight")).flatten()
|
||||
})
|
||||
.or_else(|| ALIASES.iter().find(|(_, names)| names.iter().any(|a| a.eq_ignore_ascii_case(name_or_id))).and_then(|(id, _)| find(id)))
|
||||
.or_else(|| {
|
||||
// Display names of earlier versions.
|
||||
migrate::EFFECT_NAME_ALIASES.iter().find(|(old, _)| old.eq_ignore_ascii_case(name_or_id)).and_then(|(_, id)| find(id))
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use effectcraft_project::{LayerId, Property, Uid};
|
|||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::prop::{edit_keys, prop_ref};
|
||||
use super::prop::prop_ref;
|
||||
use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_keys, layers_p, merge_p, str_p, time_p};
|
||||
use crate::{EngineError, KeyClip, KeyRef, Result, Session, cmd, query};
|
||||
|
||||
|
|
@ -73,11 +73,19 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let path = str_p(p, "path")?;
|
||||
comp.layer(*targets.first()?)?.props.prop(path).map(|pr| pr.uid)
|
||||
});
|
||||
// Keys copied from several layers, pasted onto as many layers: the first copied layer's keys
|
||||
// go to the first target and so on (else every target gets every copied property).
|
||||
let mut sources: Vec<LayerId> = clip.iter().map(|c| c.layer).collect();
|
||||
sources.dedup();
|
||||
let paired = sources.len() > 1 && sources.len() == targets.len();
|
||||
// (layer, prop uid, clip index)
|
||||
let mut plan: Vec<(LayerId, Uid, usize)> = vec![];
|
||||
for lid in &targets {
|
||||
for (ti, lid) in targets.iter().enumerate() {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
for (ci, c) in clip.iter().enumerate() {
|
||||
if paired && sources.get(ti) != Some(&c.layer) {
|
||||
continue;
|
||||
}
|
||||
let Some(sample) = c.keys.first().map(|k| &k.value) else { continue };
|
||||
let target = if clip.len() == 1 {
|
||||
explicit
|
||||
|
|
@ -131,7 +139,14 @@ fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let (cid, mut layers) = layers_p(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut sel = vec![];
|
||||
if p.get("layers").is_none() && p.get("layer").is_none() && !s.state.selected_props.is_empty() {
|
||||
if let Some(visible) = super::visible_p(p) {
|
||||
// Ctrl+Alt+A in the Timeline: every key it shows (its revealed properties).
|
||||
for (lid, uid) in visible {
|
||||
if let Some(pr) = comp.layer(lid).and_then(|l| l.props.find(uid)) {
|
||||
sel.extend(pr.keys.iter().map(|k| KeyRef { layer: lid, prop: uid, time: k.time }));
|
||||
}
|
||||
}
|
||||
} else if p.get("layers").is_none() && p.get("layer").is_none() && !s.state.selected_props.is_empty() {
|
||||
for (lid, uid) in &s.state.selected_props {
|
||||
if let Some(pr) = comp.layer(*lid).and_then(|l| l.props.find(*uid)) {
|
||||
sel.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time }));
|
||||
|
|
@ -145,6 +160,37 @@ fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
l.props.walk("", &mut |_, pr| sel.extend(pr.keys.iter().map(|k| KeyRef { layer: l.id, prop: pr.uid, time: k.time })));
|
||||
}
|
||||
}
|
||||
// Keys of locked layers can't be selected.
|
||||
sel.retain(|k| comp.layer(k.layer).is_some_and(|l| !l.switches.locked));
|
||||
s.state.selected_keys = sel;
|
||||
Ok(json!(s.state.selected_keys.len()))
|
||||
}
|
||||
|
||||
/// The keyframe context menu's Select Equal / Previous / Following Keyframes: in every property
|
||||
/// with a selected key, its keys with the same value as a selected one / before the first
|
||||
/// selected one / after the last selected one (the selected keys stay selected).
|
||||
fn select_related(s: &mut Session, which: &str) -> Result<Value> {
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let mut sel = s.state.selected_keys.clone();
|
||||
let mut props: Vec<(LayerId, Uid)> = sel.iter().map(|k| (k.layer, k.prop)).collect();
|
||||
props.sort();
|
||||
props.dedup();
|
||||
for (lid, uid) in props {
|
||||
let Some(pr) = comp.layer(lid).and_then(|l| l.props.find(uid)) else { continue };
|
||||
let picked: Vec<&Keyframe> = pr.keys.iter().filter(|k| sel.iter().any(|r| r.layer == lid && r.prop == uid && r.time == k.time)).collect();
|
||||
let (Some(first), Some(last)) = (picked.iter().map(|k| k.time).min(), picked.iter().map(|k| k.time).max()) else { continue };
|
||||
for k in &pr.keys {
|
||||
let hit = match which {
|
||||
"equal" => picked.iter().any(|p| p.value == k.value),
|
||||
"previous" => k.time < first,
|
||||
_ => k.time > last,
|
||||
};
|
||||
let r = KeyRef { layer: lid, prop: uid, time: k.time };
|
||||
if hit && !sel.contains(&r) {
|
||||
sel.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
s.state.selected_keys = sel;
|
||||
Ok(json!(s.state.selected_keys.len()))
|
||||
}
|
||||
|
|
@ -153,11 +199,8 @@ fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let frames = p.get("frames").and_then(Value::as_i64).ok_or_else(|| bad("keys.nudge", "missing `frames`"))?;
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let d = Tick(comp.frame_duration().0 * frames);
|
||||
edit_keys(s, "Nudge Keyframes", merge_p(p), move |_, _, t| {
|
||||
*t += d;
|
||||
true
|
||||
})
|
||||
let d = Tick(comp.frame_duration().0.saturating_mul(frames));
|
||||
super::prop::shift_keys(s, "Nudge Keyframes", merge_p(p), d)
|
||||
}
|
||||
|
||||
/// Edit one key directly: move it in time and/or set its value (Graph Editor drags).
|
||||
|
|
@ -386,7 +429,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
vec![
|
||||
cmd!("keys.copy", "Copy Keyframes", [], None, "{}", has_keys, copy),
|
||||
cmd!("keys.paste", "Paste Keyframes", [], None, "{layers?, prop?|path?, time?}", has_key_clip, paste),
|
||||
cmd!("keys.selectAll", "Select All Keyframes", [], Some("Cmd+Alt+A"), "{layers?}", keys_or_layers, select_all),
|
||||
cmd!(
|
||||
"keys.selectAll",
|
||||
"Select All Keyframes",
|
||||
[],
|
||||
Some("Cmd+Alt+A"),
|
||||
"{layers?, visible?: [{layer, prop}] (every key of these properties)}",
|
||||
keys_or_layers,
|
||||
select_all
|
||||
),
|
||||
cmd!("keys.nudge", "Nudge Keyframes", [], None, "{frames, merge?}", has_keys, nudge),
|
||||
cmd!("keys.nudgeForward", "Move Keyframes 1 Frame Later", [], Some("Alt+ArrowRight"), "{}", has_keys, |s, _| nudge(s, &json!({"frames": 1}))),
|
||||
cmd!("keys.nudgeBackward", "Move Keyframes 1 Frame Earlier", [], Some("Alt+ArrowLeft"), "{}", has_keys, |s, _| nudge(s, &json!({"frames": -1}))),
|
||||
|
|
@ -399,6 +450,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
&json!({"frames": -10})
|
||||
)),
|
||||
cmd!("keys.set", "Edit Keyframe", [], None, "{layer?, path|prop, time (layer s), newTime?, value?, merge?}", has_comp, set_key),
|
||||
cmd!("keys.selectEqual", "Select Equal Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "equal")),
|
||||
cmd!("keys.selectPrevious", "Select Previous Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "previous")),
|
||||
cmd!("keys.selectFollowing", "Select Following Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "following")),
|
||||
cmd!(
|
||||
"keys.setEase",
|
||||
"Set Keyframe Ease",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -299,46 +299,6 @@ fn copy_expression_only(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
/// Paste the keyframe clipboard at the CTI onto the selected layers (same property paths).
|
||||
fn paste_keys(s: &mut Session, reversed: bool) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let ids = s.state.selected_layers.clone();
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("select a layer to paste keyframes into".into()));
|
||||
}
|
||||
let clip = s.state.key_clipboard.clone();
|
||||
let t = s.time();
|
||||
let n = s.edit(if reversed { "Paste Reversed Keyframes" } else { "Paste Keyframes" }, None, |proj, st| {
|
||||
let mut n = 0;
|
||||
st.selected_keys.clear();
|
||||
for lid in &ids {
|
||||
let l = super::layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
for c in &clip {
|
||||
let Some(pr) = l.props.prop_mut(&c.path) else { continue };
|
||||
let span = c.keys.last().map(|k| k.time).unwrap_or(Tick::ZERO);
|
||||
for k in &c.keys {
|
||||
let mut k = k.clone();
|
||||
if std::mem::discriminant(&k.value) != std::mem::discriminant(&pr.value) {
|
||||
continue;
|
||||
}
|
||||
if reversed {
|
||||
k.time = span - k.time;
|
||||
std::mem::swap(&mut k.in_interp, &mut k.out_interp);
|
||||
std::mem::swap(&mut k.in_ease, &mut k.out_ease);
|
||||
std::mem::swap(&mut k.spatial_in, &mut k.spatial_out);
|
||||
}
|
||||
k.time += lt;
|
||||
st.selected_keys.push(crate::KeyRef { layer: *lid, prop: pr.uid, time: k.time });
|
||||
effectcraft_keyframe::set_key(&mut pr.keys, k);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({"keys": n}))
|
||||
}
|
||||
|
||||
fn paste_links(s: &mut Session, clip: LinkClip) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let ids = s.state.selected_layers.clone();
|
||||
|
|
@ -470,8 +430,34 @@ fn label(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn paste_reversed(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
paste_keys(s, true)
|
||||
/// Edit ▸ Paste Reversed Keyframes: the copied keys mirrored in time over their whole span (the
|
||||
/// last key first, incoming and outgoing eases swapped), pasted like Edit ▸ Paste
|
||||
/// (`keys.paste`: at the current time, into the selected or the same properties).
|
||||
fn paste_reversed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let original = s.state.key_clipboard.clone();
|
||||
let span = original.iter().flat_map(|c| c.keys.iter().map(|k| k.time)).max().unwrap_or(Tick::ZERO);
|
||||
s.state.key_clipboard = original
|
||||
.iter()
|
||||
.map(|c| crate::KeyClip {
|
||||
keys: c
|
||||
.keys
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|k| {
|
||||
let mut k = k.clone();
|
||||
k.time = span - k.time;
|
||||
std::mem::swap(&mut k.in_interp, &mut k.out_interp);
|
||||
std::mem::swap(&mut k.in_ease, &mut k.out_ease);
|
||||
std::mem::swap(&mut k.spatial_in, &mut k.spatial_out);
|
||||
k
|
||||
})
|
||||
.collect(),
|
||||
..c.clone()
|
||||
})
|
||||
.collect();
|
||||
let r = s.execute("keys.paste", p.clone());
|
||||
s.state.key_clipboard = original;
|
||||
r
|
||||
}
|
||||
|
||||
/// Lift (leave a gap) or extract (close the gap) the work area from the selected layers (all
|
||||
|
|
@ -667,7 +653,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
),
|
||||
cmd!("edit.copyExpressionOnly", "Copy Expression Only", ["Edit"], None, "{}", has_props_or_layers, copy_expression_only),
|
||||
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{} (layers, keyframes at the CTI, or property links / expressions)", has_clip, paste),
|
||||
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{}", has_key_clip, paste_reversed),
|
||||
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{layers?, prop?|path?, time?}", has_key_clip, paste_reversed),
|
||||
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete),
|
||||
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?}", has_layers, duplicate),
|
||||
cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use effectcraft_project::build::Ids;
|
|||
use effectcraft_project::{GroupKind, LayerId, PropGroup, Uid};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layers_p, str_p};
|
||||
use super::{CommandSpec, b_p, bad, f_p, has_layers, layer_mut, layers_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
/// `effect.plugins.list`: the registered plug-in effects (WebAssembly or Rust).
|
||||
|
|
@ -132,6 +132,52 @@ fn find_fx(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project::It
|
|||
Ok((cid, lid, g.uid))
|
||||
}
|
||||
|
||||
/// A colour parameter's eyedropper on a keyer (Effect Controls): the colour of the effect's input
|
||||
/// (the layer's pixels before this effect, so a keyed screen can still be picked) at a
|
||||
/// layer-space point, set on the parameter. `average` takes the 5 × 5 pixels around it.
|
||||
fn pick_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "effect.pickColor";
|
||||
let (cid, lid, fx_uid) = find_fx(s, p, cmd)?;
|
||||
let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) };
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let fx = layer.effects().ok_or_else(|| bad(cmd, "no effects"))?;
|
||||
let index = fx.groups().position(|g| g.uid == fx_uid).ok_or_else(|| bad(cmd, "no such effect"))?;
|
||||
let g = fx.groups().nth(index).ok_or_else(|| bad(cmd, "no such effect"))?;
|
||||
let prop = match (p.get("prop").and_then(Value::as_u64), str_p(p, "param")) {
|
||||
(Some(uid), _) => g.find(uid),
|
||||
(None, Some(path)) => g.prop(path),
|
||||
_ => return Err(bad(cmd, "missing `param` (e.g. `screenColour`) or `prop` (uid)")),
|
||||
}
|
||||
.filter(|pr| pr.ui == effectcraft_project::ParamUi::Color)
|
||||
.ok_or_else(|| bad(cmd, "not a colour parameter of this effect"))?;
|
||||
let t = super::time_p(s, p, Some(comp));
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
let mut r = effectcraft_render::Renderer::new(&s.project, &*s.footage, effectcraft_render::RenderOpts::default());
|
||||
r.expr = s.expr.as_deref();
|
||||
r.cache = Some(&s.layer_cache);
|
||||
let buf = r.layer_input(&ctx, layer, index).ok_or_else(|| bad(cmd, "this layer has no pixels"))?;
|
||||
let (px, py) = buf.to_px([x, y]);
|
||||
let (cx, cy) = (px.floor() as i64, py.floor() as i64);
|
||||
let rad = if b_p(p, "average") == Some(true) { 2 } else { 0 };
|
||||
let mut sum = [0.0f32; 4];
|
||||
for yy in cy.saturating_sub(rad)..=cy.saturating_add(rad) {
|
||||
for xx in cx.saturating_sub(rad)..=cx.saturating_add(rad) {
|
||||
// Outside the layer reads as transparent.
|
||||
let c = buf.img.get(xx, yy);
|
||||
(0..4).for_each(|i| sum[i] += c[i]);
|
||||
}
|
||||
}
|
||||
if sum[3] <= 1e-6 {
|
||||
return Err(bad(cmd, "nothing to sample there (transparent or outside the layer)"));
|
||||
}
|
||||
// Straight colour of the (averaged) premultiplied pixels.
|
||||
let color: Vec<f64> = (0..3).map(|i| (sum[i] / sum[3]).clamp(0.0, 1.0) as f64).chain([1.0]).collect();
|
||||
let uid = prop.uid;
|
||||
s.execute("prop.set", json!({"comp": cid.0, "layer": lid.0, "prop": uid, "value": color}))?;
|
||||
Ok(json!({"prop": uid, "color": color}))
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = super::comp_id(s, p)?;
|
||||
let fx = effects_p(s, p, "effect.remove")?;
|
||||
|
|
@ -355,11 +401,11 @@ fn last(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let filter = str_p(p, "filter").map(str::to_ascii_lowercase);
|
||||
let filter = str_p(p, "filter").map(str::to_lowercase);
|
||||
let v: Vec<Value> = effectcraft_effects::all()
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
filter.as_ref().is_none_or(|f| e.name.to_ascii_lowercase().contains(f) || e.id.contains(f.as_str()) || e.category.to_ascii_lowercase().contains(f))
|
||||
filter.as_ref().is_none_or(|f| effectcraft_effects::name_matches(e, f) || e.id.contains(f.as_str()) || e.category.to_ascii_lowercase().contains(f))
|
||||
})
|
||||
.map(|e| {
|
||||
json!({"id": e.id, "name": e.name, "category": e.category, "gpu": e.gpu, "float": e.float, "params": e.params.iter().map(|p| json!({"id": p.id, "name": p.name, "default": p.default.to_json()})).collect::<Vec<_>>()})
|
||||
|
|
@ -371,6 +417,15 @@ fn list(_: &mut Session, p: &Value) -> Result<Value> {
|
|||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("effect.apply", "Apply Effect", [], None, "{effect: id|name (e.g. Gaussian Blur), layers?}", has_layers, apply),
|
||||
cmd!(
|
||||
"effect.pickColor",
|
||||
"Pick Effect Colour",
|
||||
[],
|
||||
None,
|
||||
"{layer?, effect?: index|uid|name, param: id (e.g. screenColour) | prop: uid, x, y (layer px), average?: bool (5×5), time?} — the colour of the effect's input there",
|
||||
has_layers,
|
||||
pick_color
|
||||
),
|
||||
cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last),
|
||||
cmd!("effect.removeAll", "Remove All", ["Effect"], Some("Cmd+Shift+E"), "{layers?}", has_layers, remove_all),
|
||||
cmd!("effect.remove", "Remove Effect", [], None, "{layer?, effect: index|uid|name}", has_layers, remove),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -19,11 +19,25 @@ fn v3(v: Option<&Value>) -> Option<[f64; 3]> {
|
|||
/// Transform the selected keys: times scale about `timeAnchor` (comp seconds, default the first
|
||||
/// selected key) by `timeScale` and move by `timeOffset`; values scale about `valueAnchor` by
|
||||
/// `valueScale` and move by `valueOffset` (every dimension, or only `dim`). Times land on frames.
|
||||
/// With `fromStart` (drags), each step of one merged gesture gives the whole transform since the
|
||||
/// drag started and applies to the keys as they were then: keys squeezed onto one frame on the
|
||||
/// way, or rounded steps, are never lost; only the final position counts.
|
||||
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let merge = merge_p(p);
|
||||
let from_start = b_p(p, "fromStart").unwrap_or(false) && merge.is_some();
|
||||
let gesture = from_start && merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)) && s.key_move.as_ref().map(|g| g.merge.as_str()) == merge;
|
||||
let (sel, base) = match (&s.key_move, s.history.undo.last()) {
|
||||
(Some(g), Some((_, b))) if gesture => (g.from.clone(), Some(b.clone())),
|
||||
_ => (s.state.selected_keys.clone(), None),
|
||||
};
|
||||
if from_start {
|
||||
s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: sel.clone(), total: Tick::ZERO });
|
||||
}
|
||||
// The comp as it was when the drag started (its keys are what the transform applies to).
|
||||
let start = base.clone().unwrap_or_else(|| s.project.clone());
|
||||
let comp = start.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let fr = comp.frame_rate;
|
||||
let sel = s.state.selected_keys.clone();
|
||||
let ts = f_p(p, "timeScale").unwrap_or(1.0);
|
||||
let toff = f_p(p, "timeOffset").unwrap_or(0.0);
|
||||
let vs = f_p(p, "valueScale").unwrap_or(1.0);
|
||||
|
|
@ -46,7 +60,10 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fr.snap_nearest(Tick::from_seconds_f64(x.max(0.0)))
|
||||
};
|
||||
let touch_values = vs != 1.0 || voff != 0.0;
|
||||
s.edit("Transform Keyframes", merge_p(p), |proj, st| {
|
||||
s.edit("Transform Keyframes", merge, |proj, st| {
|
||||
if let Some(b) = base {
|
||||
*proj = (*b).clone();
|
||||
}
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
|
||||
for k in &sel {
|
||||
|
|
@ -136,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Transform Keyframes",
|
||||
[],
|
||||
None,
|
||||
"{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?}",
|
||||
"{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?, fromStart?: bool (with merge: values are the whole transform since the drag started)}",
|
||||
has_keys,
|
||||
transform
|
||||
),
|
||||
|
|
|
|||
|
|
@ -517,6 +517,13 @@ pub(crate) fn unlocked(s: &Session, cid: ItemId, ids: Vec<LayerId>, cmd: &str) -
|
|||
}
|
||||
}
|
||||
|
||||
/// `visible: [{layer, prop}]`: the properties the Timeline shows (J / K and Select All
|
||||
/// Keyframes use only those, as in After Effects). `None` when not given.
|
||||
pub(crate) fn visible_p(p: &Value) -> Option<Vec<(LayerId, effectcraft_project::Uid)>> {
|
||||
let a = p.get("visible")?.as_array()?;
|
||||
Some(a.iter().filter_map(|v| Some((LayerId(v.get("layer")?.as_u64()?), v.get("prop")?.as_u64()?))).collect())
|
||||
}
|
||||
|
||||
/// `time` (seconds) or `frame` param, else the CTI.
|
||||
pub(crate) fn time_p(s: &Session, p: &Value, comp: Option<&Comp>) -> Tick {
|
||||
if let Some(t) = f_p(p, "time") {
|
||||
|
|
|
|||
|
|
@ -335,7 +335,19 @@ fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
sel.push(KeyRef { layer: l, prop: pr.uid, time: kt });
|
||||
}
|
||||
}
|
||||
if b_p(p, "add").unwrap_or(false) {
|
||||
// Keys of locked layers can't be selected (so nothing edits them).
|
||||
sel.retain(|k| comp.layer(k.layer).is_some_and(|l| !l.switches.locked));
|
||||
if b_p(p, "toggle").unwrap_or(false) {
|
||||
// Shift+click: in and out of the selection.
|
||||
for k in sel {
|
||||
match s.state.selected_keys.iter().position(|x| *x == k) {
|
||||
Some(i) => {
|
||||
s.state.selected_keys.remove(i);
|
||||
}
|
||||
None => s.state.selected_keys.push(k),
|
||||
}
|
||||
}
|
||||
} else if b_p(p, "add").unwrap_or(false) {
|
||||
for k in sel {
|
||||
if !s.state.selected_keys.contains(&k) {
|
||||
s.state.selected_keys.push(k);
|
||||
|
|
@ -400,14 +412,72 @@ pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: im
|
|||
})
|
||||
}
|
||||
|
||||
/// Move the selected keys by `d` comp seconds, snapped to the comp's frames in comp time (keys
|
||||
/// of time-stretched layers move with the pointer, not by the layer-time distance). Keys of
|
||||
/// locked layers stay. A key landing on another key of its property replaces it.
|
||||
pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: Tick) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
// A drag (steps with one merge key) starts again from the keys before it each step.
|
||||
let gesture = merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)) && s.key_move.as_ref().map(|g| g.merge.as_str()) == merge;
|
||||
let (from, total, base) = match (&s.key_move, s.history.undo.last()) {
|
||||
(Some(g), Some((_, base))) if gesture => (g.from.clone(), g.total + d, Some(base.clone())),
|
||||
_ => (s.state.selected_keys.clone(), d, None),
|
||||
};
|
||||
s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total });
|
||||
s.edit(label, merge, |proj, st| {
|
||||
if let Some(b) = base {
|
||||
*proj = (*b).clone();
|
||||
}
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let fr = comp.frame_rate;
|
||||
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
|
||||
for k in &from {
|
||||
groups.entry((k.layer, k.prop)).or_default().push(k.time);
|
||||
}
|
||||
let mut sel = vec![];
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(l) = comp.layer_mut(lid) else { continue };
|
||||
let to = |t: Tick| l.layer_time(fr.snap_nearest(l.comp_time(t) + total));
|
||||
let moves: Vec<(Tick, Tick)> = times.iter().map(|t| (*t, to(*t))).collect();
|
||||
if l.switches.locked {
|
||||
sel.extend(times.iter().map(|t| KeyRef { layer: lid, prop: uid, time: *t }));
|
||||
continue;
|
||||
}
|
||||
let Some(pr) = l.props.find_mut(uid) else { continue };
|
||||
// Take the moving keys out, then put them back at their new times.
|
||||
let mut moved = vec![];
|
||||
for (old, new) in &moves {
|
||||
if let Some(i) = key_at(&pr.keys, *old) {
|
||||
let mut k = pr.keys.remove(i);
|
||||
k.time = *new;
|
||||
moved.push(k);
|
||||
}
|
||||
}
|
||||
for k in moved {
|
||||
sel.push(KeyRef { layer: lid, prop: uid, time: k.time });
|
||||
match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) {
|
||||
Ok(j) => pr.keys[j] = k,
|
||||
Err(j) => pr.keys.insert(j, k),
|
||||
}
|
||||
}
|
||||
// Roving keys follow their neighbours: keep the selection on them.
|
||||
let before: Vec<Tick> = pr.keys.iter().map(|k| k.time).collect();
|
||||
if effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial) {
|
||||
for r in sel.iter_mut().filter(|r| r.layer == lid && r.prop == uid) {
|
||||
if let Some(i) = before.iter().position(|t| *t == r.time) {
|
||||
r.time = pr.keys[i].time;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
st.selected_keys = sel;
|
||||
Ok(json!(st.selected_keys.len()))
|
||||
})
|
||||
}
|
||||
|
||||
fn move_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = Tick::from_seconds_f64(f_p(p, "delta").ok_or_else(|| bad("keys.move", "missing `delta` (seconds)"))?);
|
||||
let fr = s.active_comp().map(|c| c.frame_rate);
|
||||
edit_keys(s, "Move Keyframes", merge_p(p), move |_, _, t| {
|
||||
let nt = *t + d;
|
||||
*t = fr.map(|r| r.snap_nearest(nt)).unwrap_or(nt);
|
||||
true
|
||||
})
|
||||
shift_keys(s, "Move Keyframes", merge_p(p), d)
|
||||
}
|
||||
|
||||
fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
|
@ -543,7 +613,14 @@ fn interpolation(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn toggle_hold(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
edit_keys(s, "Toggle Hold Keyframe", None, |keys, i, _| {
|
||||
let k = &mut keys[i];
|
||||
k.out_interp = if k.out_interp == Interp::Hold { Interp::Linear } else { Interp::Hold };
|
||||
// Off again: back to Bezier when the key eases (its other side, auto / continuous, or
|
||||
// an outgoing ease), else Linear.
|
||||
let bezier = k.auto_bezier || k.continuous || k.in_interp == Interp::Bezier || !k.out_ease.is_empty();
|
||||
k.out_interp = match k.out_interp {
|
||||
Interp::Hold if bezier => Interp::Bezier,
|
||||
Interp::Hold => Interp::Linear,
|
||||
_ => Interp::Hold,
|
||||
};
|
||||
false
|
||||
})
|
||||
}
|
||||
|
|
@ -667,7 +744,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_layers,
|
||||
convert_expr_to_keys
|
||||
),
|
||||
cmd!("keys.select", "Select Keyframes", [], None, "{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?}", has_comp, select_keys),
|
||||
cmd!(
|
||||
"keys.select",
|
||||
"Select Keyframes",
|
||||
[],
|
||||
None,
|
||||
"{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?, toggle?: bool (Shift+click: in or out of the selection)}",
|
||||
has_comp,
|
||||
select_keys
|
||||
),
|
||||
cmd!("keys.move", "Move Keyframes", [], None, "{delta (s), merge?}", has_keys, move_keys),
|
||||
cmd!("keys.delete", "Delete Keyframes", [], None, "{}", has_keys, delete_keys),
|
||||
cmd!("keys.easyEase", "Easy Ease", ["Animation", "Keyframe Assistant"], Some("F9"), "{which?: both|in|out}", has_keys, ease),
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let t = s.time();
|
||||
let to = str_p(p, "to").unwrap_or("start");
|
||||
let sel_layer = s.state.selected_layers.first().and_then(|id| comp.layer(*id));
|
||||
let visible = super::visible_p(p);
|
||||
let target = match to {
|
||||
"start" => Tick::ZERO,
|
||||
"end" => comp.duration - comp.frame_duration(),
|
||||
|
|
@ -78,8 +79,25 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"workEnd" => comp.work_area.1 - comp.frame_duration(),
|
||||
"layerIn" => sel_layer.map(|l| l.in_point).unwrap_or(t),
|
||||
"layerOut" => sel_layer.map(|l| l.out_point - comp.frame_duration()).unwrap_or(t),
|
||||
"nextKey" | "prevKey" if visible.is_some() => {
|
||||
// What the Timeline shows: keys of its revealed properties, layer and comp markers
|
||||
// and the work area (After Effects' "visible items").
|
||||
let mut times: Vec<Tick> = comp.markers.iter().map(|m| m.time).collect();
|
||||
times.extend([comp.work_area.0, comp.work_area.1 - comp.frame_duration()]);
|
||||
for l in comp.layers.iter().filter(|l| !(comp.hide_shy && l.switches.shy)) {
|
||||
times.extend(l.markers.iter().map(|m| l.comp_time(m.time)));
|
||||
}
|
||||
for (lid, uid) in visible.iter().flatten() {
|
||||
if let Some(l) = comp.layer(*lid)
|
||||
&& let Some(pr) = l.props.find(*uid)
|
||||
{
|
||||
times.extend(pr.keys.iter().map(|k| l.comp_time(k.time)));
|
||||
}
|
||||
}
|
||||
step_to(times, t, comp.frame_duration(), to == "nextKey")
|
||||
}
|
||||
"nextKey" | "prevKey" => {
|
||||
// Keyframes (of visible/selected layers) and markers, in comp time.
|
||||
// Keyframes (of visible/selected layers), markers and the work area, in comp time.
|
||||
let mut times: Vec<Tick> = comp.markers.iter().map(|m| m.time).collect();
|
||||
let layers: Vec<_> = if s.state.selected_layers.is_empty() {
|
||||
comp.layers.iter().collect()
|
||||
|
|
@ -90,6 +108,9 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let only = p.get("prop").and_then(Value::as_u64);
|
||||
if only.is_some() {
|
||||
times.clear();
|
||||
} else {
|
||||
// The work area's start and end too, as in After Effects.
|
||||
times.extend([comp.work_area.0, comp.work_area.1 - comp.frame_duration()]);
|
||||
}
|
||||
for l in layers {
|
||||
l.props.walk("", &mut |_, pr| {
|
||||
|
|
@ -104,14 +125,7 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
times.extend(l.markers.iter().map(|m| l.comp_time(m.time)));
|
||||
}
|
||||
}
|
||||
times.sort();
|
||||
times.dedup();
|
||||
let half = comp.frame_duration().0 / 2;
|
||||
if to == "nextKey" {
|
||||
times.into_iter().find(|x| x.0 > t.0 + half).unwrap_or(t)
|
||||
} else {
|
||||
times.into_iter().rev().find(|x| x.0 < t.0 - half).unwrap_or(t)
|
||||
}
|
||||
step_to(times, t, comp.frame_duration(), to == "nextKey")
|
||||
}
|
||||
_ => return Err(super::bad("time.go", "to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey")),
|
||||
};
|
||||
|
|
@ -119,6 +133,22 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(report(s))
|
||||
}
|
||||
|
||||
/// The nearest time in `times` after (or before) `t`, more than half a frame away; `t` itself
|
||||
/// when there is none.
|
||||
fn step_to(mut times: Vec<Tick>, t: Tick, frame: Tick, next: bool) -> Tick {
|
||||
times.sort();
|
||||
times.dedup();
|
||||
let half = frame.0 / 2;
|
||||
if next { times.into_iter().find(|x| x.0 > t.0 + half).unwrap_or(t) } else { times.into_iter().rev().find(|x| x.0 < t.0 - half).unwrap_or(t) }
|
||||
}
|
||||
|
||||
/// `p` with `to` set (J / K pass their other parameters on).
|
||||
fn with_to(p: &Value, to: &str) -> Value {
|
||||
let mut p = if p.is_object() { p.clone() } else { json!({}) };
|
||||
p["to"] = json!(to);
|
||||
p
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("time.set", "Go to Time...", ["View"], Some("Alt+Shift+J"), "{time? (s) | frame? | timecode?}", has_comp, set),
|
||||
|
|
@ -131,8 +161,27 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("time.end", "Go to End", [], Some("End"), "{}", has_comp, |s, _| go(s, &json!({"to": "end"}))),
|
||||
cmd!("time.layerIn", "Go to Layer In Point", [], Some("I"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerIn"}))),
|
||||
cmd!("time.layerOut", "Go to Layer Out Point", [], Some("O"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerOut"}))),
|
||||
cmd!("time.nextKey", "Go to Next Keyframe or Marker", [], Some("K"), "{}", has_comp, |s, _| go(s, &json!({"to": "nextKey"}))),
|
||||
cmd!("time.previousKey", "Go to Previous Keyframe or Marker", [], Some("J"), "{}", has_comp, |s, _| go(s, &json!({"to": "prevKey"}))),
|
||||
cmd!("time.go", "Go To", [], None, "{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey, prop?: uid}", has_comp, go),
|
||||
cmd!(
|
||||
"time.nextKey",
|
||||
"Go to Next Keyframe or Marker",
|
||||
[],
|
||||
Some("K"),
|
||||
"{visible?: [{layer, prop}] (only these properties' keys, as the Timeline shows them)}",
|
||||
has_comp,
|
||||
|s, p| go(s, &with_to(p, "nextKey"))
|
||||
),
|
||||
cmd!("time.previousKey", "Go to Previous Keyframe or Marker", [], Some("J"), "{visible?: [{layer, prop}]}", has_comp, |s, p| go(
|
||||
s,
|
||||
&with_to(p, "prevKey")
|
||||
)),
|
||||
cmd!(
|
||||
"time.go",
|
||||
"Go To",
|
||||
[],
|
||||
None,
|
||||
"{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey, prop?: uid, visible?: [{layer, prop}]}",
|
||||
has_comp,
|
||||
go
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,6 +134,17 @@ pub struct KeyRef {
|
|||
pub time: Tick,
|
||||
}
|
||||
|
||||
/// A keyframe drag in progress (`keys.move` steps sharing a merge key): the selection it
|
||||
/// started from and the total offset so far, so every step is applied to the keys as they were
|
||||
/// before the drag. A key passed over on the way is never overwritten; only the drop position
|
||||
/// counts, as in After Effects.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct KeyMove {
|
||||
pub merge: String,
|
||||
pub from: Vec<KeyRef>,
|
||||
pub total: Tick,
|
||||
}
|
||||
|
||||
/// Keyframes of one property on the keyframe clipboard.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct KeyClip {
|
||||
|
|
@ -373,6 +384,10 @@ pub struct Session {
|
|||
/// The last physical-memory reading (Settings ▸ Memory & CPU budgets); `None` = unknown
|
||||
/// (headless sessions don't query it, see [`Session::memory_tick`]).
|
||||
pub sys_memory: Option<sysinfo::SysMemory>,
|
||||
/// The keyframe drag in progress (`keys.move` with a merge key).
|
||||
pub key_move: Option<KeyMove>,
|
||||
/// Reads the system's memory in the background for [`Session::memory_tick`].
|
||||
pub memory_watch: sysinfo::MemoryWatch,
|
||||
/// Switches Affect Nested Comps as last applied (a change empties the layer cache).
|
||||
pub applied_nested_switches: Option<bool>,
|
||||
/// Runs renders and analyses off the UI thread where there are no threads (the web app's
|
||||
|
|
@ -465,6 +480,8 @@ impl Default for Session {
|
|||
plugin_loader: None,
|
||||
script_ui: scriptui::ScriptUi::default(),
|
||||
sys_memory: None,
|
||||
key_move: None,
|
||||
memory_watch: Default::default(),
|
||||
applied_nested_switches: None,
|
||||
offload: None,
|
||||
offloaded: vec![],
|
||||
|
|
@ -937,6 +954,8 @@ mod tests_fidelity;
|
|||
#[cfg(test)]
|
||||
mod tests_frame_export;
|
||||
#[cfg(test)]
|
||||
mod tests_keylight;
|
||||
#[cfg(test)]
|
||||
mod tests_lottie;
|
||||
#[cfg(test)]
|
||||
mod tests_m137;
|
||||
|
|
@ -951,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"),
|
||||
|
|
|
|||
|
|
@ -75,11 +75,13 @@ impl Session {
|
|||
self.footage.set_cache_budget(b.media);
|
||||
}
|
||||
|
||||
/// Re-read the system's memory (desktop frontends call this every few seconds) and re-apply
|
||||
/// the cache budgets when the reading changes them. Returns the effective budgets.
|
||||
/// Take the latest reading of the system's memory and start the next one in the background
|
||||
/// (desktop frontends call this every few seconds; it never waits for the system), then
|
||||
/// re-apply the cache budgets when the reading changes them. Returns the effective budgets.
|
||||
pub fn memory_tick(&mut self) -> crate::prefs::CacheBudgets {
|
||||
let before = self.prefs.cache_budgets(self.sys_memory);
|
||||
self.sys_memory = crate::sysinfo::memory().or(self.sys_memory);
|
||||
self.sys_memory = self.memory_watch.latest().or(self.sys_memory);
|
||||
self.memory_watch.poll();
|
||||
let after = self.prefs.cache_budgets(self.sys_memory);
|
||||
if after != before {
|
||||
self.apply_cache_budgets();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@
|
|||
//! Pure Rust and dependency-free: the values come from the operating system's own reporting
|
||||
//! (`/proc/meminfo` on Linux, `sysctl`/`vm_stat` on macOS, `wmic`/PowerShell on Windows). Every
|
||||
//! query may fail (sandboxes, the web): callers treat `None` as "unknown" and keep their
|
||||
//! configured budgets.
|
||||
//! configured budgets. The periodic reading behind the cache budgets runs on a background
|
||||
//! thread ([`MemoryWatch`]): on Windows it starts PowerShell, which takes about a second.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
|
|
@ -23,10 +27,51 @@ impl SysMemory {
|
|||
}
|
||||
}
|
||||
|
||||
/// The system's memory, read in the background: [`MemoryWatch::poll`] starts a reading (unless
|
||||
/// one is running) and [`MemoryWatch::latest`] returns the last one without waiting.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct MemoryWatch {
|
||||
latest: Arc<Mutex<Option<SysMemory>>>,
|
||||
busy: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl MemoryWatch {
|
||||
/// Start a new reading in the background (no threads on the web: read inline, it is
|
||||
/// unknown there anyway).
|
||||
pub fn poll(&self) {
|
||||
if self.busy.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
let (latest, busy) = (self.latest.clone(), self.busy.clone());
|
||||
let read = move || {
|
||||
if let Some(m) = memory() {
|
||||
*latest.lock().unwrap_or_else(PoisonError::into_inner) = Some(m);
|
||||
}
|
||||
busy.store(false, Ordering::Release);
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if std::thread::Builder::new().name("ec-memory".into()).spawn(read).is_err() {
|
||||
self.busy.store(false, Ordering::Release);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
read();
|
||||
}
|
||||
|
||||
/// The last reading (`None` until one finished).
|
||||
pub fn latest(&self) -> Option<SysMemory> {
|
||||
*self.latest.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[allow(dead_code)]
|
||||
fn run(cmd: &str, args: &[&str]) -> Option<String> {
|
||||
let out = std::process::Command::new(cmd).args(args).output().ok()?;
|
||||
let mut c = std::process::Command::new(cmd);
|
||||
c.args(args);
|
||||
// A console program started from the windowed app would flash a console window.
|
||||
#[cfg(target_os = "windows")]
|
||||
std::os::windows::process::CommandExt::creation_flags(&mut c, 0x0800_0000); // CREATE_NO_WINDOW
|
||||
let out = c.output().ok()?;
|
||||
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).to_string())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,135 @@
|
|||
//! Key Light end to end: a green-screen plate (uneven screen, a subject with green spill and a
|
||||
//! soft edge) keyed through the commands an agent uses: `effect.apply` by its After Effects name,
|
||||
//! `effect.pickColor` on the screen (the effect's input, as the eyedropper does) and a render.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::render::{FootageSource, RenderOpts};
|
||||
|
||||
const W: u32 = 96;
|
||||
const H: u32 = 64;
|
||||
const SKIN: [f32; 3] = [0.86, 0.62, 0.52];
|
||||
|
||||
/// The plate's straight colour and alpha (opaque everywhere) at a pixel.
|
||||
fn plate(x: u32, y: u32) -> [f32; 3] {
|
||||
// Screen: a slightly uneven, noisy green.
|
||||
let noise = (((x * 7919 + y * 104_729) % 97) as f32 / 97.0 - 0.5) * 0.02;
|
||||
let screen = [0.18 + noise, 0.66 + (x as f32 / W as f32 - 0.5) * 0.04 + noise, 0.30 - noise];
|
||||
let r = ((x as f32 + 0.5 - 48.0).powi(2) + (y as f32 + 0.5 - 32.0).powi(2)).sqrt();
|
||||
// Subject: skin, with green spill in its outer 3 px and a 3 px soft edge.
|
||||
let mut subject = SKIN;
|
||||
if r > 13.0 {
|
||||
subject[1] += 0.12;
|
||||
}
|
||||
let a = ((19.0 - r) / 3.0).clamp(0.0, 1.0);
|
||||
[0, 1, 2].map(|i| subject[i] * a + screen[i] * (1.0 - a))
|
||||
}
|
||||
|
||||
struct Plate;
|
||||
|
||||
impl FootageSource for Plate {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
let mut img = Image::new(W, H);
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let c = plate(x, y);
|
||||
img.set(x, y, [c[0], c[1], c[2], 1.0]);
|
||||
}
|
||||
}
|
||||
Some(Arc::new(img))
|
||||
}
|
||||
}
|
||||
|
||||
struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
Ok(Footage {
|
||||
path: path.into(),
|
||||
kind: FootageKind::Still,
|
||||
width: W,
|
||||
height: H,
|
||||
has_video: true,
|
||||
frame_rate: FrameRate::from_f64(24.0),
|
||||
duration: Tick::ZERO,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn keyed() -> (Session, ItemId) {
|
||||
let mut s = Session { footage: Arc::new(Plate), importer: Some(Arc::new(Probe)), ..Default::default() };
|
||||
let item = s.execute("file.import", json!({"paths": ["/plates/greenscreen.png"]})).unwrap()["items"][0].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Key", "width": W, "height": H, "frameRate": 24, "duration": 1})).unwrap();
|
||||
let c = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": item})).unwrap();
|
||||
// After Effects' name finds it; MCP's add_effect / list_effects go through the same lookup.
|
||||
let r = s.execute_checked("effect.apply", json!({"effect": "Keylight (1.2)"})).unwrap();
|
||||
assert_eq!(r["effect"], "ec.keying.keylight");
|
||||
(s, c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_light_removes_a_green_screen() {
|
||||
let (mut s, c) = keyed();
|
||||
let steps = s.history.undo.len();
|
||||
// The default screen colour (pure green) already keys the screen, so the shown frame is
|
||||
// transparent there; the eyedropper samples the effect's input instead.
|
||||
let picked = s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": 6, "y": 6, "average": true})).unwrap();
|
||||
let col: Vec<f64> = picked["color"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
let want = plate(6, 6);
|
||||
assert!((0..3).all(|i| (col[i] - want[i] as f64).abs() < 0.02), "{col:?} vs {want:?}");
|
||||
let tree = s.execute("layer.tree", json!({})).unwrap().to_string();
|
||||
assert!(tree.contains("Screen Colour"), "{tree}");
|
||||
// One undo step.
|
||||
assert_eq!(s.history.undo.len(), steps + 1);
|
||||
// Clean the matte as one does in Screen Matte view: Clip Black for the uneven screen, Clip
|
||||
// White for the spill's partial transparency.
|
||||
for (path, v) in [("effects/#1/screenMatte/clipBlack", 10), ("effects/#1/screenMatte/clipWhite", 85)] {
|
||||
s.execute_checked("prop.set", json!({"path": path, "value": v})).unwrap();
|
||||
}
|
||||
|
||||
let img = s.render(c, Tick::ZERO, RenderOpts::default());
|
||||
let px = |x, y| img.get(x, y);
|
||||
// The whole screen goes (corners, both ends of the gradient, noise).
|
||||
for (x, y) in [(1, 1), (94, 1), (1, 62), (94, 62), (20, 32), (76, 32)] {
|
||||
assert!(px(x, y)[3] < 0.03, "screen at {x},{y}: {:?}", px(x, y));
|
||||
}
|
||||
// The subject stays, with its colour.
|
||||
let fg = px(48, 32);
|
||||
assert!(fg[3] > 0.99 && (0..3).all(|i| (fg[i] - SKIN[i]).abs() < 0.02), "{fg:?}");
|
||||
// Spill on the subject's edge is suppressed: no green cast left.
|
||||
let sp = px(48, 32 - 14);
|
||||
assert!(sp[3] > 0.99 && sp[1] <= 0.5 * (sp[0] + sp[2]) + 0.01, "spill {sp:?}");
|
||||
// The soft edge keeps partial alpha, and its recovered colour has no green fringe.
|
||||
let e = px(48, 32 - 18);
|
||||
assert!(e[3] > 0.15 && e[3] < 0.85, "edge {e:?}");
|
||||
let a = e[3];
|
||||
assert!(e[1] / a <= 0.5 * (e[0] + e[2]) / a + 0.02, "edge fringe {e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_color_rejects_bad_targets_and_screen_matte_view_shows_the_key() {
|
||||
let (mut s, c) = keyed();
|
||||
// Not a colour parameter / outside the layer.
|
||||
assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenGain", "x": 6, "y": 6})).is_err());
|
||||
assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": -50, "y": 6})).is_err());
|
||||
assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": 1e300, "y": -1e300, "average": true})).is_err());
|
||||
assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour"})).is_err());
|
||||
s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": 90, "y": 60})).unwrap();
|
||||
// View ▸ Screen Matte: black screen (once Clip Black takes the uneven screen), white subject.
|
||||
for (path, v) in [("effects/#1/view", 4), ("effects/#1/screenMatte/clipBlack", 25)] {
|
||||
s.execute_checked("prop.set", json!({"path": path, "value": v})).unwrap();
|
||||
}
|
||||
let img = s.render(c, Tick::ZERO, RenderOpts::default());
|
||||
assert!(img.get(2, 2)[0] < 0.03 && img.get(48, 32)[0] > 0.97, "{:?} {:?}", img.get(2, 2), img.get(48, 32));
|
||||
// The Effects & Presets / list_effects search finds it by After Effects' name too.
|
||||
let list = s.execute("effect.list", json!({"filter": "Keylight (1.2)"})).unwrap();
|
||||
assert_eq!(list.as_array().map(|a| a.len()), Some(1), "{list}");
|
||||
assert_eq!(list[0]["id"], "ec.keying.keylight");
|
||||
}
|
||||
|
|
@ -863,3 +863,194 @@ fn new_comp_from_selection_survives_hostile_sequence_numbers() {
|
|||
s.state.project_selection = vec![a, b];
|
||||
assert!(s.execute("file.newCompFromSelection", json!({"single": true, "dimensionsFrom": 99})).is_err());
|
||||
}
|
||||
|
||||
fn key_times(s: &Session, l: u64) -> Vec<f64> {
|
||||
layer(s, l).props.prop("transform/opacity").unwrap().keys.iter().map(|k| (k.time.seconds() * 30.0).round() / 30.0).collect()
|
||||
}
|
||||
|
||||
fn opacity_keys(s: &mut Session, l: u64, times: &[f64]) {
|
||||
for (i, t) in times.iter().enumerate() {
|
||||
s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": t, "value": i as f64 * 10.0})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_key_past_another_keeps_it() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
opacity_keys(&mut s, a, &[1.0, 2.0]);
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
|
||||
// One drag, frame by frame, from 1 s over the 2 s key to 3 s.
|
||||
for _ in 0..60 {
|
||||
s.execute("keys.move", json!({"delta": 1.0 / 30.0, "merge": "key-drag"})).unwrap();
|
||||
}
|
||||
assert_eq!(key_times(&s, a), vec![2.0, 3.0], "the 2 s key passed over is still there");
|
||||
// Dropped exactly on a key: it replaces it, as in After Effects.
|
||||
s.history.merge_key = None;
|
||||
s.execute("keys.move", json!({"delta": -1.0, "merge": "key-drag-2"})).unwrap();
|
||||
assert_eq!(key_times(&s, a), vec![2.0]);
|
||||
// The whole drag was one undo step.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(key_times(&s, a), vec![1.0, 2.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_of_a_stretched_layer_move_with_the_pointer() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
opacity_keys(&mut s, a, &[0.0, 1.0]);
|
||||
s.execute_checked("layer.timeStretch", json!({"layers": [a], "percent": 200})).unwrap();
|
||||
assert_eq!(layer(&s, a).stretch, 200.0);
|
||||
let comp_time = |s: &Session, i: usize| {
|
||||
let l = layer(s, a);
|
||||
l.comp_time(l.props.prop("transform/opacity").unwrap().keys[i].time).seconds()
|
||||
};
|
||||
let before = comp_time(&s, 1);
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
|
||||
s.execute("keys.move", json!({"delta": 0.5})).unwrap();
|
||||
assert!((comp_time(&s, 1) - (before + 0.5)).abs() < 1.0 / 30.0, "{} → {}", before, comp_time(&s, 1));
|
||||
s.execute("keys.nudge", json!({"frames": 3})).unwrap();
|
||||
assert!((comp_time(&s, 1) - (before + 0.6)).abs() < 1.0 / 60.0, "three comp frames");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
opacity_keys(&mut s, a, &[0.0, 1.0]);
|
||||
let k = |t: f64| json!({"layer": a, "path": "transform/opacity", "time": t});
|
||||
s.execute("keys.select", json!({"keys": [k(0.0)]})).unwrap();
|
||||
s.execute("keys.select", json!({"keys": [k(1.0)], "toggle": true})).unwrap();
|
||||
assert_eq!(s.state.selected_keys.len(), 2);
|
||||
s.execute("keys.select", json!({"keys": [k(0.0)], "toggle": true})).unwrap();
|
||||
assert_eq!(s.state.selected_keys.len(), 1, "toggled out");
|
||||
// Hold off returns an eased key to Bezier.
|
||||
s.execute("keys.easyEase", json!({})).unwrap();
|
||||
s.execute("keys.toggleHold", json!({})).unwrap();
|
||||
s.execute("keys.toggleHold", json!({})).unwrap();
|
||||
let kf = layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].clone();
|
||||
assert_eq!(kf.out_interp, effectcraft_keyframe::Interp::Bezier);
|
||||
// Locked: its keys can't be selected.
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "lock", "value": true})).unwrap();
|
||||
s.execute("keys.select", json!({"keys": [k(0.0)]})).unwrap();
|
||||
assert!(s.state.selected_keys.is_empty());
|
||||
s.execute("keys.selectAll", json!({"layers": [a]})).unwrap();
|
||||
assert!(s.state.selected_keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
opacity_keys(&mut s, a, &[0.0, 1.0, 2.0]);
|
||||
s.execute("keys.selectAll", json!({"layers": [a]})).unwrap();
|
||||
// Squeezed onto one frame on the way, then back: the three keys survive.
|
||||
for k in [0.5, 0.01, 0.0001, 1.0] {
|
||||
s.execute("keys.transform", json!({"timeScale": k, "timeAnchor": 0.0, "merge": "box", "fromStart": true})).unwrap();
|
||||
}
|
||||
assert_eq!(key_times(&s, a), vec![0.0, 1.0, 2.0]);
|
||||
// A slow move in tiny steps still arrives (each step is the whole offset).
|
||||
s.history.merge_key = None;
|
||||
for i in 1..=30 {
|
||||
s.execute("keys.transform", json!({"timeOffset": i as f64 * 0.1 / 30.0, "merge": "move", "fromStart": true})).unwrap();
|
||||
}
|
||||
assert_eq!(key_times(&s, a), vec![0.1, 1.1, 2.1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_equal_previous_and_following_keyframes() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
for (t, v) in [(0.0, 20.0), (1.0, 50.0), (2.0, 20.0), (3.0, 70.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let pick = |s: &mut Session, t: f64| s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": t}]})).unwrap();
|
||||
let times = |s: &Session| {
|
||||
let mut v: Vec<f64> = s.state.selected_keys.iter().map(|k| k.time.seconds()).collect();
|
||||
v.sort_by(f64::total_cmp);
|
||||
v
|
||||
};
|
||||
pick(&mut s, 0.0);
|
||||
s.execute("keys.selectEqual", json!({})).unwrap();
|
||||
assert_eq!(times(&s), vec![0.0, 2.0]);
|
||||
pick(&mut s, 2.0);
|
||||
s.execute("keys.selectPrevious", json!({})).unwrap();
|
||||
assert_eq!(times(&s), vec![0.0, 1.0, 2.0]);
|
||||
pick(&mut s, 1.0);
|
||||
s.execute("keys.selectFollowing", json!({})).unwrap();
|
||||
assert_eq!(times(&s), vec![1.0, 2.0, 3.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_from_several_layers_paste_in_order_and_paste_reversed_respects_stretch() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
opacity_keys(&mut s, a, &[0.0, 1.0]);
|
||||
s.execute("prop.addKey", json!({"layer": b, "path": "transform/opacity", "time": 0.0, "value": 77})).unwrap();
|
||||
let c = solid(&mut s, "#0000ff");
|
||||
let d = solid(&mut s, "#ffffff");
|
||||
// Copy A's and B's Opacity keys, paste onto C and D: A → C, B → D (in order).
|
||||
s.execute("keys.selectAll", json!({"layers": [a, b]})).unwrap();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
s.execute("time.set", json!({"time": 2.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [c, d]})).unwrap();
|
||||
s.execute("keys.paste", json!({})).unwrap();
|
||||
assert_eq!(key_times(&s, c), vec![2.0, 3.0], "A's two keys");
|
||||
assert_eq!(key_times(&s, d), vec![2.0], "B's one key");
|
||||
// Paste Reversed onto a layer stretched to 200 %: the reversed keys land 2 s apart there too.
|
||||
let e = solid(&mut s, "#808080");
|
||||
s.execute_checked("layer.timeStretch", json!({"layers": [e], "percent": 200})).unwrap();
|
||||
s.execute("keys.selectAll", json!({"layers": [a]})).unwrap();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
s.execute("time.set", json!({"time": 0.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [e]})).unwrap();
|
||||
s.execute("edit.pasteReversedKeyframes", json!({})).unwrap();
|
||||
let pr = layer(&s, e).props.prop("transform/opacity").unwrap().clone();
|
||||
let l = layer(&s, e);
|
||||
let ct: Vec<f64> = pr.keys.iter().map(|k| l.comp_time(k.time).seconds()).collect();
|
||||
let vals: Vec<f64> = pr.keys.iter().map(|k| k.value.as_f64()).collect();
|
||||
assert!(ct.len() == 2 && (ct[1] - ct[0] - 1.0).abs() < 1e-6, "keys 1 s apart in comp time: {ct:?}");
|
||||
assert_eq!(vals, vec![10.0, 0.0], "reversed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn j_and_k_stop_at_keys_markers_and_the_work_area() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
opacity_keys(&mut s, a, &[2.0]);
|
||||
s.execute("comp.workArea", json!({"start": 1.0, "end": 3.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [a]})).unwrap();
|
||||
let mut stops = vec![];
|
||||
for _ in 0..4 {
|
||||
s.execute("time.nextKey", json!({})).unwrap();
|
||||
stops.push((s.time().seconds() * 30.0).round() / 30.0);
|
||||
}
|
||||
let last = (3.0 * 30.0 - 1.0) / 30.0;
|
||||
assert_eq!(stops, vec![1.0, 2.0, (last * 30.0f64).round() / 30.0, (last * 30.0f64).round() / 30.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn j_k_and_select_all_use_only_the_properties_the_timeline_shows() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
opacity_keys(&mut s, a, &[1.0]);
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/rotation", "time": 2.0, "value": 45})).unwrap();
|
||||
let op = layer(&s, a).props.prop("transform/opacity").unwrap().uid;
|
||||
// Only Opacity is revealed: K skips the hidden Rotation key at 2 s, stopping at 1 s and then
|
||||
// at the work area's end.
|
||||
let visible = json!([{"layer": a, "prop": op}]);
|
||||
s.execute_checked("time.nextKey", json!({"visible": visible})).unwrap();
|
||||
assert_eq!(s.time().seconds(), 1.0);
|
||||
s.execute_checked("time.nextKey", json!({"visible": visible})).unwrap();
|
||||
assert!(s.time().seconds() > 3.9, "work area end, not the hidden key: {}", s.time().seconds());
|
||||
// Without `visible` (agents), every key of the layers counts, as before.
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("time.nextKey", json!({})).unwrap();
|
||||
assert_eq!(s.time().seconds(), 2.0);
|
||||
// Select All Keyframes: the shown properties' keys only.
|
||||
s.execute_checked("keys.selectAll", json!({"visible": visible})).unwrap();
|
||||
assert_eq!(s.state.selected_keys.len(), 1);
|
||||
assert_eq!(s.state.selected_keys[0].prop, op);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
@ -464,3 +464,25 @@ fn open_recent_increment_and_revert() {
|
|||
assert_eq!(autosave::increment_path("/x/v1.ecproj", |p| p.ends_with("v1 2.ecproj")), "/x/v1 3.ecproj");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_tick_never_waits_for_the_system() {
|
||||
let mut s = Session::default();
|
||||
let t0 = std::time::Instant::now();
|
||||
s.memory_tick();
|
||||
s.memory_tick();
|
||||
// Reading the system's memory starts PowerShell on Windows (about a second): the UI thread
|
||||
// only takes the last reading.
|
||||
assert!(t0.elapsed() < std::time::Duration::from_millis(200), "{:?}", t0.elapsed());
|
||||
if crate::sysinfo::memory().is_some() {
|
||||
let mut got = None;
|
||||
for _ in 0..200 {
|
||||
got = s.memory_watch.latest();
|
||||
if got.is_some() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
assert!(got.is_some_and(|m| m.total > 0), "the reading arrives in the background");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,6 +196,15 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
if crate::panels::unsaved::guard(app, id, ¶ms) {
|
||||
return Ok(json!({"dialog": "unsavedChanges"}));
|
||||
}
|
||||
// J / K and Select All Keyframes act on what the Timeline shows (its revealed properties).
|
||||
if matches!(id, "time.nextKey" | "time.previousKey" | "keys.selectAll")
|
||||
&& ["visible", "layers", "layer", "prop"].iter().all(|k| params.get(*k).is_none())
|
||||
&& let Some(c) = app.session.active_comp_arc()
|
||||
{
|
||||
let mut p = if params.is_object() { params.clone() } else { json!({}) };
|
||||
p["visible"] = json!(crate::panels::timeline::visible_props(app, &c));
|
||||
return run_engine(app, ctx, id, p);
|
||||
}
|
||||
// New Camera/Light and Camera/Light Settings without parameters open their dialogs.
|
||||
if crate::panels::dialogs_3d::route(app, id, ¶ms)? {
|
||||
return Ok(Value::Null);
|
||||
|
|
@ -497,10 +506,32 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
}
|
||||
|
||||
/// Run an engine command and perform the frontend events it emitted right away.
|
||||
/// Commands that fill the internal clipboard.
|
||||
const COPY_COMMANDS: &[&str] =
|
||||
&["edit.copy", "edit.cut", "edit.copyWithPropertyLinks", "edit.copyWithRelativePropertyLinks", "edit.copyExpressionOnly", "keys.copy", "effect.copy"];
|
||||
|
||||
/// A line for the system clipboard naming what EffectCraft copied. The system clipboard must
|
||||
/// hold something: with nothing on it, the windowing layer sends no paste event for Ctrl+V.
|
||||
fn clipboard_note(s: &effectcraft_engine::Session) -> String {
|
||||
let st = &s.state;
|
||||
let n = |n: usize, one: &str, many: &str| format!("EffectCraft: {n} {}", if n == 1 { one } else { many });
|
||||
if st.clip_is_keys && !st.key_clipboard.is_empty() {
|
||||
n(st.key_clipboard.iter().map(|c| c.keys.len()).sum(), "keyframe", "keyframes")
|
||||
} else if !st.effect_clipboard.is_empty() {
|
||||
n(st.effect_clipboard.len(), "effect", "effects")
|
||||
} else if st.link_clipboard.is_some() {
|
||||
"EffectCraft: property links".into()
|
||||
} else {
|
||||
n(st.clipboard.len(), "layer", "layers")
|
||||
}
|
||||
}
|
||||
|
||||
fn run_engine(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
|
||||
let r = app.session.execute(id, params).map_err(|e| e.to_string());
|
||||
if let Err(e) = &r {
|
||||
app.ui.status = e.clone();
|
||||
} else if COPY_COMMANDS.contains(&id) && app.session.state.text_edit.is_none() {
|
||||
ctx.copy_text(clipboard_note(&app.session));
|
||||
}
|
||||
let events = app.session.drain_events();
|
||||
for ev in events {
|
||||
|
|
@ -1217,7 +1248,13 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
if ctx.egui_wants_keyboard_input() {
|
||||
return;
|
||||
}
|
||||
// Text editing in the viewer takes the clipboard events itself.
|
||||
let clipboard = app.session.state.text_edit.is_none();
|
||||
let events: Vec<(egui::Key, egui::Modifiers)> = ctx.input(|i| {
|
||||
// The windowing layer turns Ctrl+C / Ctrl+X / Ctrl+V into clipboard events instead of
|
||||
// key presses: map them back to the keys (with the modifiers held) so Edit ▸ Copy, Cut,
|
||||
// Paste and their variants (Ctrl+Alt+C…) run.
|
||||
let held = if i.modifiers.command { i.modifiers } else { egui::Modifiers::COMMAND };
|
||||
i.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
|
|
@ -1226,6 +1263,9 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
{
|
||||
Some((*key, *modifiers))
|
||||
}
|
||||
egui::Event::Copy if clipboard => Some((egui::Key::C, held)),
|
||||
egui::Event::Cut if clipboard => Some((egui::Key::X, held)),
|
||||
egui::Event::Paste(_) if clipboard => Some((egui::Key::V, held)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
|
|
|
|||
|
|
@ -1098,7 +1098,13 @@ pub fn viewer_hook(
|
|||
app.ui.fx_pick = None;
|
||||
return;
|
||||
};
|
||||
if pick.kind == "color" {
|
||||
// Keyers pick from their input (the shown frame is already keyed): `effect.pickColor`.
|
||||
let keyer = (pick.kind == "color")
|
||||
.then(|| layer.effects()?.groups().find(|g| g.find(pick.prop).is_some()))
|
||||
.flatten()
|
||||
.filter(|g| effectcraft_engine::effects::find(&g.match_id).is_some_and(|s| s.category == "Keying"))
|
||||
.map(|g| g.uid);
|
||||
if pick.kind == "color" && keyer.is_none() {
|
||||
// GPU frames: read the shown frame back for sampling.
|
||||
app.viewer_pixels();
|
||||
}
|
||||
|
|
@ -1113,6 +1119,7 @@ pub fn viewer_hook(
|
|||
let c = map.to_comp(hp);
|
||||
app.pointer_comp = Some([c[0] as f32, c[1] as f32]);
|
||||
if pick.kind == "color"
|
||||
&& keyer.is_none()
|
||||
&& let Some(img) = &app.viewer_image
|
||||
&& let Some(s) = fw::sample_frame(img, map.comp, c)
|
||||
{
|
||||
|
|
@ -1129,6 +1136,15 @@ pub fn viewer_hook(
|
|||
if let Some(lp) = fw::comp_to_layer(&l2c(ectx, &layer), c) {
|
||||
actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [lp[0], lp[1]]})));
|
||||
}
|
||||
} else if let Some(fx) = keyer {
|
||||
// Ctrl/Cmd+click averages the 5 × 5 pixels around the point.
|
||||
if let Some(lp) = fw::comp_to_layer(&l2c(ectx, &layer), c) {
|
||||
let average = ui.input(|i| i.modifiers.command);
|
||||
actions.push((
|
||||
"effect.pickColor".into(),
|
||||
json!({"layer": pick.layer, "effect": fx, "prop": pick.prop, "x": lp[0], "y": lp[1], "average": average}),
|
||||
));
|
||||
}
|
||||
} else if let Some(img) = &app.viewer_image
|
||||
&& let Some(s) = fw::sample_frame(img, map.comp, c)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -323,7 +323,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let mut y = list.min.y - scroll;
|
||||
let mut apply: Option<Apply> = None;
|
||||
let reg = effectcraft_engine::effects::all();
|
||||
let keep = |e: &effectcraft_engine::effects::EffectSpec| depth_keeps(&view.depth, e) && (query.is_empty() || e.name.to_lowercase().contains(&query));
|
||||
let keep =
|
||||
|e: &effectcraft_engine::effects::EffectSpec| depth_keeps(&view.depth, e) && (query.is_empty() || effectcraft_engine::effects::name_matches(e, &query));
|
||||
let lookup = |id: &str| reg.iter().find(|e| e.id == id);
|
||||
|
||||
// Favorites and Recently Used.
|
||||
|
|
|
|||
|
|
@ -195,6 +195,23 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
|||
],
|
||||
)
|
||||
}
|
||||
// Double-click a keyframe (or its context menu ▸ Edit Value…): the property's value at
|
||||
// that key, like After Effects' value dialog.
|
||||
"keys.set" if !has(p, &["value", "newTime"]) => {
|
||||
let l = p.get("layer").and_then(Value::as_u64).and_then(|l| comp?.layer(effectcraft_engine::project::LayerId(l)));
|
||||
let pr = l.and_then(|l| l.props.find(p.get("prop").and_then(Value::as_u64)?));
|
||||
let t = p.get("time").and_then(Value::as_f64).map(effectcraft_engine::time::Tick::from_seconds_f64);
|
||||
let Some((pr, t)) = pr.zip(t) else { return false };
|
||||
let Some(k) = pr.keys.iter().min_by_key(|k| (k.time.0 - t.0).abs()) else { return false };
|
||||
use effectcraft_engine::keyframe::Value as KV;
|
||||
let fields = match &k.value {
|
||||
KV::Scalar(v) => vec![Field::num("value", &pr.name, *v)],
|
||||
KV::Vec2(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])],
|
||||
KV::Vec3(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
|
||||
_ => return false,
|
||||
};
|
||||
(pr.name.clone(), fields)
|
||||
}
|
||||
"layer.setTransform" if !has(p, &["value"]) => {
|
||||
let prop = p.get("prop").and_then(Value::as_str).unwrap_or("position");
|
||||
let cur = layer.and_then(|l| l.transform()).and_then(|tr| tr.get(if prop == "anchorPoint" { "anchor" } else { prop })).map(|pr| pr.value_at(lt));
|
||||
|
|
|
|||
|
|
@ -63,6 +63,22 @@ struct Curve<'a> {
|
|||
}
|
||||
|
||||
/// Properties shown: the selected ones, or every animated property of the selected layers.
|
||||
/// Whether the speed graph shows: Edit Speed Graph, or Auto-Select Graph Type with only spatial
|
||||
/// properties shown (Position, Anchor Point… move along a path: their speed is what to edit).
|
||||
fn speed_graph(app: &EffectcraftApp, comp: &Comp) -> bool {
|
||||
match app.ui.timeline.graph_mode.as_str() {
|
||||
"speed" => true,
|
||||
"value" => false,
|
||||
_ => {
|
||||
let props = shown_props(app, comp);
|
||||
!props.is_empty()
|
||||
&& props.iter().all(|(lid, uid)| {
|
||||
comp.layer(*lid).and_then(|l| l.props.find(*uid)).is_some_and(|pr| pr.spatial && matches!(pr.value, Value::Vec2(_) | Value::Vec3(_)))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shown_props(app: &EffectcraftApp, comp: &Comp) -> Vec<(LayerId, u64)> {
|
||||
let st = &app.session.state;
|
||||
let numeric = |pr: &Property| !pr.value.components().is_empty() && pr.value.interpolates();
|
||||
|
|
@ -179,7 +195,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
|||
let bg = ui.interact(plot, egui::Id::new("graph-bg"), Sense::click_and_drag());
|
||||
p.rect_filled(area, 0.0, Color32::from_rgb(0x1d, 0x1d, 0x1d));
|
||||
app.auto.add("timeline.graph", plot, &app.ui.timeline.graph_mode.clone());
|
||||
let speed = app.ui.timeline.graph_mode == "speed";
|
||||
let speed = speed_graph(app, comp);
|
||||
let cs = curves(app, comp, ectx, tm, plot, speed);
|
||||
|
||||
// Vertical range: auto-zoom to the curves + handles, or the stored manual range.
|
||||
|
|
@ -272,7 +288,6 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
|||
super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, false, actions);
|
||||
// Keys and handles.
|
||||
let mut key_screens: Vec<(serde_json::Value, Pos2)> = vec![];
|
||||
let fr = comp.frame_rate;
|
||||
for c in &cs {
|
||||
let (lid, uid, d) = (c.layer.id, c.prop.uid, c.dim);
|
||||
for k in &c.keys {
|
||||
|
|
@ -323,28 +338,46 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
|||
let kr = Rect::from_center_size(kp, vec2(11.0, 11.0));
|
||||
let kresp = ui.interact(kr, egui::Id::new(("gkey", uid, d, k.idx)), Sense::click_and_drag());
|
||||
app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name);
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
if (kresp.clicked() || kresp.drag_started()) && (!k.sel || shift) {
|
||||
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "add": shift})));
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
let key = &c.prop.keys[k.idx];
|
||||
if kresp.clicked() && mods.command {
|
||||
// Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off (as in the Timeline).
|
||||
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()]})));
|
||||
if mods.alt {
|
||||
actions.push(("keys.toggleHold".into(), json!({})));
|
||||
} else {
|
||||
let linear = key.in_interp == Interp::Linear && key.out_interp == Interp::Linear;
|
||||
actions.push(("keys.interpolation".into(), json!({"interpolation": if linear { "autoBezier" } else { "linear" }})));
|
||||
}
|
||||
} else if kresp.clicked() && mods.shift {
|
||||
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "toggle": true})));
|
||||
} else if (kresp.clicked() || kresp.drag_started()) && !k.sel {
|
||||
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "add": mods.shift})));
|
||||
}
|
||||
// A drag moves every selected key, by how far the pointer went from where the button
|
||||
// went down (the key doesn't jump under the pointer); Shift keeps it to the time or
|
||||
// the value axis, whichever moved more.
|
||||
if kresp.drag_started()
|
||||
&& let Some(o) = ui.input(|i| i.pointer.press_origin())
|
||||
{
|
||||
ctx.data_mut(|dd| dd.insert_temp(key_grab_id(), KeyGrab { press: o, t: k.t }));
|
||||
}
|
||||
if kresp.dragged()
|
||||
&& let Some(pt) = kresp.interact_pointer_pos()
|
||||
&& let Some(g) = ctx.data(|dd| dd.get_temp::<KeyGrab>(key_grab_id()))
|
||||
{
|
||||
ctx.data_mut(|dd| dd.insert_temp(drag_id, true));
|
||||
let merge = format!("gkey-{uid}-{}", k.idx);
|
||||
let (dx, dy) = (pt.x - g.press.x, pt.y - g.press.y);
|
||||
let (time_ok, value_ok) = if mods.shift { (dx.abs() >= dy.abs(), dy.abs() > dx.abs()) } else { (true, true) };
|
||||
// Graph Editor ▸ Snap: to the current time and other keys.
|
||||
let others: Vec<f64> = all_key_times.iter().filter(|(u, i, _)| !(*u == uid && *i == k.idx)).map(|x| x.2).collect();
|
||||
let ct = super::graph_tools::snap_time(app, tm, tm.t(pt.x).max(0.0), &others);
|
||||
let nt = c.layer.layer_time(fr.snap_nearest(Tick::from_seconds_f64(ct)));
|
||||
let mut params = json!({"layer": lid.0, "prop": uid, "time": k.time.seconds(), "newTime": nt.seconds(), "merge": merge});
|
||||
if !speed && c.editable {
|
||||
let mut comps = c.prop.keys[k.idx].value.components();
|
||||
if d < comps.len() {
|
||||
comps[d] = vmap(pt.y);
|
||||
}
|
||||
params["value"] = if comps.len() == 1 { json!(comps[0]) } else { json!(comps) };
|
||||
let t = if time_ok { super::graph_tools::snap_time(app, tm, (g.t + (tm.t(pt.x) - tm.t(g.press.x))).max(0.0), &others) } else { g.t };
|
||||
let mut params = json!({"timeOffset": t - g.t, "merge": "graph-key", "fromStart": true});
|
||||
if value_ok && !speed && c.editable {
|
||||
params["valueOffset"] = json!(vmap(pt.y) - vmap(g.press.y));
|
||||
params["dim"] = json!(d);
|
||||
}
|
||||
actions.push(("keys.set".into(), params));
|
||||
actions.push(("keys.transform".into(), params));
|
||||
}
|
||||
if kresp.drag_stopped() {
|
||||
ctx.data_mut(|dd| dd.insert_temp(drag_id, false));
|
||||
|
|
@ -391,11 +424,25 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
|||
let y = bar.center().y;
|
||||
let mut x = bar.min.x + 8.0;
|
||||
let tl = &app.ui.timeline;
|
||||
let (is_value, show_sel, auto_on) = (tl.graph_mode != "speed", tl.graph_show_selected, tl.graph_auto_zoom);
|
||||
if text_button(app, ui, p, &mut x, y, "Value", is_value, "valueGraph", "Edit Value Graph") {
|
||||
let (auto_type, show_sel, auto_on) = (tl.graph_mode == "auto", tl.graph_show_selected, tl.graph_auto_zoom);
|
||||
if text_button(
|
||||
app,
|
||||
ui,
|
||||
p,
|
||||
&mut x,
|
||||
y,
|
||||
"Auto",
|
||||
auto_type,
|
||||
"autoSelectGraphType",
|
||||
"Auto-Select Graph Type (speed graph for spatial properties, else value graph)",
|
||||
) {
|
||||
app.ui.timeline.graph_mode = if auto_type { if speed { "speed" } else { "value" } } else { "auto" }.into();
|
||||
}
|
||||
// Choosing a graph type turns Auto-Select off, as in After Effects.
|
||||
if text_button(app, ui, p, &mut x, y, "Value", !speed, "valueGraph", "Edit Value Graph") {
|
||||
app.ui.timeline.graph_mode = "value".into();
|
||||
}
|
||||
if text_button(app, ui, p, &mut x, y, "Speed", !is_value, "speedGraph", "Edit Speed Graph") {
|
||||
if text_button(app, ui, p, &mut x, y, "Speed", speed, "speedGraph", "Edit Speed Graph") {
|
||||
app.ui.timeline.graph_mode = "speed".into();
|
||||
}
|
||||
x += 6.0;
|
||||
|
|
@ -500,3 +547,14 @@ fn fmt(v: f64, step: f64) -> String {
|
|||
let s = format!("{v:.dec$}");
|
||||
if s.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { s.trim_start_matches('-').to_string() } else { s }
|
||||
}
|
||||
|
||||
/// A Graph Editor key drag: where the button went down and the grabbed key's comp time then.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct KeyGrab {
|
||||
press: Pos2,
|
||||
t: f64,
|
||||
}
|
||||
|
||||
fn key_grab_id() -> egui::Id {
|
||||
egui::Id::new("graph-key-grab")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ struct BoxDrag {
|
|||
handle: usize,
|
||||
/// Fixed opposite point (comp s, value).
|
||||
anchor: [f64; 2],
|
||||
/// Pointer data position of the previous frame.
|
||||
/// Where the drag started (comp s, value): the pointer for a move, the handle for a scale.
|
||||
/// Every step sends the whole transform since then (`keys.transform {fromStart}`).
|
||||
last: [f64; 2],
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +135,7 @@ pub(crate) fn transform_box(
|
|||
});
|
||||
}
|
||||
let Some(resp) = resp_any else { return };
|
||||
let Some(mut st) = ctx.data(|d| d.get_temp::<BoxDrag>(box_id())) else { return };
|
||||
let Some(st) = ctx.data(|d| d.get_temp::<BoxDrag>(box_id())) else { return };
|
||||
if (st.handle == 8) == handles {
|
||||
return;
|
||||
}
|
||||
|
|
@ -142,21 +143,20 @@ pub(crate) fn transform_box(
|
|||
&& let Some(pt) = resp.interact_pointer_pos()
|
||||
{
|
||||
let cur = data(pt);
|
||||
let mut params = json!({"merge": "graph-box"});
|
||||
let mut params = json!({"merge": "graph-box", "fromStart": true});
|
||||
if st.handle == 8 {
|
||||
params["timeOffset"] = json!(cur[0] - st.last[0]);
|
||||
if value_ok {
|
||||
params["valueOffset"] = json!(cur[1] - st.last[1]);
|
||||
}
|
||||
st.last = cur;
|
||||
} else {
|
||||
let (time_axis, value_axis) = match st.handle {
|
||||
4 | 6 => (false, true),
|
||||
5 | 7 => (true, false),
|
||||
_ => (true, true),
|
||||
};
|
||||
// Scale relative to where the handle is now (incremental, so merged steps compose).
|
||||
let now = corner(st.handle);
|
||||
// Scale relative to where the handle was when the drag started.
|
||||
let now = st.last;
|
||||
if time_axis && (now[0] - st.anchor[0]).abs() > 1e-6 {
|
||||
let k = (cur[0] - st.anchor[0]) / (now[0] - st.anchor[0]);
|
||||
if k > 0.01 {
|
||||
|
|
@ -250,6 +250,8 @@ struct AltScale {
|
|||
anchor: f64,
|
||||
/// The dragged end is the group's last key (else its first).
|
||||
end_is_max: bool,
|
||||
/// The group's span when the drag started (set on its first step).
|
||||
span: Option<f64>,
|
||||
}
|
||||
|
||||
fn alt_id() -> egui::Id {
|
||||
|
|
@ -272,9 +274,9 @@ fn sel_range(app: &EffectcraftApp, comp: &Comp) -> Option<(f64, f64)> {
|
|||
pub(crate) fn alt_scale_begin(app: &EffectcraftApp, ctx: &egui::Context, comp: &Comp, key_ct: f64) -> bool {
|
||||
let Some((lo, hi)) = sel_range(app, comp) else { return false };
|
||||
let st = if (key_ct - hi).abs() < 1e-6 {
|
||||
AltScale { anchor: lo, end_is_max: true }
|
||||
AltScale { anchor: lo, end_is_max: true, span: None }
|
||||
} else if (key_ct - lo).abs() < 1e-6 {
|
||||
AltScale { anchor: hi, end_is_max: false }
|
||||
AltScale { anchor: hi, end_is_max: false, span: None }
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
|
|
@ -293,16 +295,16 @@ pub(crate) fn alt_scale_update(app: &EffectcraftApp, ctx: &egui::Context, comp:
|
|||
let (Some((lo, hi)), Some(px)) = (sel_range(app, comp), ctx.input(|i| i.pointer.latest_pos())) else { return };
|
||||
let fr = comp.frame_rate;
|
||||
let target = fr.snap_nearest(effectcraft_engine::time::Tick::from_seconds_f64(tm.t(px.x).max(0.0))).seconds();
|
||||
let end = if st.end_is_max { hi } else { lo };
|
||||
let span = end - st.anchor;
|
||||
// The group's span when the drag started: every step sends the whole scale since then.
|
||||
let span = st.span.unwrap_or((if st.end_is_max { hi } else { lo }) - st.anchor);
|
||||
if st.span.is_none() {
|
||||
ctx.data_mut(|d| d.insert_temp(alt_id(), AltScale { span: Some(span), ..st }));
|
||||
}
|
||||
let want = target - st.anchor;
|
||||
if span.abs() < 1e-9 || want.abs() < 1e-9 || want.signum() != span.signum() {
|
||||
return;
|
||||
}
|
||||
let k = want / span;
|
||||
if (k - 1.0).abs() > 1e-6 {
|
||||
actions.push(("keys.transform".into(), json!({"timeScale": k, "timeAnchor": st.anchor, "merge": "key-alt-scale"})));
|
||||
}
|
||||
actions.push(("keys.transform".into(), json!({"timeScale": want / span, "timeAnchor": st.anchor, "merge": "key-alt-scale", "fromStart": true})));
|
||||
}
|
||||
|
||||
/// An Alt-drag group scale is in progress (the plain key move stands aside).
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -101,13 +101,25 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
p.text(pos2(x0, ly), Align2::LEFT_CENTER, format!("Render: {ms:.0} ms • UI {:.0} fps{rq}", app.fps), Tokens::ui(11.5), t.text_faint);
|
||||
}
|
||||
if !app.session.state.selected_keys.is_empty() {
|
||||
p.text(
|
||||
pos2(x0, ly + 18.0),
|
||||
Align2::LEFT_CENTER,
|
||||
format!("{} keyframes selected", app.session.state.selected_keys.len()),
|
||||
Tokens::ui(11.5),
|
||||
t.text_dim,
|
||||
);
|
||||
// One key: its property, time and value (as After Effects' Info panel shows them).
|
||||
let sel = &app.session.state.selected_keys;
|
||||
let one = match sel.as_slice() {
|
||||
[k] => c.layer(k.layer).and_then(|l| {
|
||||
let pr = l.props.find(k.prop)?;
|
||||
let key = pr.keys.iter().find(|x| x.time == k.time)?;
|
||||
let tc = crate::panels::timecode(&app.session, &c, l.comp_time(k.time));
|
||||
let v = match &key.value {
|
||||
effectcraft_engine::keyframe::Value::Scalar(v) => format!("{v:.2}"),
|
||||
effectcraft_engine::keyframe::Value::Vec2(v) => format!("{:.1}, {:.1}", v[0], v[1]),
|
||||
effectcraft_engine::keyframe::Value::Vec3(v) => format!("{:.1}, {:.1}, {:.1}", v[0], v[1], v[2]),
|
||||
_ => String::new(),
|
||||
};
|
||||
Some(format!("{} {tc} {v}", pr.name).trim_end().to_string())
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let line = one.unwrap_or_else(|| format!("{} keyframes selected", sel.len()));
|
||||
p.text(pos2(x0, ly + 18.0), Align2::LEFT_CENTER, line, Tokens::ui(11.5), t.text_dim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//! bar, layer duration bars, keyframes, the CTI, and the value graph editor.
|
||||
|
||||
use effectcraft_engine::color::BlendMode;
|
||||
use effectcraft_engine::keyframe::Value;
|
||||
use effectcraft_engine::keyframe::{Interp, Value};
|
||||
use effectcraft_engine::project::{Comp, GroupKind, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, PropGroup, Property};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use effectcraft_engine::time::Tick;
|
||||
|
|
@ -515,6 +515,18 @@ fn reveal_targets(kind: &str) -> Option<(&'static str, &'static [&'static str])>
|
|||
})
|
||||
}
|
||||
|
||||
/// The properties the Timeline shows (revealed rows), as `{layer, prop}`: J / K and Select All
|
||||
/// Keyframes act on these, as in After Effects.
|
||||
pub(crate) fn visible_props(app: &EffectcraftApp, comp: &Comp) -> Vec<serde_json::Value> {
|
||||
build_rows(app, comp)
|
||||
.iter()
|
||||
.filter_map(|r| match r.kind {
|
||||
RowKind::Prop { uid } => Some(json!({"layer": r.layer.0, "prop": uid})),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
|
||||
let tl = &app.ui.timeline;
|
||||
let search = tl.search.trim().to_lowercase();
|
||||
|
|
@ -779,6 +791,19 @@ fn layer_icon(l: &Layer, project: &effectcraft_engine::project::Project) -> Icon
|
|||
fn layer_drag_id() -> egui::Id {
|
||||
egui::Id::new("tl-layer-drag")
|
||||
}
|
||||
|
||||
/// A keyframe drag in progress: where the pointer started, the grabbed key's comp time
|
||||
/// (seconds) and how many frames the selected keys have moved so far.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct KeyDrag {
|
||||
origin_x: f32,
|
||||
anchor: f64,
|
||||
applied: i64,
|
||||
}
|
||||
|
||||
fn key_drag_id() -> egui::Id {
|
||||
egui::Id::new("tl-key-drag")
|
||||
}
|
||||
/// Set on the frame the reorder drag is released.
|
||||
fn layer_drop_id() -> egui::Id {
|
||||
egui::Id::new("tl-layer-drop")
|
||||
|
|
@ -1857,27 +1882,55 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
None if ks => t.keyframe_selected,
|
||||
None => t.keyframe,
|
||||
};
|
||||
icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol, Color32::from_black_alpha(200));
|
||||
if k.roving {
|
||||
// Roving keys: a small dot (their time follows their neighbours).
|
||||
gp.circle_filled(c, 2.5, kcol);
|
||||
} else {
|
||||
icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol, Color32::from_black_alpha(200));
|
||||
}
|
||||
let kr = Rect::from_center_size(c, vec2(12.0, 14.0));
|
||||
let kresp = ui.interact(kr, egui::Id::new(("key", uid, k.time.0)), Sense::click_and_drag());
|
||||
app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name));
|
||||
if kresp.clicked() || kresp.drag_started() {
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
if !ks || shift {
|
||||
actions.push((
|
||||
"keys.select".into(),
|
||||
json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}], "add": shift}),
|
||||
));
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
let this_key = json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]});
|
||||
if kresp.clicked() && mods.command {
|
||||
// Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off.
|
||||
actions.push(("keys.select".into(), this_key.clone()));
|
||||
if mods.alt {
|
||||
actions.push(("keys.toggleHold".into(), json!({})));
|
||||
} else {
|
||||
let linear = k.in_interp == Interp::Linear && k.out_interp == Interp::Linear;
|
||||
actions.push(("keys.interpolation".into(), json!({"interpolation": if linear { "autoBezier" } else { "linear" }})));
|
||||
}
|
||||
} else if kresp.clicked() && mods.shift {
|
||||
// Shift+click: in or out of the selection.
|
||||
let mut p = this_key.clone();
|
||||
p["toggle"] = json!(true);
|
||||
actions.push(("keys.select".into(), p));
|
||||
} else if (kresp.clicked() || kresp.drag_started()) && !ks {
|
||||
let mut p = this_key.clone();
|
||||
p["add"] = json!(mods.shift);
|
||||
actions.push(("keys.select".into(), p));
|
||||
}
|
||||
if kresp.double_clicked() {
|
||||
ui_actions.push(UiAct::SetTime(ct));
|
||||
// Edit the key's value (numeric properties), as After Effects does.
|
||||
if matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
|
||||
actions.push(("keys.set".into(), json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})));
|
||||
} else {
|
||||
ui_actions.push(UiAct::SetTime(ct));
|
||||
}
|
||||
}
|
||||
kresp.context_menu(|ui| {
|
||||
for (lbl, cmd, params) in [
|
||||
("Edit Value…", "keys.set", json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})),
|
||||
("-", "", json!({})),
|
||||
("Copy", "keys.copy", json!({})),
|
||||
("Paste", "keys.paste", json!({})),
|
||||
("-", "", json!({})),
|
||||
("Select Equal Keyframes", "keys.selectEqual", json!({})),
|
||||
("Select Previous Keyframes", "keys.selectPrevious", json!({})),
|
||||
("Select Following Keyframes", "keys.selectFollowing", json!({})),
|
||||
("-", "", json!({})),
|
||||
("Keyframe Interpolation…", "keys.interpolation", json!({})),
|
||||
("Keyframe Velocity…", "keys.velocity", json!({})),
|
||||
("Toggle Hold Keyframe", "keys.toggleHold", json!({})),
|
||||
|
|
@ -1918,6 +1971,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
if lbl == "Rove Across Time" && !prop.spatial {
|
||||
continue;
|
||||
}
|
||||
if lbl == "Edit Value…" && !matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
|
||||
continue;
|
||||
}
|
||||
if ui.button(lbl).clicked() {
|
||||
if !ks {
|
||||
actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]})));
|
||||
|
|
@ -1934,21 +1990,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
{
|
||||
super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds());
|
||||
}
|
||||
let alt_scaling = super::graph_tools::alt_scale_active(&ctx);
|
||||
if kresp.dragged() && !alt_scaling {
|
||||
let acc_id = egui::Id::new("key-drag-acc");
|
||||
let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
|
||||
acc += kresp.drag_delta().x;
|
||||
let frames = ((acc as f64 / pps) / fd).trunc() as i64;
|
||||
if frames != 0 {
|
||||
acc -= (frames as f64 * fd * pps) as f32;
|
||||
actions.push(("keys.move".into(), json!({"delta": frames as f64 * fd, "merge": "key-drag"})));
|
||||
}
|
||||
ctx.data_mut(|d| d.insert_temp(acc_id, acc));
|
||||
}
|
||||
if kresp.drag_stopped() {
|
||||
ctx.data_mut(|d| d.remove::<f32>(egui::Id::new("key-drag-acc")));
|
||||
ui_actions.push(UiAct::EndMerge);
|
||||
// A plain drag moves the selected keys. The timeline follows it (below): this
|
||||
// widget's id changes with the key's time, so its own drag would end after
|
||||
// the first frame of movement.
|
||||
// (From where the button went down: a drag starts a few pixels later.)
|
||||
if kresp.drag_started()
|
||||
&& !super::graph_tools::alt_scale_active(&ctx)
|
||||
&& let Some(p) = ctx.input(|i| i.pointer.press_origin())
|
||||
{
|
||||
ctx.data_mut(|d| d.insert_temp(key_drag_id(), KeyDrag { origin_x: p.x, anchor: ct.seconds(), applied: 0 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2031,6 +2081,49 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
ctx.data_mut(|d| d.remove::<Vec<u64>>(layer_drag_id()));
|
||||
}
|
||||
}
|
||||
// Keyframe drag: the selected keys move by whole frames with the pointer until it is
|
||||
// released (one undo step); Shift snaps the grabbed key to the current time, the work area
|
||||
// and composition markers.
|
||||
if let Some(mut kd) = ctx.data(|d| d.get_temp::<KeyDrag>(key_drag_id())) {
|
||||
let (down, ptr, shift) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos(), i.modifiers.shift));
|
||||
match ptr.filter(|_| down) {
|
||||
Some(ptr) => {
|
||||
let mut target = kd.anchor + (ptr.x - kd.origin_x) as f64 / pps;
|
||||
if shift {
|
||||
// The current time and whatever it snaps to (keys, layer ends, markers), but
|
||||
// not the moving keys themselves.
|
||||
let moving: Vec<f64> =
|
||||
app.session.state.selected_keys.iter().filter_map(|k| comp.layer(k.layer).map(|l| l.comp_time(k.time).seconds())).collect();
|
||||
let mut cands = snap_candidates(&comp, &rows);
|
||||
cands.retain(|c| !moving.iter().any(|m| (m - c).abs() < 1e-9));
|
||||
cands.push(time.seconds());
|
||||
target = snap_time(&cands, target, 8.0 / pps);
|
||||
}
|
||||
let total = ((target - kd.anchor) / fd).round() as i64;
|
||||
if total != kd.applied {
|
||||
actions.push(("keys.move".into(), json!({"delta": (total - kd.applied) as f64 * fd, "merge": "key-drag"})));
|
||||
kd.applied = total;
|
||||
ctx.data_mut(|d| d.insert_temp(key_drag_id(), kd));
|
||||
}
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
// Where the grabbed key is and how far the keys moved (After Effects shows this
|
||||
// in the Info panel).
|
||||
let at = Tick::from_seconds_f64(kd.anchor + kd.applied as f64 * fd);
|
||||
let note = format!("{} {:+} f", super::timecode(&app.session, &comp, at), kd.applied);
|
||||
let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tl-key-drag-tip")));
|
||||
let g = fg.layout_no_wrap(note, Tokens::ui(11.5), t.text);
|
||||
let r = Rect::from_min_size(ptr + vec2(14.0, 14.0), g.size() + vec2(10.0, 6.0));
|
||||
fg.rect_filled(r, 3.0, t.panel_bg);
|
||||
fg.rect_stroke(r, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
fg.galley(r.min + vec2(5.0, 3.0), g, t.text);
|
||||
app.ui.status = format!("Keyframe at {}", super::timecode(&app.session, &comp, at));
|
||||
}
|
||||
None => {
|
||||
ctx.data_mut(|d| d.remove::<KeyDrag>(key_drag_id()));
|
||||
ui_actions.push(UiAct::EndMerge);
|
||||
}
|
||||
}
|
||||
}
|
||||
if empty.drag_stopped()
|
||||
&& let Some(br) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::new("tl-box")))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -41,12 +41,13 @@ fn dropped_files(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Re-read the system's free memory every few seconds (cache budgets follow it).
|
||||
/// Re-read the system's free memory every 30 s (cache budgets follow it; on Windows each
|
||||
/// reading starts PowerShell in the background, so it isn't done more often).
|
||||
fn memory_watch(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
let id = egui::Id::new("prefs-memory-watch");
|
||||
let last = ctx.data(|d| d.get_temp::<f64>(id));
|
||||
if last.is_some_and(|t| now - t < 10.0) {
|
||||
if last.is_some_and(|t| now - t < 30.0) {
|
||||
return;
|
||||
}
|
||||
ctx.data_mut(|d| d.insert_temp(id, now));
|
||||
|
|
|
|||
|
|
@ -363,8 +363,9 @@ pub struct TimelineState {
|
|||
/// Properties / groups (uids) shown by the `props` reveal (Animation ▸ Reveal Properties…).
|
||||
#[serde(default)]
|
||||
pub reveal_props: BTreeSet<u64>,
|
||||
/// Graph Editor: `value` or `speed` graph.
|
||||
#[serde(default = "value_graph")]
|
||||
/// Graph Editor: `auto` (Auto-Select Graph Type, the default: the speed graph when every
|
||||
/// property shown is spatial, else the value graph), `value` or `speed`.
|
||||
#[serde(default = "auto_graph")]
|
||||
pub graph_mode: String,
|
||||
/// Show only the selected properties (else every animated property of the selected layers).
|
||||
#[serde(default = "yes")]
|
||||
|
|
@ -401,8 +402,8 @@ pub fn default_tl_columns() -> BTreeSet<String> {
|
|||
["av", "label", "num", "switches", "parent"].map(String::from).into_iter().collect()
|
||||
}
|
||||
|
||||
fn value_graph() -> String {
|
||||
"value".into()
|
||||
fn auto_graph() -> String {
|
||||
"auto".into()
|
||||
}
|
||||
fn yes() -> bool {
|
||||
true
|
||||
|
|
@ -423,7 +424,7 @@ impl Default for TimelineState {
|
|||
open_groups: BTreeSet::new(),
|
||||
reveal: vec![],
|
||||
reveal_props: BTreeSet::new(),
|
||||
graph_mode: value_graph(),
|
||||
graph_mode: auto_graph(),
|
||||
graph_show_selected: true,
|
||||
graph_auto_zoom: true,
|
||||
graph_range: None,
|
||||
|
|
|
|||
|
|
@ -165,3 +165,34 @@ fn crosshair_and_eyedropper_pick_from_the_viewer() {
|
|||
}
|
||||
assert_eq!(c[3], 1.0);
|
||||
}
|
||||
|
||||
/// Key Light's Screen Colour eyedropper picks from the effect's input: the shown frame is
|
||||
/// already keyed (the default screen colour takes most of a green), so sampling it would miss.
|
||||
#[test]
|
||||
fn keyer_eyedropper_picks_the_screen_from_the_effect_input() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Key", "width": 320, "height": 180, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let screen = s.execute("layer.newSolid", json!({"name": "Screen", "color": "#30b050"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let fx = s.execute("effect.apply", json!({"layers": [screen], "effect": "Key Light"})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
let prop = layer(&s, screen).effects().unwrap().groups().find(|g| g.uid == fx).and_then(|g| g.get("screenColour")).map(|p| p.uid).unwrap();
|
||||
s.state.selected_layers = vec![LayerId(screen)];
|
||||
let mut app = EffectcraftApp::new(s);
|
||||
app.show_panel(PanelKind::EffectControls);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app);
|
||||
settle(&mut h);
|
||||
h.state_mut().ui.fx_pick = Some(FxPick { kind: "color".into(), layer: screen, prop, name: "Screen Colour".into() });
|
||||
h.step();
|
||||
let pos = effectcraft_ui_egui::panels::viewer::comp_to_screen(&h.ctx, [160.0, 90.0]).unwrap();
|
||||
click(&mut h, pos);
|
||||
assert!(h.state().ui.fx_pick.is_none(), "the pick is used up");
|
||||
let v = layer(&h.state().session, screen).effects().unwrap().find(prop).unwrap().value.clone();
|
||||
let KV::Color(c) = v else { panic!("{v:?}") };
|
||||
let want = [0x30 as f64 / 255.0, 0xb0 as f64 / 255.0, 0x50 as f64 / 255.0];
|
||||
for k in 0..3 {
|
||||
assert!((c[k] - want[k]).abs() < 0.01, "{c:?} vs {want:?}");
|
||||
}
|
||||
// The picked screen keys the whole solid out.
|
||||
let s = &h.state().session;
|
||||
let img = s.render(s.active_comp_id().unwrap(), s.time(), Default::default());
|
||||
assert!(img.get(160, 90)[3] < 0.01, "{:?}", img.get(160, 90));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,238 @@
|
|||
//! Headless checks for Timeline keyframes as a person uses them: dragging keys over many frames
|
||||
//! in one gesture (the drag used to end after the first frame), Shift-snapping, and Ctrl+C /
|
||||
//! Ctrl+V, which the windowing layer delivers as clipboard events rather than key presses.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::LayerId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use egui::{Event, Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
/// A 4 s, 30 fps comp with a Box layer whose Opacity has keys at 0 s and 1 s, revealed in the
|
||||
/// Timeline; returns the harness, the layer and the property uid.
|
||||
fn harness() -> (Harness<'static, EffectcraftApp>, LayerId, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Keys", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap();
|
||||
let id = s.active_comp().unwrap().layers[0].id;
|
||||
for (t, v) in [(0.0, 0.0), (1.0, 100.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let uid = s.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
s.execute("layer.select", json!({"layers": [id.0]})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.opacity", json!({})).unwrap();
|
||||
h.run_steps(4);
|
||||
(h, id, uid)
|
||||
}
|
||||
|
||||
fn key_times(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> Vec<f64> {
|
||||
let l = h.state().session.active_comp().unwrap().layer(id).unwrap().clone();
|
||||
l.props.prop("transform/opacity").unwrap().keys.iter().map(|k| (k.time.seconds() * 30.0).round() / 30.0).collect()
|
||||
}
|
||||
|
||||
/// Screen centres of the property's keys, left to right.
|
||||
fn keys(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Vec<Pos2> {
|
||||
let mut ks: Vec<Pos2> =
|
||||
h.state().auto.query(&format!("timeline.key.{uid}.")).iter().map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)).collect();
|
||||
ks.sort_by(|a, b| a.x.total_cmp(&b.x));
|
||||
ks
|
||||
}
|
||||
|
||||
/// Press at `from`, move to `to` in small steps (a real drag), release.
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: egui::Modifiers) {
|
||||
h.input_mut().events.push(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers });
|
||||
h.step();
|
||||
for i in 1..=24 {
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 24.0)));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Default::default()));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_drag_follows_the_pointer_for_its_whole_length() {
|
||||
let (mut h, id, uid) = harness();
|
||||
let ks = keys(&h, uid);
|
||||
assert_eq!(ks.len(), 2);
|
||||
let px_per_s = ks[1].x - ks[0].x;
|
||||
// Drag the 1 s key to where 2.5 s is: many frames, one gesture.
|
||||
let to = pos2(ks[1].x + px_per_s * 1.5, ks[1].y);
|
||||
drag(&mut h, ks[1], to, Default::default());
|
||||
let ts = key_times(&h, id);
|
||||
assert!((ts[1] - 2.5).abs() <= 2.0 / 30.0, "the key follows the whole drag: {ts:?}");
|
||||
let steps = h.state().session.history.undo.iter().filter(|(l, _)| l == "Move Keyframes").count();
|
||||
assert_eq!(steps, 1, "one undo step per drag");
|
||||
// Shift snaps it to the current time indicator (at 0.5 s), from a few pixels away.
|
||||
h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(0.5));
|
||||
h.run_steps(2);
|
||||
let ks = keys(&h, uid);
|
||||
let near = pos2(ks[0].x + px_per_s * 0.5 + 4.0, ks[1].y);
|
||||
drag(&mut h, ks[1], near, egui::Modifiers { shift: true, ..Default::default() });
|
||||
assert_eq!(key_times(&h, id)[1], 0.5, "snapped to the current time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_and_ctrl_v_copy_and_paste_keys_at_the_current_time() {
|
||||
let (mut h, id, uid) = harness();
|
||||
let ks = keys(&h, uid);
|
||||
// Click the 1 s key to select it, Ctrl+C (a clipboard event, not a key press).
|
||||
h.input_mut().events.push(Event::PointerMoved(ks[1]));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: ks[1], button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: ks[1], button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.state.selected_keys.len(), 1);
|
||||
h.input_mut().events.push(Event::Copy);
|
||||
h.step();
|
||||
let copied: Vec<String> = h
|
||||
.output()
|
||||
.platform_output
|
||||
.commands
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
egui::OutputCommand::CopyText(t) => Some(t.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(copied, ["EffectCraft: 1 keyframe"], "the system clipboard is filled, so Ctrl+V sends a paste event");
|
||||
assert!(h.state().session.state.clip_is_keys);
|
||||
// Move to 3 s, Ctrl+V: the key lands there.
|
||||
h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(3.0));
|
||||
h.input_mut().events.push(Event::Paste("EffectCraft: 1 keyframe".into()));
|
||||
h.run_steps(2);
|
||||
assert_eq!(key_times(&h, id), vec![0.0, 1.0, 3.0]);
|
||||
}
|
||||
|
||||
fn click_with(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: egui::Modifiers) {
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Default::default()));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_toggles_and_ctrl_click_switches_interpolation() {
|
||||
let (mut h, id, uid) = harness();
|
||||
let ks = keys(&h, uid);
|
||||
let shift = egui::Modifiers { shift: true, ..Default::default() };
|
||||
click_with(&mut h, ks[0], Default::default());
|
||||
click_with(&mut h, ks[1], shift);
|
||||
assert_eq!(h.state().session.state.selected_keys.len(), 2, "Shift+click adds");
|
||||
click_with(&mut h, ks[1], shift);
|
||||
assert_eq!(h.state().session.state.selected_keys.len(), 1, "and takes out again");
|
||||
let key =
|
||||
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().keys[0].clone();
|
||||
// Ctrl+click: Linear → Auto Bezier → Linear.
|
||||
click_with(&mut h, ks[0], egui::Modifiers::COMMAND);
|
||||
assert!(key(&h).auto_bezier, "Auto Bezier");
|
||||
click_with(&mut h, ks[0], egui::Modifiers::COMMAND);
|
||||
assert_eq!(key(&h).out_interp, effectcraft_engine::keyframe::Interp::Linear);
|
||||
// Ctrl+Alt+click: Hold.
|
||||
click_with(&mut h, ks[0], egui::Modifiers { alt: true, ..egui::Modifiers::COMMAND });
|
||||
assert_eq!(key(&h).out_interp, effectcraft_engine::keyframe::Interp::Hold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_editor_drags_move_every_selected_key_and_shift_keeps_an_axis() {
|
||||
let (mut h, id, uid) = harness();
|
||||
let s = &mut h.state_mut().session;
|
||||
s.execute("prop.select", json!({"layer": id.0, "prop": uid})).unwrap();
|
||||
s.execute("keys.selectAll", json!({})).unwrap();
|
||||
h.state_mut().ui.timeline.graph_editor = true;
|
||||
h.run_steps(4);
|
||||
let at = |h: &Harness<'_, EffectcraftApp>, i: usize| {
|
||||
let e = h.state().auto.find(&format!("timeline.graph.key.{uid}.0.{i}")).unwrap_or_else(|| panic!("no graph key {i}")).clone();
|
||||
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
|
||||
};
|
||||
let (k0, k1) = (at(&h, 0), at(&h, 1));
|
||||
let px_per_s = k1.x - k0.x;
|
||||
let values = |h: &Harness<'_, EffectcraftApp>| -> Vec<f64> {
|
||||
h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().keys.iter().map(|k| k.value.as_f64()).collect()
|
||||
};
|
||||
// Shift: only along time, both keys, by 0.5 s.
|
||||
drag(&mut h, k1, pos2(k1.x + px_per_s * 0.5, k1.y - 6.0), egui::Modifiers { shift: true, ..Default::default() });
|
||||
assert_eq!(key_times(&h, id), vec![0.5, 1.5], "both selected keys moved");
|
||||
assert_eq!(values(&h), vec![0.0, 100.0], "Shift kept the values");
|
||||
let undo = h.state().session.history.undo.iter().filter(|(l, _)| l == "Transform Keyframes").count();
|
||||
assert_eq!(undo, 1, "one undo step per drag");
|
||||
// Without Shift the values move too (both keys, by the same amount).
|
||||
h.run_steps(2);
|
||||
let k1 = at(&h, 1);
|
||||
drag(&mut h, k1, pos2(k1.x, k1.y + 20.0), Default::default());
|
||||
let v = values(&h);
|
||||
assert!(v[0] < 0.0 && (v[1] - 100.0 - v[0]).abs() < 1e-6, "{v:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_a_key_to_edit_its_value() {
|
||||
let (mut h, id, uid) = harness();
|
||||
let k1 = keys(&h, uid)[1];
|
||||
h.input_mut().events.push(Event::PointerMoved(k1));
|
||||
h.step();
|
||||
// Both clicks inside egui's double-click time.
|
||||
for _ in 0..2 {
|
||||
h.input_mut().events.push(Event::PointerButton { pos: k1, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.input_mut().events.push(Event::PointerButton { pos: k1, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
}
|
||||
h.step();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form), "the value dialog opened");
|
||||
h.run_steps(2);
|
||||
assert!(h.state().auto.find("form.field.value").is_some(), "one Opacity field");
|
||||
// OK keeps the key where it is (the dialog edits the value only).
|
||||
let ok = h.state().auto.find("form.ok").expect("form.ok").clone();
|
||||
let p = pos2(ok.rect[0] + ok.rect[2] / 2.0, ok.rect[1] + ok.rect[3] / 2.0);
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
assert!(h.state().dialog.is_none());
|
||||
let k = h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().keys[1].clone();
|
||||
assert_eq!((k.time.seconds(), k.value.as_f64()), (1.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn k_follows_the_revealed_properties_and_auto_select_picks_the_speed_graph_for_position() {
|
||||
let (mut h, id, _) = harness();
|
||||
// A Rotation key at 2 s on a property that isn't revealed: K skips it.
|
||||
h.state_mut().session.execute("prop.addKey", json!({"layer": id.0, "path": "transform/rotation", "time": 2.0, "value": 45})).unwrap();
|
||||
h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.0));
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.push(Event::Key { key: egui::Key::K, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
assert!(h.state().session.time().seconds() > 3.9, "past the hidden key to the work area end: {}", h.state().session.time().seconds());
|
||||
// Auto-Select Graph Type: Position shows its speed (one curve), Opacity its value.
|
||||
let s = &mut h.state_mut().session;
|
||||
for (t, v) in [(0.0, [40.0, 90.0]), (1.0, [280.0, 90.0])] {
|
||||
s.execute("prop.addKey", json!({"layer": id.0, "path": "transform/position", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let pos = s.active_comp().unwrap().layer(id).unwrap().props.prop("transform/position").unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": id.0, "prop": pos})).unwrap();
|
||||
h.state_mut().ui.timeline.graph_editor = true;
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().ui.timeline.graph_mode, "auto");
|
||||
assert!(h.state().auto.find(&format!("timeline.graph.key.{pos}.0.0")).is_some());
|
||||
assert!(h.state().auto.find(&format!("timeline.graph.key.{pos}.1.0")).is_none(), "speed graph: one curve");
|
||||
assert!(h.state().auto.find("timeline.graph.autoSelectGraphType").is_some());
|
||||
h.state_mut().ui.timeline.graph_mode = "value".into();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().auto.find(&format!("timeline.graph.key.{pos}.1.0")).is_some(), "value graph: X and Y");
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -94,6 +94,7 @@ Short recipes; every step is one tool call.
|
|||
| Title card | `execute_command comp.new {...}` → `execute_command layer.newText {"text":"Hi","size":120}` → `render_frame {"time":0}` |
|
||||
| Animate | `add_keyframe {"layer":2,"path":"transform/scale","keys":[{"time":0,"value":[0,0]},{"time":0.5,"value":[100,100]}],"interpolation":"easyEase"}` |
|
||||
| Effect with settings | `list_effects {"filter":"glow"}` → `add_effect {"layer":2,"effect":"Glow","values":{"threshold":40,"radius":30}}` → `set_property {"layer":2,"path":"effects/#1/intensity","value":2}` |
|
||||
| Key a green screen | `add_effect {"layer":2,"effect":"Keylight (1.2)"}` → `execute_command effect.pickColor {"layer":2,"effect":1,"param":"screenColour","x":20,"y":20,"average":true}` (a screen pixel, layer space; samples the effect's input) → `set_property {"layer":2,"path":"effects/#1/screenMatte/clipBlack","value":10}` → `render_frame {"transparent":true}` |
|
||||
| Drive with an expression | `set_property {"layer":2,"path":"transform/rotation","expression":"time*90"}` |
|
||||
| Inspect a layer | `get_layer {"layer":2,"flat":true}` (every property with its `path`, value, key count) |
|
||||
| Check the result | `render_frame {"time":1.5,"max_side":640}`; in bridge mode `screenshot {"panel":"Timeline"}` |
|
||||
|
|
@ -133,6 +134,14 @@ build real projects through these interfaces; they are worked examples of everyt
|
|||
(a string param that is exactly `"$N"` or `"$N.key"` is replaced; `$2.path` is the trim's path).
|
||||
* **Expressions**: `set_property`/`get_property` replies carry `evaluated` (what renders) next to
|
||||
`value` (the keyframed value) and `expressionError` when the expression fails.
|
||||
* **Keyframes like a person**: `keys.select {keys, toggle: true}` (Shift+click), `keys.selectEqual`
|
||||
/ `selectPrevious` / `selectFollowing`, `keys.move {delta, merge}` (steps sharing a merge key are
|
||||
one drag: each applies to the keys as they were before it, so a key passed over survives) and
|
||||
`keys.transform {…, merge, fromStart: true}` (values are the whole transform since the drag
|
||||
started). Keys of locked layers can't be selected. Copy / paste: `keys.copy`, then `keys.paste`
|
||||
at the current time (keys copied from several layers go to as many selected layers in order).
|
||||
`time.nextKey` / `time.previousKey` / `keys.selectAll` take `visible: [{layer, prop}]` to act
|
||||
only on those properties (the Timeline passes its revealed ones, as J / K and Ctrl+Alt+A do).
|
||||
* **Easing with Keyframe Velocity**: select keys by path, `keys.select {"keys":[{"layer":"Ring","path":"contents/trim/end","time":0}]}`,
|
||||
then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`.
|
||||
* **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ Mocha and Cineware are third-party and not counted.
|
|||
| Blur & Sharpen | Camera-Shake Deblur, CC Radial Blur | Deblur substitutes aligned patches from sharper neighbouring frames. |
|
||||
| Audio | Compressor, Distortion, Gate | Applied in the mixdown like the other audio effects. |
|
||||
| Simulation | CC Hair, Particle Playground | Particle Playground: cannon, grid, layer exploder, layer map, gravity, repel, wall, persistent property mapper (no Particle Exploder, text particles or ephemeral mapper yet). |
|
||||
| Keying | Key Light | The full Keylight 1.2 control set under a generic name ("Keylight" is a vendor trademark); `lookup("Keylight (1.2)")` finds it. |
|
||||
| Keying | Key Light | The full Keylight 1.2 control set under a generic name ("Keylight" is a vendor trademark); `lookup("Keylight (1.2)")` and the Effects & Presets search ("keylight") find it. Keyers' colour eyedroppers sample the effect's input (`effect.pickColor`), so a screen that is already keyed can still be picked. |
|
||||
| Utility | Color Profile Converter | Our colour spaces and ACES; rendering intents (perceptual gamut compression, relative / absolute colorimetric, saturation). |
|
||||
| Matte | Mocha shape | Mocha's export format is not public: reads a documented JSON shape format instead. |
|
||||
|
||||
|
|
@ -392,6 +392,64 @@ Browser in the web app (landed in M13.10: browser storage and File System Access
|
|||
wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script
|
||||
`Socket` validates ports, and ScriptUI resource strings accept trailing array commas.
|
||||
|
||||
## Update: M5.9–M5.14 keyframes, M3.15 clipboard, M12.8 responsiveness
|
||||
|
||||
- **The app froze every 10 s on Windows** (M12.8): the cache budgets' memory reading started
|
||||
PowerShell on the UI thread and waited about a second for it. It runs on a background thread
|
||||
now, every 30 s.
|
||||
- **Ctrl+C / Ctrl+X / Ctrl+V** (M3.15) reach Edit ▸ Copy, Cut and Paste in the app (the window
|
||||
layer sends them as clipboard events, which the shortcut dispatcher ignored), and copying puts
|
||||
a line on the system clipboard so Ctrl+V always arrives.
|
||||
- **Timeline keyframes** (M5.9–M5.12): a drag follows the pointer to the end (it let go after a
|
||||
frame or two), moves every selected key as one undo step, never deletes keys it passes over,
|
||||
moves keys of time-stretched layers with the pointer, and Shift snaps to the current time,
|
||||
keys, layer ends and markers with a time / offset tooltip; Shift+click toggles selection,
|
||||
Ctrl+click switches Linear ↔ Auto Bezier, Ctrl+Alt+click toggles Hold; keys of locked layers
|
||||
can't be selected; Toggle Hold off restores Bezier; double-click edits a key's value; Select
|
||||
Equal / Previous / Following Keyframes; roving keys draw as dots.
|
||||
- **Graph Editor** (M5.11): a drag moves every selected key in time and value from where it was
|
||||
grabbed, Shift keeps one axis; the transform box and Alt-drag scaling never merge keys on the
|
||||
way and move them however slowly they are dragged; the same Shift / Ctrl / Ctrl+Alt clicks.
|
||||
- **Paste** (M5.13): keys copied from several layers paste onto as many layers in order; Paste
|
||||
Reversed Keyframes follows Paste's rules (stretch, target property, Hold-only properties).
|
||||
- **J / K and Info** (M5.14): J / K also stop at the work area; the Info panel shows a selected
|
||||
key's property, time and value.
|
||||
|
||||
Since (M5.15): J / K and Select All Keyframes use what the Timeline shows — the revealed
|
||||
properties' keys, layer and comp markers and the work area (`visible: [{layer, prop}]` on
|
||||
`time.nextKey` / `previousKey` / `keys.selectAll`; without it, agents get every property of the
|
||||
layers); the Graph Editor has Auto-Select Graph Type (the default: the speed graph when only
|
||||
spatial properties are shown, else the value graph; choosing Value or Speed turns it off). Graph
|
||||
Editor keys are small squares whatever their interpolation, as in After Effects.
|
||||
|
||||
Since (M13.30–M13.31): the Tools bar and About dialog carry the ArtCraft mark. The
|
||||
Home screen is laid out like After Effects' and covers the whole workspace. A left rail holds New
|
||||
Project / Open Project, the Home, Templates and Learn pages and, at its foot, the community
|
||||
links. The Home page has a "Welcome to EffectCraft" heading, quick-start tiles (New Composition,
|
||||
Open Demo Project, Import Footage, New from Template) and the recent projects as a filterable
|
||||
table with thumbnails and Name / Opened ("3 hours ago") / Size / Kind columns. New automation ids:
|
||||
`home.filter`, `home.templates`, `home.file.import`.
|
||||
|
||||
Since (M4.13): motion blur follows a collapsed precomp layer's own motion. Its nested layers are
|
||||
drawn with the precomp layer's transform at every sub-sample, and with the containing comp's
|
||||
shutter, as they are drawn into its frames. Shape and text layers whose content animates within
|
||||
the shutter (paths, Trim Paths, shape transforms, text animators) are drawn at Samples Per Frame
|
||||
times and averaged, as After Effects does for shape layers; content that holds still is drawn
|
||||
once.
|
||||
|
||||
Since (M3.15): the Timeline shows one tab per open comp, as in After Effects. Double-clicking a
|
||||
comp in the Project panel opens it as a new tab next to the comps already open; it no longer
|
||||
renames (Enter or the context menu still do). A tab's × closes that comp's Timeline, not the
|
||||
panel. A tab dragged over a tab strip shows an insertion mark between the tabs it will land
|
||||
between, and lands there; this reorders a group's own tabs too.
|
||||
|
||||
Since (M5.16): Key Light (After Effects' Keylight 1.2) is checked end to end on a green-screen
|
||||
plate with an uneven screen, spill and soft edges (`effect.apply "Keylight (1.2)"`, pick, Clip
|
||||
Black / White, render). The colour eyedroppers of Keying effects sample the effect's input
|
||||
rather than the keyed frame (Ctrl/Cmd+click averages 5 × 5 pixels); agents do the same with
|
||||
`effect.pickColor {effect, param, x, y, average?}` (layer pixels). Searching "keylight" in
|
||||
Effects & Presets, or `list_effects`, finds it.
|
||||
|
||||
## Update: M4.9–M4.11 nested comps and motion blur
|
||||
|
||||
- **Composition Settings ▸ Advanced ▸ Preserve frame rate when nested or in render queue** and
|
||||
|
|
@ -672,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.
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ Commands (CLI, MCP, control channel):
|
|||
sample rate and read back instead of decoding again
|
||||
- `memory.ramReservedGb`: the cache budgets together leave this much physical memory free
|
||||
- `memory.reduceCacheWhenLow`: cache budgets halve while the system is low on memory (checked
|
||||
every 10 s by the desktop app)
|
||||
every 30 s by the desktop app, on a background thread)
|
||||
- `video.enableOutput`, `video.device`, `video.outputDuringPlayback`, `video.mirrorOnMonitor`,
|
||||
`video.disableWhenBackground`: Video Preview, a second window (or full screen on the display
|
||||
it is on) showing the composition frame, Mercury Transmit-style
|
||||
|
|
|
|||
Loading…
Reference in New Issue