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:
vangel76andClaude Sonnet 5.5 committed 2026-10-12 21:07:36 +02:00
1 parent 470ea7a3af
commit f11792827e
20 files changed
+301 -25

No files matched your search

+3 -1
View File
@@ -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(())
+20
View File
@@ -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),
+65 -4
View File
@@ -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),
+5 -1
View File
@@ -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))
+3
View File
@@ -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
+32
View File
@@ -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);
}
+50
View File
@@ -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());
}
+2 -5
View File
@@ -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 {
+9 -1
View File
@@ -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> {
+30
View File
@@ -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 {
+5 -1
View File
@@ -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());
+30
View File
@@ -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.
+7
View File
@@ -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"]) =>
+2 -2
View File
@@ -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);
}
}
+2 -1
View File
@@ -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))
})
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 |