From f11792827eb813db640153a8f95575a28719b7c2 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:07:36 +0200 Subject: [PATCH] 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 --- crates/engine/src/commands/comp_more.rs | 4 +- crates/engine/src/commands/effect.rs | 20 ++++++ crates/engine/src/commands/view.rs | 69 ++++++++++++++++++-- crates/engine/src/commands/viewer_cmds.rs | 6 +- crates/engine/src/menus.rs | 3 + crates/engine/src/tests_effects.rs | 32 +++++++++ crates/engine/src/tests_menu_cmds.rs | 50 ++++++++++++++ crates/engine/src/viewer.rs | 7 +- crates/project/src/essential.rs | 10 ++- crates/project/src/lib.rs | 30 +++++++++ crates/project/src/props.rs | 6 +- crates/ui-egui/src/i18n.rs | 30 +++++++++ crates/ui-egui/src/panels/effect_controls.rs | 23 +++++++ crates/ui-egui/src/panels/forms.rs | 7 ++ crates/ui-egui/src/panels/viewer.rs | 4 +- crates/ui-egui/src/panels/viewer_tools.rs | 3 +- docs/control-protocol.md | 5 +- docs/effects-parity.md | 5 +- docs/gaps.md | 5 +- docs/ui-parity.md | 7 +- 20 files changed, 301 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/commands/comp_more.rs b/crates/engine/src/commands/comp_more.rs index f1511558..d6de320e 100644 --- a/crates/engine/src/commands/comp_more.rs +++ b/crates/engine/src/commands/comp_more.rs @@ -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(()) diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index 3c597ebd..1e67e071 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -227,6 +227,25 @@ fn toggle(s: &mut Session, p: &Value) -> Result { Ok(json!(r)) } +/// `effect.setLabel`: the effect's colour label (None clears it). +fn set_label(s: &mut Session, p: &Value) -> Result { + 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 { 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 { 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), diff --git a/crates/engine/src/commands/view.rs b/crates/engine/src/commands/view.rs index abf92416..3e80c891 100644 --- a/crates/engine/src/commands/view.rs +++ b/crates/engine/src/commands/view.rs @@ -20,15 +20,58 @@ fn add_guide(s: &mut Session, p: &Value) -> Result { 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 { + 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 { + 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 { let cid = comp_id(s, p)?; s.edit("Clear Guides", None, |proj, _| { @@ -128,7 +171,25 @@ macro_rules! res { pub fn specs() -> Vec { 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), diff --git a/crates/engine/src/commands/viewer_cmds.rs b/crates/engine/src/commands/viewer_cmds.rs index 263f7191..f7d0686b 100644 --- a/crates/engine/src/commands/viewer_cmds.rs +++ b/crates/engine/src/commands/viewer_cmds.rs @@ -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 { 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)) diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index bd1cffb0..095ad84a 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -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 diff --git a/crates/engine/src/tests_effects.rs b/crates/engine/src/tests_effects.rs index 82f51036..bec84b9a 100644 --- a/crates/engine/src/tests_effects.rs +++ b/crates/engine/src/tests_effects.rs @@ -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); +} diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index cb83c163..41a4eddc 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -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()); +} diff --git a/crates/engine/src/viewer.rs b/crates/engine/src/viewer.rs index 5abb942a..cac75024 100644 --- a/crates/engine/src/viewer.rs +++ b/crates/engine/src/viewer.rs @@ -335,11 +335,8 @@ fn targets_in(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec= 1.0 { diff --git a/crates/project/src/essential.rs b/crates/project/src/essential.rs index fcf2cfb3..65ba8e36 100644 --- a/crates/project/src/essential.rs +++ b/crates/project/src/essential.rs @@ -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> { diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index fee3b8ce..283cb0b3 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -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 { diff --git a/crates/project/src/props.rs b/crates/project/src/props.rs index f1d5149d..9e639523 100644 --- a/crates/project/src/props.rs +++ b/crates/project/src/props.rs @@ -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, #[serde(default)] pub children: Vec, } @@ -338,7 +342,7 @@ pub fn parse_path(s: &str) -> Vec { 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) -> PropGroup { self.children.push(n.into()); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4e814ea2..e846da46 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -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..."), diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index d8de61d8..5c16a9e5 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -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 = 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. diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 2193b605..9025092d 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -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"]) => diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index cbb842d9..23473132 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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); } } diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index fa198505..d229f272 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -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)) }) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f5487981..5dcc1eea 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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 diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 5f26587c..ff4a29c8 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -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 | diff --git a/docs/gaps.md b/docs/gaps.md index e621ee87..8ae4f48f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3654f61b..1a49e604 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 |