mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Gap 21: percentage guides and effect colour labels (#1095)
Guides can store a percent of the comp so they follow comp-size changes (View > Add Percentage Guide, Convert Guides to Percentage / Pixels); an effect instance has an optional colour label (effect.setLabel, Effect Controls header bar and Label menu). Both are serde-defaulted, so old projects load. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
470ea7a3af
commit
f11792827e
20 files changed
+301
-25
No files matched your search
@@ -22,6 +22,7 @@ fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: &
|
||||
let (w, h) = (effectcraft_project::limits::size_f64(r[2]), effectcraft_project::limits::size_f64(r[3]));
|
||||
s.edit(label, None, |proj, st| {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let (old_w, old_h) = (c.width, c.height);
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) {
|
||||
@@ -30,7 +31,8 @@ fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: &
|
||||
}
|
||||
}
|
||||
for g in &mut c.guides {
|
||||
g.position -= if g.vertical { x } else { y };
|
||||
let px = g.px(old_w, old_h) - if g.vertical { x } else { y };
|
||||
g.set_px(px, w, h);
|
||||
}
|
||||
st.region_of_interest = None;
|
||||
Ok(())
|
||||
|
||||
@@ -227,6 +227,25 @@ fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!(r))
|
||||
}
|
||||
|
||||
/// `effect.setLabel`: the effect's colour label (None clears it).
|
||||
fn set_label(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "effect.setLabel";
|
||||
let (cid, lid, uid) = find_fx(s, p, c)?;
|
||||
let lab = match p.get("label") {
|
||||
Some(Value::Number(n)) => n.as_u64().and_then(|i| effectcraft_color::Label::ALL.get(usize::try_from(i).ok()?).copied()),
|
||||
Some(Value::String(name)) => s.prefs.label_from_name(name),
|
||||
_ => None,
|
||||
}
|
||||
.ok_or_else(|| bad(c, "missing or unknown `label` (name or index 0-16; 0 / None clears it)"))?;
|
||||
s.edit("Effect Label", None, |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let g = l.props.find_group_mut(uid).ok_or_else(|| bad(c, "gone"))?;
|
||||
g.label = (lab != effectcraft_color::Label::None).then_some(lab);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = find_fx(s, p, "effect.reorder")?;
|
||||
let to = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("effect.reorder", "missing `index` (1-based)"))? as usize;
|
||||
@@ -482,6 +501,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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),
|
||||
cmd!("effect.toggle", "Toggle Effect", [], None, "{layer?, effect, value?}", has_layers, toggle),
|
||||
cmd!("effect.setLabel", "Effect Label", [], None, "{layer?, effect, label: name|index 0-16 (None clears)}", has_layers, set_label),
|
||||
cmd!("effect.reorder", "Reorder Effect", [], None, "{layer?, effect, index}", has_layers, reorder),
|
||||
cmd!("effect.duplicate", "Duplicate Effect", [], None, "{layer?, effect}", has_layers, duplicate),
|
||||
cmd!("effect.copy", "Copy Effects", [], None, "{layer?, effect? | effects?: [uid|name|index]} (default: the selected effects)", has_layers, copy),
|
||||
|
||||
@@ -20,15 +20,58 @@ fn add_guide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o => return Err(bad("view.addGuide", format!("orientation must be vertical or horizontal, not `{o}`"))),
|
||||
};
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let position = f_p(p, "position").unwrap_or(if vertical { comp.width as f64 / 2.0 } else { comp.height as f64 / 2.0 });
|
||||
let n = s.edit("Add Guide", None, |proj, _| {
|
||||
let percent = p.get("percent").and_then(Value::as_bool).unwrap_or(false);
|
||||
let default = if percent {
|
||||
50.0
|
||||
} else if vertical {
|
||||
comp.width as f64 / 2.0
|
||||
} else {
|
||||
comp.height as f64 / 2.0
|
||||
};
|
||||
let position = f_p(p, "position").filter(|v| v.is_finite()).unwrap_or(default);
|
||||
let n = s.edit(if percent { "Add Percentage Guide" } else { "Add Guide" }, None, |proj, _| {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
c.guides.push(Guide { vertical, position });
|
||||
c.guides.push(Guide { vertical, position, percent });
|
||||
Ok(c.guides.len())
|
||||
})?;
|
||||
Ok(json!({"guides": n}))
|
||||
}
|
||||
|
||||
/// View ▸ Convert Guides to Percentage / Pixels: every guide (or just `index`) changes unit
|
||||
/// without moving on the comp.
|
||||
fn convert_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let percent = match str_p(p, "to").unwrap_or("percent") {
|
||||
"percent" | "percentage" => true,
|
||||
"pixels" | "px" => false,
|
||||
o => return Err(bad("view.convertGuides", format!("`to` must be percent or pixels, not `{o}`"))),
|
||||
};
|
||||
let only = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
|
||||
let n = s.project.comp(cid).ok_or(EngineError::NoComp)?.guides.len();
|
||||
if only.is_some_and(|i| i >= n) {
|
||||
return Err(bad("view.convertGuides", format!("no guide {} (the comp has {n})", only.unwrap_or(0))));
|
||||
}
|
||||
s.edit(if percent { "Convert Guides to Percentage" } else { "Convert Guides to Pixels" }, None, |proj, _| {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let (w, h) = (c.width, c.height);
|
||||
for (i, g) in c.guides.iter_mut().enumerate() {
|
||||
if only.is_none_or(|o| o == i) {
|
||||
g.set_percent(percent, w, h);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"guides": n, "percent": percent}))
|
||||
}
|
||||
|
||||
fn add_percent_guide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.insert("percent".into(), json!(true));
|
||||
}
|
||||
add_guide(s, &q)
|
||||
}
|
||||
|
||||
fn clear_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
s.edit("Clear Guides", None, |proj, _| {
|
||||
@@ -128,7 +171,25 @@ macro_rules! res {
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("view.addGuide", "Add Guide...", ["View"], None, "{orientation?: vertical|horizontal, position? (comp px)}", has_comp, add_guide),
|
||||
cmd!(
|
||||
"view.addGuide",
|
||||
"Add Guide...",
|
||||
["View"],
|
||||
None,
|
||||
"{orientation?: vertical|horizontal, position? (comp px, or percent with percent: true), percent?: bool}",
|
||||
has_comp,
|
||||
add_guide
|
||||
),
|
||||
cmd!(
|
||||
"view.addPercentGuide",
|
||||
"Add Percentage Guide...",
|
||||
["View"],
|
||||
None,
|
||||
"{orientation?: vertical|horizontal, position? (percent of the comp)}",
|
||||
has_comp,
|
||||
add_percent_guide
|
||||
),
|
||||
cmd!("view.convertGuides", "Convert Guides to Percentage", ["View"], None, "{comp?, to?: percent|pixels, index?}", has_guides, convert_guides),
|
||||
cmd!("view.clearGuides", "Clear Guides", ["View"], None, "{comp?}", has_guides, clear_guides),
|
||||
cmd!("view.importGuides", "Import Guides...", ["View"], None, "{path}", has_comp, import_guides),
|
||||
cmd!("view.exportGuides", "Export Guides...", ["View"], None, "{path}", has_guides, export_guides),
|
||||
|
||||
@@ -194,8 +194,12 @@ fn guide_index(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project
|
||||
fn move_guide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, i) = guide_index(s, p, "view.moveGuide")?;
|
||||
let pos = f_p(p, "position").ok_or_else(|| bad("view.moveGuide", "missing `position` (comp px)"))?;
|
||||
let pos = if pos.is_finite() { pos } else { return Err(bad("view.moveGuide", "`position` must be a finite number")) };
|
||||
s.edit("Move Guide", merge_p(p), |proj, _| {
|
||||
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.guides[i].position = pos;
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let (w, h) = (c.width, c.height);
|
||||
let g = c.guides.get_mut(i).ok_or(EngineError::NoComp)?;
|
||||
g.set_px(pos, w, h);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!(pos))
|
||||
|
||||
@@ -1106,6 +1106,9 @@ View
|
||||
Snap to Guides | view.snapToGuides
|
||||
Lock Guides | view.lockGuides
|
||||
Add Guide... | view.addGuide
|
||||
Add Percentage Guide... | view.addPercentGuide
|
||||
Convert Guides to Percentage | view.convertGuides {"to":"percent"}
|
||||
Convert Guides to Pixels | view.convertGuides {"to":"pixels"}
|
||||
Clear Guides | view.clearGuides
|
||||
Import Guides... | view.importGuides
|
||||
Export Guides... | view.exportGuides
|
||||
|
||||
@@ -369,3 +369,35 @@ fn ocio_effect_warns_about_unsupported_config_transforms() {
|
||||
set(&mut s, "configFile", json!("/no/such/config.ocio"));
|
||||
assert!(warning(&mut s).as_str().is_some_and(|w| w.starts_with("Can't read the OCIO config")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_colour_labels_set_clear_undo_and_round_trip() {
|
||||
let (mut s, a, _) = comp_with_two_solids();
|
||||
let blur = apply(&mut s, a, "Gaussian Blur");
|
||||
let label = |s: &Session| layer(s, a).effects().unwrap().groups().next().unwrap().label;
|
||||
assert_eq!(label(&s), None);
|
||||
s.execute("effect.setLabel", json!({"layer": a, "effect": blur, "label": "Blue"})).unwrap();
|
||||
assert_eq!(label(&s), Some(effectcraft_color::Label::Blue));
|
||||
// Duplicates keep the label.
|
||||
s.execute("effect.duplicate", json!({"layer": a, "effect": blur})).unwrap();
|
||||
assert_eq!(layer(&s, a).effects().unwrap().groups().filter(|g| g.label.is_some()).count(), 2);
|
||||
s.undo();
|
||||
// It saves with the project and survives a reload.
|
||||
let json = s.project.to_file_json().unwrap();
|
||||
let back = effectcraft_project::Project::from_json(&json).unwrap();
|
||||
let l = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(a)).unwrap().clone();
|
||||
assert_eq!(l.effects().unwrap().groups().next().unwrap().label, Some(effectcraft_color::Label::Blue));
|
||||
// None (or index 0) clears it; the change undoes.
|
||||
s.execute("effect.setLabel", json!({"layer": a, "effect": blur, "label": 0})).unwrap();
|
||||
assert_eq!(label(&s), None);
|
||||
s.undo();
|
||||
assert_eq!(label(&s), Some(effectcraft_color::Label::Blue));
|
||||
assert!(s.execute("effect.setLabel", json!({"layer": a, "effect": blur, "label": "Chartreuse"})).is_err());
|
||||
assert!(s.execute("effect.setLabel", json!({"layer": a, "effect": blur, "label": 99})).is_err());
|
||||
// A project saved before labels existed has none.
|
||||
let old = json.replace("\"label\":\"Blue\"", "\"x\":0").replace("\"label\": \"Blue\"", "\"x\": 0");
|
||||
assert_ne!(old, json, "the replace removed the effect's label");
|
||||
let old = effectcraft_project::Project::from_json(&old).unwrap();
|
||||
let l = old.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(a)).unwrap().clone();
|
||||
assert_eq!(l.effects().unwrap().groups().next().unwrap().label, None);
|
||||
}
|
||||
@@ -1464,3 +1464,53 @@ fn interpret_footage_loop_from_form_float() {
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentage_guides_follow_the_comp_size() {
|
||||
let mut s = comp();
|
||||
s.execute("view.addGuide", json!({"orientation": "vertical", "position": 25, "percent": true})).unwrap();
|
||||
s.execute("view.addPercentGuide", json!({"orientation": "horizontal"})).unwrap();
|
||||
s.execute("view.addGuide", json!({"orientation": "vertical", "position": 100})).unwrap();
|
||||
let c = s.active_comp().unwrap().clone();
|
||||
assert!(c.guides[0].percent && c.guides[1].percent && !c.guides[2].percent);
|
||||
assert_eq!((c.guides[0].px(c.width, c.height), c.guides[1].px(c.width, c.height)), (160.0, 180.0));
|
||||
// A comp resize moves percentage guides with it and leaves pixel guides alone.
|
||||
s.execute("comp.settings", json!({"width": 1280, "height": 720})).unwrap();
|
||||
let c = s.active_comp().unwrap().clone();
|
||||
assert_eq!((c.guides[0].px(c.width, c.height), c.guides[1].px(c.width, c.height), c.guides[2].px(c.width, c.height)), (320.0, 360.0, 100.0));
|
||||
// Dragging keeps the unit: the position arrives in comp pixels.
|
||||
s.execute("view.moveGuide", json!({"index": 0, "position": 640})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().guides[0].position, 50.0);
|
||||
// Snapping sees the pixel position.
|
||||
let c = s.active_comp().unwrap().clone();
|
||||
assert!(c.guides.iter().any(|g| g.percent && g.vertical && g.px(c.width, c.height) == 640.0));
|
||||
// Converting keeps the guide where it is, and undoes in one step.
|
||||
s.execute("view.convertGuides", json!({"to": "pixels"})).unwrap();
|
||||
let g = s.active_comp().unwrap().guides.clone();
|
||||
assert!(g.iter().all(|g| !g.percent));
|
||||
assert_eq!((g[0].position, g[1].position), (640.0, 360.0));
|
||||
s.execute("view.convertGuides", json!({"to": "percent", "index": 2})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().guides[2].position, 100.0 / 1280.0 * 100.0);
|
||||
s.undo();
|
||||
assert!(!s.active_comp().unwrap().guides[2].percent);
|
||||
assert!(s.execute("view.convertGuides", json!({"to": "furlongs"})).is_err());
|
||||
assert!(s.execute("view.convertGuides", json!({"index": 9})).is_err());
|
||||
// A crop keeps percentage guides where they were on the image.
|
||||
s.execute("view.convertGuides", json!({"to": "percent"})).unwrap();
|
||||
s.execute("view.setRegionOfInterest", json!({"rect": [320, 0, 640, 720]})).unwrap();
|
||||
s.execute("comp.cropToRegionOfInterest", json!({})).unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
assert!((c.guides[0].px(c.width, c.height) - 320.0).abs() < 1e-9, "640 - 320 crop offset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_guides_load_as_pixel_guides_and_percent_round_trips() {
|
||||
let old: effectcraft_project::Guide = serde_json::from_str(r#"{"vertical":true,"position":12.5}"#).unwrap();
|
||||
assert!(!old.percent);
|
||||
assert!(!serde_json::to_string(&old).unwrap().contains("percent"), "pixel guides stay in the old shape");
|
||||
let g = effectcraft_project::Guide { vertical: false, position: 10.0, percent: true };
|
||||
let back: effectcraft_project::Guide = serde_json::from_str(&serde_json::to_string(&g).unwrap()).unwrap();
|
||||
assert_eq!(back, g);
|
||||
// A zero-sized comp never divides by zero.
|
||||
assert!(g.px(0, 0).is_finite());
|
||||
}
|
||||
@@ -335,11 +335,8 @@ fn targets_in(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<Snap
|
||||
}
|
||||
if opts.guides {
|
||||
for g in &comp.guides {
|
||||
out.push(if g.vertical {
|
||||
SnapTarget::vline(g.position, PRI_GUIDE, SnapSource::Guide)
|
||||
} else {
|
||||
SnapTarget::hline(g.position, PRI_GUIDE, SnapSource::Guide)
|
||||
});
|
||||
let pos = g.px(comp.width, comp.height);
|
||||
out.push(if g.vertical { SnapTarget::vline(pos, PRI_GUIDE, SnapSource::Guide) } else { SnapTarget::hline(pos, PRI_GUIDE, SnapSource::Guide) });
|
||||
}
|
||||
}
|
||||
if opts.grid && opts.grid_spacing >= 1.0 {
|
||||
|
||||
@@ -487,7 +487,15 @@ fn build_group(project: &Project, comp: ItemId, eg: &EssentialGraphics, old: Opt
|
||||
}
|
||||
}
|
||||
}
|
||||
PropGroup { uid, match_id: GROUP.into(), name: "Essential Properties".into(), kind: GroupKind::Essential { overridden: keep }, enabled: true, children }
|
||||
PropGroup {
|
||||
uid,
|
||||
match_id: GROUP.into(),
|
||||
name: "Essential Properties".into(),
|
||||
kind: GroupKind::Essential { overridden: keep },
|
||||
enabled: true,
|
||||
label: None,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_prop<'a>(nodes: &'a [Node], m: &str) -> Option<&'a Property> {
|
||||
|
||||
@@ -467,7 +467,37 @@ pub struct Comp {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Guide {
|
||||
pub vertical: bool,
|
||||
/// Comp pixels, or percent of the comp's width (vertical) / height (horizontal) when
|
||||
/// [`Guide::percent`] is set.
|
||||
pub position: f64,
|
||||
/// A percentage guide (After Effects 26.5): `position` is a percent of the comp's size, so the
|
||||
/// guide keeps its relative place when the comp is resized. Absent in older projects.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub percent: bool,
|
||||
}
|
||||
|
||||
impl Guide {
|
||||
/// The comp's extent along this guide's axis, never zero.
|
||||
fn extent(&self, width: u32, height: u32) -> f64 {
|
||||
f64::from(if self.vertical { width } else { height }).max(1.0)
|
||||
}
|
||||
|
||||
/// The guide's position in comp pixels for a comp of this size.
|
||||
pub fn px(&self, width: u32, height: u32) -> f64 {
|
||||
if self.percent { self.position / 100.0 * self.extent(width, height) } else { self.position }
|
||||
}
|
||||
|
||||
/// Place the guide at `px` comp pixels, keeping its unit.
|
||||
pub fn set_px(&mut self, px: f64, width: u32, height: u32) {
|
||||
self.position = if self.percent { px / self.extent(width, height) * 100.0 } else { px };
|
||||
}
|
||||
|
||||
/// Switch between pixels and percent without moving the guide on the comp.
|
||||
pub fn set_percent(&mut self, percent: bool, width: u32, height: u32) {
|
||||
let px = self.px(width, height);
|
||||
self.percent = percent;
|
||||
self.set_px(px, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
|
||||
@@ -255,6 +255,10 @@ pub struct PropGroup {
|
||||
/// fx switch for effects, eye for shape groups, etc.
|
||||
#[serde(default = "yes")]
|
||||
pub enabled: bool,
|
||||
/// An effect's colour label (After Effects 26.5), shown in Effect Controls and the Timeline;
|
||||
/// `None` is unlabelled. Absent in older projects.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<effectcraft_color::Label>,
|
||||
#[serde(default)]
|
||||
pub children: Vec<Node>,
|
||||
}
|
||||
@@ -338,7 +342,7 @@ pub fn parse_path(s: &str) -> Vec<Seg> {
|
||||
|
||||
impl PropGroup {
|
||||
pub fn new(uid: Uid, match_id: &str, name: &str) -> PropGroup {
|
||||
PropGroup { uid, match_id: match_id.into(), name: name.into(), kind: GroupKind::Plain, enabled: true, children: vec![] }
|
||||
PropGroup { uid, match_id: match_id.into(), name: name.into(), kind: GroupKind::Plain, enabled: true, label: None, children: vec![] }
|
||||
}
|
||||
pub fn with(mut self, n: impl Into<Node>) -> PropGroup {
|
||||
self.children.push(n.into());
|
||||
|
||||
@@ -777,6 +777,9 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[
|
||||
("view.snapToGuides", "Snap to Guides", "对齐到参考线"),
|
||||
("view.lockGuides", "Lock Guides", "锁定参考线"),
|
||||
("view.addGuide", "Add Guide...", "添加参考线..."),
|
||||
("view.addPercentGuide", "Add Percentage Guide...", "添加百分比参考线..."),
|
||||
("view.convertGuides", "Convert Guides to Percentage", "将参考线转换为百分比"),
|
||||
("view.convertGuides", "Convert Guides to Pixels", "将参考线转换为像素"),
|
||||
("view.clearGuides", "Clear Guides", "清除参考线"),
|
||||
("view.importGuides", "Import Guides...", "导入参考..."),
|
||||
("view.exportGuides", "Export Guides...", "导出参考..."),
|
||||
@@ -1407,6 +1410,9 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[
|
||||
("view.snapToGuides", "Snap to Guides", "靠齊參考線"),
|
||||
("view.lockGuides", "Lock Guides", "鎖定參考線"),
|
||||
("view.addGuide", "Add Guide...", "新增參考線..."),
|
||||
("view.addPercentGuide", "Add Percentage Guide...", "新增百分比參考線..."),
|
||||
("view.convertGuides", "Convert Guides to Percentage", "將參考線轉換為百分比"),
|
||||
("view.convertGuides", "Convert Guides to Pixels", "將參考線轉換為像素"),
|
||||
("view.clearGuides", "Clear Guides", "清除參考線"),
|
||||
("view.importGuides", "Import Guides...", "匯入參考線..."),
|
||||
("view.exportGuides", "Export Guides...", "匯出參考線..."),
|
||||
@@ -2034,6 +2040,9 @@ const JAPANESE: &[(&str, &str, &str)] = &[
|
||||
("view.snapToGuides", "Snap to Guides", "ガイドにスナップ"),
|
||||
("view.lockGuides", "Lock Guides", "ガイドをロック"),
|
||||
("view.addGuide", "Add Guide...", "ガイドを追加..."),
|
||||
("view.addPercentGuide", "Add Percentage Guide...", "パーセントガイドを追加..."),
|
||||
("view.convertGuides", "Convert Guides to Percentage", "ガイドをパーセントに変換"),
|
||||
("view.convertGuides", "Convert Guides to Pixels", "ガイドをピクセルに変換"),
|
||||
("view.clearGuides", "Clear Guides", "ガイドを消去"),
|
||||
("view.importGuides", "Import Guides...", "ガイドを読み込み..."),
|
||||
("view.exportGuides", "Export Guides...", "ガイドを書き出し..."),
|
||||
@@ -2664,6 +2673,9 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[
|
||||
("view.snapToGuides", "Snap to Guides", "Прив’язувати до напрямних"),
|
||||
("view.lockGuides", "Lock Guides", "Заблокувати напрямні"),
|
||||
("view.addGuide", "Add Guide...", "Додати напрямну..."),
|
||||
("view.addPercentGuide", "Add Percentage Guide...", "Додати відсоткову напрямну..."),
|
||||
("view.convertGuides", "Convert Guides to Percentage", "Перетворити напрямні на відсотки"),
|
||||
("view.convertGuides", "Convert Guides to Pixels", "Перетворити напрямні на пікселі"),
|
||||
("view.clearGuides", "Clear Guides", "Очистити напрямні"),
|
||||
("view.importGuides", "Import Guides...", "Імпортувати напрямні..."),
|
||||
("view.exportGuides", "Export Guides...", "Експортувати напрямні..."),
|
||||
@@ -3295,6 +3307,9 @@ const SPANISH: &[(&str, &str, &str)] = &[
|
||||
("view.snapToGuides", "Snap to Guides", "Ajustar a las guías"),
|
||||
("view.lockGuides", "Lock Guides", "Bloquear guías"),
|
||||
("view.addGuide", "Add Guide...", "Añadir guía..."),
|
||||
("view.addPercentGuide", "Add Percentage Guide...", "Añadir guía porcentual..."),
|
||||
("view.convertGuides", "Convert Guides to Percentage", "Convertir guías a porcentaje"),
|
||||
("view.convertGuides", "Convert Guides to Pixels", "Convertir guías a píxeles"),
|
||||
("view.clearGuides", "Clear Guides", "Borrar guías"),
|
||||
("view.importGuides", "Import Guides...", "Importar guías..."),
|
||||
("view.exportGuides", "Export Guides...", "Exportar guías..."),
|
||||
@@ -3924,6 +3939,9 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[
|
||||
("view.snapToGuides", "Snap to Guides", "Encaixar nas guias"),
|
||||
("view.lockGuides", "Lock Guides", "Bloquear guias"),
|
||||
("view.addGuide", "Add Guide...", "Adicionar guia..."),
|
||||
("view.addPercentGuide", "Add Percentage Guide...", "Adicionar guia percentual..."),
|
||||
("view.convertGuides", "Convert Guides to Percentage", "Converter guias em porcentagem"),
|
||||
("view.convertGuides", "Convert Guides to Pixels", "Converter guias em pixels"),
|
||||
("view.clearGuides", "Clear Guides", "Limpar guias"),
|
||||
("view.importGuides", "Import Guides...", "Importar guias..."),
|
||||
("view.exportGuides", "Export Guides...", "Exportar guias..."),
|
||||
@@ -4555,6 +4573,9 @@ const RUSSIAN: &[(&str, &str, &str)] = &[
|
||||
("view.snapToGuides", "Snap to Guides", "Привязывать к направляющим"),
|
||||
("view.lockGuides", "Lock Guides", "Закрепить направляющие"),
|
||||
("view.addGuide", "Add Guide...", "Добавить направляющую..."),
|
||||
("view.addPercentGuide", "Add Percentage Guide...", "Добавить процентную направляющую..."),
|
||||
("view.convertGuides", "Convert Guides to Percentage", "Преобразовать направляющие в проценты"),
|
||||
("view.convertGuides", "Convert Guides to Pixels", "Преобразовать направляющие в пиксели"),
|
||||
("view.clearGuides", "Clear Guides", "Очистить направляющие"),
|
||||
("view.importGuides", "Import Guides...", "Импортировать направляющие..."),
|
||||
("view.exportGuides", "Export Guides...", "Экспортировать направляющие..."),
|
||||
@@ -5184,6 +5205,9 @@ const FRENCH: &[(&str, &str, &str)] = &[
|
||||
("view.snapToGuides", "Snap to Guides", "Aimanter sur les repères"),
|
||||
("view.lockGuides", "Lock Guides", "Verrouiller les repères"),
|
||||
("view.addGuide", "Add Guide...", "Ajouter un repère..."),
|
||||
("view.addPercentGuide", "Add Percentage Guide...", "Ajouter un repère en pourcentage..."),
|
||||
("view.convertGuides", "Convert Guides to Percentage", "Convertir les repères en pourcentage"),
|
||||
("view.convertGuides", "Convert Guides to Pixels", "Convertir les repères en pixels"),
|
||||
("view.clearGuides", "Clear Guides", "Effacer les repères"),
|
||||
("view.importGuides", "Import Guides...", "Importer des repères..."),
|
||||
("view.exportGuides", "Export Guides...", "Exporter des repères..."),
|
||||
@@ -5813,6 +5837,9 @@ const GERMAN: &[(&str, &str, &str)] = &[
|
||||
("view.snapToGuides", "Snap to Guides", "An Hilfslinien einrasten"),
|
||||
("view.lockGuides", "Lock Guides", "Hilfslinien sperren"),
|
||||
("view.addGuide", "Add Guide...", "Hilfslinie hinzufügen..."),
|
||||
("view.addPercentGuide", "Add Percentage Guide...", "Prozentuale Hilfslinie hinzufügen..."),
|
||||
("view.convertGuides", "Convert Guides to Percentage", "Hilfslinien in Prozent umwandeln"),
|
||||
("view.convertGuides", "Convert Guides to Pixels", "Hilfslinien in Pixel umwandeln"),
|
||||
("view.clearGuides", "Clear Guides", "Hilfslinien entfernen"),
|
||||
("view.importGuides", "Import Guides...", "Hilfslinien importieren..."),
|
||||
("view.exportGuides", "Export Guides...", "Hilfslinien exportieren..."),
|
||||
@@ -6443,6 +6470,9 @@ const CZECH: &[(&str, &str, &str)] = &[
|
||||
("view.snapToGuides", "Snap to Guides", "Přichytávat k vodítkům"),
|
||||
("view.lockGuides", "Lock Guides", "Zamknout vodítka"),
|
||||
("view.addGuide", "Add Guide...", "Přidat vodítko..."),
|
||||
("view.addPercentGuide", "Add Percentage Guide...", "Přidat procentuální vodítko..."),
|
||||
("view.convertGuides", "Convert Guides to Percentage", "Převést vodítka na procenta"),
|
||||
("view.convertGuides", "Convert Guides to Pixels", "Převést vodítka na pixely"),
|
||||
("view.clearGuides", "Clear Guides", "Vymazat vodítka"),
|
||||
("view.importGuides", "Import Guides...", "Importovat vodítka..."),
|
||||
("view.exportGuides", "Export Guides...", "Exportovat vodítka..."),
|
||||
|
||||
@@ -1107,6 +1107,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let selected = app.session.state.selected_props.iter().any(|(l, u)| *l == layer.id && *u == g.uid);
|
||||
let active = effect_active(g, layer.id, &app.session.state.selected_props);
|
||||
bp.rect_filled(r, 0.0, if active { t.fx_header_active } else { t.fx_header });
|
||||
// The effect's colour label (26.5): a bar on the header's left edge.
|
||||
if let Some(lab) = g.label.filter(|l| *l != effectcraft_engine::color::Label::None) {
|
||||
let [lr, lg, lb] = app.session.prefs.label_rgb(lab);
|
||||
bp.rect_filled(Rect::from_min_size(r.min, vec2(4.0, r.height())), 0.0, Color32::from_rgb(lr, lg, lb));
|
||||
app.auto.add(
|
||||
&format!("effectControls.effect.{}.label", g.uid),
|
||||
Rect::from_min_size(r.min, vec2(4.0, r.height())),
|
||||
&app.session.prefs.label_name(lab),
|
||||
);
|
||||
}
|
||||
let fxr = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(16.0, 16.0));
|
||||
if widgets::icon_toggle(ui, fxr, Icon::Fx, g.enabled, &t, egui::Id::new(("ec-fx", g.uid)), Sense::click()).clicked() {
|
||||
actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": g.uid})));
|
||||
@@ -1197,6 +1207,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
crate::widgets::context_menu(&hresp, |ui| {
|
||||
let lid = layer.id.0;
|
||||
let mut picked_label: Option<usize> = None;
|
||||
let mut item = |ui: &mut egui::Ui, label: &str, enabled: bool, cmd: &str, params: serde_json::Value| {
|
||||
if ui.add_enabled(enabled, egui::Button::new(label)).clicked() {
|
||||
actions.push((cmd.into(), params));
|
||||
@@ -1212,11 +1223,23 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
ui.menu_button(crate::i18n::tr("Label"), |ui| {
|
||||
let cur = g.label.unwrap_or(effectcraft_engine::color::Label::None);
|
||||
for (i, l) in effectcraft_engine::color::Label::ALL.into_iter().enumerate() {
|
||||
if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), l == cur, &t).clicked() {
|
||||
picked_label = Some(i);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
item(ui, "Reset", true, "effect.reset", json!({"layer": lid, "effect": g.uid}));
|
||||
item(ui, "Remove", true, "effect.remove", json!({"layer": lid, "effect": g.uid}));
|
||||
ui.separator();
|
||||
item(ui, "Move Up", fi > 0, "effect.reorder", json!({"layer": lid, "effect": g.uid, "index": fi.max(1)}));
|
||||
item(ui, "Move Down", fi + 1 < fx.len(), "effect.reorder", json!({"layer": lid, "effect": g.uid, "index": fi + 2}));
|
||||
if let Some(i) = picked_label {
|
||||
actions.push(("effect.setLabel".into(), json!({"layer": lid, "effect": g.uid, "label": i})));
|
||||
}
|
||||
});
|
||||
// What the effect can't render as asked (an OCIO config it can't read, transforms it
|
||||
// passes through), under its name.
|
||||
|
||||
@@ -357,6 +357,13 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
||||
],
|
||||
)
|
||||
}
|
||||
"view.addPercentGuide" if !has(p, &["position"]) => (
|
||||
"Add Percentage Guide".into(),
|
||||
vec![
|
||||
Field::choice("orientation", "Orientation", &[("Vertical", json!("vertical")), ("Horizontal", json!("horizontal"))], 0),
|
||||
Field::num("position", "Position (percent of the composition)", 50.0),
|
||||
],
|
||||
),
|
||||
// Several items selected: After Effects' New Composition from Selection dialog.
|
||||
"file.newCompFromSelection"
|
||||
if s.state.project_selection.len() > 1 && !has(p, &["single", "dimensionsFrom", "duration", "sequence", "addToRenderQueue"]) =>
|
||||
|
||||
@@ -916,10 +916,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let style = app.session.prefs.grids.guide_style.clone();
|
||||
for g in &comp.guides {
|
||||
if g.vertical {
|
||||
let x = comp_rect.min.x + g.position as f32 * zoom;
|
||||
let x = comp_rect.min.x + g.px(comp.width, comp.height) as f32 * zoom;
|
||||
styled_line(&painter, [pos2(x, area.min.y), pos2(x, area.max.y)], stroke, &style);
|
||||
} else {
|
||||
let y = comp_rect.min.y + g.position as f32 * zoom;
|
||||
let y = comp_rect.min.y + g.px(comp.width, comp.height) as f32 * zoom;
|
||||
styled_line(&painter, [pos2(area.min.x, y), pos2(area.max.x, y)], stroke, &style);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +289,8 @@ pub(crate) fn guide_at(app: &EffectcraftApp, comp: &Comp, map: &ViewerMap, pos:
|
||||
return None;
|
||||
}
|
||||
comp.guides.iter().enumerate().find_map(|(i, g)| {
|
||||
let s = map.to_screen([g.position, g.position]);
|
||||
let px = g.px(comp.width, comp.height);
|
||||
let s = map.to_screen([px, px]);
|
||||
let d = if g.vertical { (s.x - pos.x).abs() } else { (s.y - pos.y).abs() };
|
||||
(d < 4.0).then_some((i, g.vertical))
|
||||
})
|
||||
|
||||
@@ -137,8 +137,9 @@ Compositions and Text Layers), `view.channel {channel, colorized?}`,
|
||||
`view.fastPreviewMode {mode}`, `view.res.full|half|third|quarter|auto {comp?}` and
|
||||
`view.res.custom {factor, comp?}` (the comp's Resolution, saved with the project but no undo
|
||||
step; `ui.set {viewer: {res}}` sets the active comp's, and `ui.inspect` reports it as
|
||||
`ui.viewer.res`), `view.setRegionOfInterest {rect}`, `view.addGuide`,
|
||||
`view.moveGuide`, `view.removeGuide`; editing: `shape.newPath` (Pen on shape layers),
|
||||
`ui.viewer.res`), `view.setRegionOfInterest {rect}`, `view.addGuide {percent?}`,
|
||||
`view.addPercentGuide`, `view.convertGuides {to: percent|pixels, index?}`,
|
||||
`view.moveGuide` (comp pixels, whatever the guide's unit), `view.removeGuide`, `effect.setLabel {layer?, effect, label}`; editing: `shape.newPath` (Pen on shape layers),
|
||||
`shape.newShape` (the shape tools: a new group in the selected shape layer, else a new shape layer),
|
||||
`shape.toolOptions` (the Tools bar's Tool Creates Shape / Mask and Fill and Stroke Options),
|
||||
`mask.insertVertex`, `mask.convertVertex`, `mask.deleteVertices`, `path.freeTransform` (masks and
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Effects parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first effects parity checklist: presence, GPU, fidelity and presets against After Effects 2026 26.5) · **Target:** Adobe After Effects 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (effect colour labels; earlier change: first effects parity checklist: presence, GPU, fidelity and presets against After Effects 2026 26.5) · **Target:** Adobe After Effects 2026
|
||||
|
||||
Effects are the deepest single area of After Effects. This page separates the four questions
|
||||
that a single "306 of 306" hides: does each effect exist, does it run fast, does it look like
|
||||
@@ -17,7 +17,7 @@ After Effects', and does the preset library exist. The per-effect catalogue is t
|
||||
| 32-bit float, no clamping | 306 | Measured: registry flags |
|
||||
| **Rendered output matches After Effects** | **unmeasured** | No effect is compared with an After Effects render yet. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) |
|
||||
| Animation presets | **31** original presets (8 text-animator, 23 Transitions) against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`, `crates/engine/presets/transitions.json`; the After Effects `Presets` folder |
|
||||
| 26.x additions | Curl Noise (26.3) present (#463 open); effect colour labels and the rebuilt Effect Controls (26.5) missing | Release notes |
|
||||
| 26.x additions | Curl Noise (26.3) present (#463 open); effect colour labels (26.5) present in Effect Controls only, not in the Timeline; the rebuilt Effect Controls (26.5) missing | Release notes |
|
||||
|
||||
**Breadth ≈ 85%** (presets weigh in; the effects themselves are ≈ 100%). **Ready ≈ 40%**: until a
|
||||
corpus compares renders, each effect is assumed to differ somewhere, and users keep confirming it.
|
||||
@@ -64,4 +64,5 @@ the GPU is performance, not parity.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls |
|
||||
| 2026-10-10 | major | First effects parity checklist |
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Where EffectCraft falls short
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (re-measured against After Effects 2026 26.5; ranked gap list with estimates; numbers reconciled with target-app-parity.md) · **Target:** Adobe After Effects 2026 (26.5.0.89)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (gap 21: percentage guides and effect colour labels in; earlier change: re-measured against After Effects 2026 26.5; ranked gap list with estimates; numbers reconciled with target-app-parity.md) · **Target:** Adobe After Effects 2026 (26.5.0.89)
|
||||
|
||||
An honest assessment of how far EffectCraft is from being a real replacement for After Effects,
|
||||
and the work that closes the gap. This document is meant for contributors and agents choosing
|
||||
@@ -77,7 +77,7 @@ are grouped by area; the issue tracker has the rest.
|
||||
| 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) |
|
||||
| 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) |
|
||||
| 20 | **Multi-monitor and video output** | #486: any panel can move into a native window of its own (panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so onto another screen, restored with the workspace; still missing: dragging tabs between those windows, app shortcuts while one has the focus, named workspaces remembering them, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) |
|
||||
| 21 | **Small 26.x features** | Proportional Scrubbing (26.2), pasting Illustrator's own clipboard data as shapes (26.3; SVG markup on the clipboard pastes as a shape layer, #738), variable-font filter (26.3), percentage guides and effect colour labels (26.5), the rebuilt Effect Controls (26.5) | Users of the current version expect them | 15–25 h | — · [ui-parity.md](ui-parity.md) |
|
||||
| 21 | **Small 26.x features** | Proportional Scrubbing (26.2), pasting Illustrator's own clipboard data as shapes (26.3; SVG markup on the clipboard pastes as a shape layer, #738), variable-font filter (26.3), the rebuilt Effect Controls (26.5). Percentage guides (View ▸ Add Percentage Guide, Convert Guides to Percentage / Pixels) and effect colour labels (Effect Controls header bar and right-click Label menu, `effect.setLabel`) are in (#1095), built from the release notes' one-line descriptions and never compared with After Effects: the Timeline shows no effect label, and the labels' exact UI there is unknown | Users of the current version expect them | 10–20 h left | — · [ui-parity.md](ui-parity.md) |
|
||||
| 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; single-file intermediates are 8-bit uncompressed QuickTime only (#372: no 16-bpc, AVI or lossless-compressed codec); #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) |
|
||||
| 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 Scale exposes X and Y; #611 FilmCraft rejects exported `.fcgt` | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) |
|
||||
| 24 | **Timeline depth requests** | #601 expand precomps in place, #602 reorderable render stack, #604 guide-only effects, #355 bulk keyframes, #425 keyframe confusion, #428 more shortcuts | Power-user speed | 15–30 h | G2 · [ui-parity.md](ui-parity.md) |
|
||||
@@ -317,6 +317,7 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | Alpha gate checked: no alpha blockers |
|
||||
| 2026-10-12 | minor | Gap 21: percentage guides and effect colour labels added (#1095) |
|
||||
| 2026-10-10 | major | Re-measured against After Effects 2026 26.5: two numbers (breadth ≈ 91%, ready ≈ 45%), dimension table with hours, a ranked list of 25 gaps with evidence, impact, estimate and parity doc; the real-user evidence moved into a report log under G2; links follow the rename of `parity.md` to `target-app-parity.md` |
|
||||
| 2026-10-08 | minor | User issues of 6–8 October and their regression evidence |
|
||||
| 2026-10-05 | major | First honest assessment: breadth ≈ 99% vs real use ≈ 30–50%; workstreams G1–G9 |
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first UI parity checklist against After Effects 2026 26.5) · **Target:** Adobe After Effects 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (percentage guides and effect colour labels, #1095) · **Target:** Adobe After Effects 2026
|
||||
|
||||
How closely EffectCraft's interaction matches After Effects: tools, handles, snapping, nudging,
|
||||
modifier keys, shortcuts, drag behaviour, numeric entry, panels, context menus and feel. Evidence
|
||||
@@ -18,11 +18,11 @@ feel and platform differences, and the 26.x interface changes are not in yet.
|
||||
| Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) |
|
||||
| Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; dropping a panel on a stacked panel doesn't make a tab (#302); stacked-panel headers (#301); saved workspaces don't persist (#320); no panels on a second monitor (#486) |
|
||||
| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; Graph Editor jitter and hidden buttons (#456, #457); rotation can't go negative by dragging (#569); bulk keyframing (#355); expand precomps in place (#601); Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties |
|
||||
| Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); inaccurate snapping (#352); anchor point grab on Linux (#492); no 3D transform gizmos (#392, #347); percentage guides (26.5); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu |
|
||||
| Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); inaccurate snapping (#352); anchor point grab on Linux (#492); no 3D transform gizmos (#392, #347); percentage guides (26.5) exist as View ▸ Add Percentage Guide and Convert Guides to Percentage / Pixels (a guide stores a percent of the comp, so it follows comp-size changes; behaviour inferred from the release note, not compared with After Effects); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu |
|
||||
| Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; pen and vertex tools on motion paths (#553) |
|
||||
| Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; Linux multi-layer edits (#491) |
|
||||
| Numeric entry | Scrubbable values, typed entry with Tab / Shift+Tab, angle revolutions, Escape cancels, non-finite rejected | 85% | `ui_angles`, `ui_inline_edit` (#93, #141, #361); rename arrows on Linux (#488) |
|
||||
| Effect Controls | After Effects' twirl-downs, angle dial, point crosshair, eyedropper, curves/levels editors, effect menu on right-click | 70% | `ui_effect_controls`, `ui_fx_editors`; 26.5 rebuilt this panel with icon controls and effect colour labels, not matched |
|
||||
| Effect Controls | After Effects' twirl-downs, angle dial, point crosshair, eyedropper, curves/levels editors, effect menu on right-click | 70% | `ui_effect_controls`, `ui_fx_editors`; 26.5 rebuilt this panel with icon controls and effect colour labels: labels exist (a colour bar on the effect header, right-click Label menu, `effect.setLabel`; not in the Timeline), the rebuilt controls are not matched |
|
||||
| Context menus | Layer, property, Project, Timeline empty area, work area, labels with colours | 80% | `ui_blank_context_menus`; #227, #290, #407 fixed |
|
||||
| Keyboard shortcuts | After Effects' defaults; Keyboard Shortcuts editor that rebinds and saves sets | 75% | Shortcut editor in Settings; adding shortcuts fails on Linux (#467); requests for more (#428, #336); no shortcuts for scripts |
|
||||
| Drag and drop | Project → Timeline / viewer / New Comp / Render Queue; effects onto layers; files from the OS | 65% | `ui_drag_drop`; OS drops fail on Wayland (winit) and some Linux setups (#268); multi-file drops (#552, #351); crash dragging over the import dialog (#511) |
|
||||
@@ -44,4 +44,5 @@ Effects running; most of the rest parallelises.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Percentage guides and effect colour labels (26.5) added; rows updated |
|
||||
| 2026-10-10 | major | First UI parity checklist |
|
||||
Reference in new issue
Block a user