From 61af9e5b725658ec1d8a394cfb6d2b14391ba0f7 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 15:41:14 +0100 Subject: [PATCH 1/3] #3233: a text layer's name follows its text until the user renames it A text layer's name followed its Source Text whenever the name started with "Text", so a user's "Text Title" was replaced by the next text edit. The layer now records whether its name follows the text (`name_follows_text`, optional in the project file). A new text layer named after its text follows it; a name given at creation or by Rename (Enter, layer.rename, a script's layer.name, Layer Settings' name) is the user's and stays; renaming to an empty name shows the text again and follows it; committing the unchanged name changes nothing; copies keep the state. On-canvas typing and layer.setText share the rule. The name is the text's first paragraph, which ends at a line feed or a bare carriage return (After Effects' paragraph break). Projects saved without the state keep the old rule for the names that followed: a name that is empty, the text's own, or "Text"/"Text 2"... follows; any other name stays. AEP import records a stored layer name as the user's and no name as following, and names an empty text layer without a name "Text" instead of "Layer"; export writes no name for a following layer and keeps the name the user gave, even when it equals the text. Fixes #3233 --- crates/aep/src/export/plan.rs | 5 +- crates/aep/src/import/build.rs | 11 ++- crates/aep/tests/it/export/text.rs | 42 ++++++++++ crates/engine/src/cmd/layer.rs | 10 ++- crates/engine/src/cmd/layer/contents.rs | 12 +-- crates/engine/src/cmd/layer/create.rs | 4 +- crates/engine/src/cmd/layer/switches.rs | 26 ++++++- crates/engine/src/cmd/layer/transform.rs | 4 +- crates/engine/src/cmd/text_edit.rs | 15 ++-- crates/engine/src/tests/text_edit.rs | 99 ++++++++++++++++++++++++ crates/project/src/build.rs | 1 + crates/project/src/layer.rs | 26 +++++++ crates/project/src/lib.rs | 2 +- docs/agents.md | 2 + 14 files changed, 236 insertions(+), 23 deletions(-) diff --git a/crates/aep/src/export/plan.rs b/crates/aep/src/export/plan.rs index 9f45c50cd..f8aa2b3b8 100644 --- a/crates/aep/src/export/plan.rs +++ b/crates/aep/src/export/plan.rs @@ -1047,9 +1047,10 @@ fn plan_layer(cx: &PlanCx<'_>, at: CompAt<'_>, cp: &CompPlan, layer: &Layer, col let props = plan_transform(layer, &src, comp, source_size, cp, comp_name, col); let remap = remap_prop.filter(|_| remapped).map(|p| plan_remap(p, comp, cp, &layer.name, comp_name, col)); let mut name = cut_name(&layer.name, 255, &what("layer"), col); - // A text layer named after its text stores no name (110, 113: After Effects shows the text). + // A text layer named after its text stores no name (110, 113: After Effects shows the text): + // the layer's own rule decides (#3233). if let LayerSrc::Text(tp) = &src - && tp.text.split('\r').next().is_some_and(|first| first.chars().take(64).collect::() == name) + && layer.name_follows_text(&effectcraft_project::text_layer_name(&tp.text)) { name.clear(); } diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs index 4ec346328..fa66ee411 100644 --- a/crates/aep/src/import/build.rs +++ b/crates/aep/src/import/build.rs @@ -479,7 +479,12 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition .map(|d| crate::import::text::first_paragraph(&d.raw_text)) .filter(|t| !t.trim().is_empty()) }; - let name = aep_name.clone().or_else(|| src.as_ref().map(|s| s.name.clone())).or_else(text_name).unwrap_or_else(|| "Layer".to_string()); + let name = aep_name + .clone() + .or_else(|| src.as_ref().map(|s| s.name.clone())) + .or_else(text_name) + // An empty text layer without a name is named as a new one is (#3233). + .unwrap_or_else(|| if h.layer_type == LayerType::Text { "Text" } else { "Layer" }.to_string()); let skip = |cx: &mut Ctx<'_>, skipped: &mut HashMap, category: LossCategory, what: &str| { cx.col.lose(category, format!("{comp_name}: {what} '{name}'")); skipped.insert(h.id, what.to_string()); @@ -537,6 +542,10 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition }; let mut l: Layer = build::layer(project, &comp, &name, source.clone(), size, None); l.name = name.clone(); + // A text layer without a stored name follows its text; a stored name is the user's (#3233). + if is_text { + l.name_follows_text = Some(aep_name.is_none()); + } l.label = Label::ALL.get(usize::from(h.label)).copied().unwrap_or(Label::None); apply_timing(&mut l, &h, &tm, &name, &mut cx.col); apply_switches(&mut l, &h); diff --git a/crates/aep/tests/it/export/text.rs b/crates/aep/tests/it/export/text.rs index 32c7572c5..32e55a269 100644 --- a/crates/aep/tests/it/export/text.rs +++ b/crates/aep/tests/it/export/text.rs @@ -196,6 +196,48 @@ fn documents_built_in_code_are_written_as_their_fields() { assert_eq!(layer.header().map(|h| h.layer_type), Some(effectcraft_aep::LayerType::Text)); } +/// #3233: whether a text layer's name follows its text survives export and import. A followed +/// name is written as no name (After Effects shows the text) and comes back following; a name the +/// user gave is written, even when it equals the text, and comes back as the user's. Without a +/// recorded state (older projects) a name equal to the text follows. +#[test] +fn a_text_layer_name_follows_its_text_through_export_and_import() { + for (name, follows, stored) in [ + ("Hello", Some(false), Some("Hello")), + ("Hello 2", Some(true), None), + ("Hello", None, None), + ("Text 2", None, None), + ("Text Title", None, Some("Text Title")), + ("Text Title", Some(false), Some("Text Title")), + ] { + let case = format!("{name:?} {follows:?}"); + let (mut p, c) = text_project(vec![TextDoc::plain("Hello")]); + with_layer(&mut p, c, 0, |l| { + l.name = name.into(); + l.name_follows_text = follows; + }); + let r = export_all(&p); + let file = parse(&r.bytes).expect("parse"); + let view = effectcraft_aep::Project::new(&file).expect("project"); + let layer = view.all_items().into_iter().find_map(|i| i.comp()).and_then(|c| c.layers().next()).expect("layer"); + assert_eq!(layer.name().as_deref(), stored, "stored name ({case})"); + let back = reimport(&r.bytes); + let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer"); + assert_eq!(l.name, stored.unwrap_or("Hello"), "imported name ({case})"); + assert_eq!(l.name_follows_text, Some(stored.is_none()), "imported state ({case})"); + } + // An empty text layer that follows its text is written without a name, as After Effects + // writes it, and comes back named "Text", still following. + let (mut p, c) = text_project(vec![TextDoc::plain("")]); + with_layer(&mut p, c, 0, |l| { + l.name = "Text".into(); + l.name_follows_text = Some(true); + }); + let back = reimport(&export_all(&p).bytes); + let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer"); + assert_eq!((l.name.as_str(), l.name_follows_text), ("Text", Some(true)), "an empty text layer"); +} + #[test] fn what_a_text_document_cannot_hold_is_reported() { let mut d = TextDoc::plain("x"); diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index f88ab32c8..b015b72b4 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -114,7 +114,15 @@ pub fn specs() -> Vec { has_layers, set_switch ), - cmd!("layer.rename", "Rename", [], Some("Enter"), "{layer?, name}", has_layers, rename), + cmd!( + "layer.rename", + "Rename", + [], + Some("Enter"), + "{layer?, name} — a text layer's name follows its text until renamed; an empty name follows it again", + has_layers, + rename + ), cmd!("layer.setComment", "Layer Comment", [], None, "{layers?, comment}", has_layers, set_comment), cmd!("layer.setBlendMode", "Blending Mode", [], None, "{layers?, mode?: Normal|Multiply|Screen|…, step?: ±1}", has_layers, blend), cmd!( diff --git a/crates/engine/src/cmd/layer/contents.rs b/crates/engine/src/cmd/layer/contents.rs index 5eb253beb..3015f4ecf 100644 --- a/crates/engine/src/cmd/layer/contents.rs +++ b/crates/engine/src/cmd/layer/contents.rs @@ -207,14 +207,14 @@ pub(super) fn set_text(s: &mut Session, p: &Value) -> Result { KV::Str(t) => TextDoc::plain(&t), _ => TextDoc::default(), }; + let before = effectcraft_project::text_layer_name(&doc.text); apply_text_params("layer.setText", p, &mut doc, range.clone())?; pr.set_value_at(lt, KV::Text(Box::new(doc.clone()))); - if let Some(first) = doc.text.lines().next() - && str_p(p, "text").is_some() - && !first.is_empty() - && (l.name.starts_with("Text") || l.name.is_empty()) - { - l.name = first.chars().take(40).collect(); + // Text layers are named after their text until renamed (#3233). + let after = effectcraft_project::text_layer_name(&doc.text); + if str_p(p, "text").is_some() && !after.is_empty() && l.name_follows_text(&before) { + l.name = after; + l.name_follows_text = Some(true); } Ok(()) })?; diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 621ae866b..0196b5aee 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -187,8 +187,10 @@ pub(super) fn new_text(s: &mut Session, p: &Value) -> Result { } let edit = b_p(p, "edit").unwrap_or(false); let id = s.edit("New Text Layer", None, |proj, st| { - let name: String = layer_name.clone().unwrap_or_else(|| doc.text.lines().next().unwrap_or("").chars().take(40).collect()); + let name: String = layer_name.clone().unwrap_or_else(|| effectcraft_project::text_layer_name(&doc.text)); let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None); + // Named after its text, the name follows the text until renamed; a given name does not. + l.name_follows_text = Some(layer_name.as_deref().is_none_or(str::is_empty)); if let Some(pr) = l.props.prop_mut("text/sourceText") { pr.value = KV::Text(Box::new(doc.clone())); } diff --git a/crates/engine/src/cmd/layer/switches.rs b/crates/engine/src/cmd/layer/switches.rs index 366c1eeb1..afee522c6 100644 --- a/crates/engine/src/cmd/layer/switches.rs +++ b/crates/engine/src/cmd/layer/switches.rs @@ -139,13 +139,37 @@ fn drop_3d_values(l: &mut Layer) { pub(super) fn rename(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.rename")?; let name = str_p(p, "name").ok_or_else(|| bad("layer.rename", "missing `name`"))?.to_string(); + let t = s.time(); s.edit("Rename Layer", None, |proj, _| { - layer_mut(proj, cid, lid)?.name = name.clone(); + set_layer_name(layer_mut(proj, cid, lid)?, &name, t); Ok(()) })?; Ok(Value::Null) } +/// Give layer `l` the name `name` the user typed (comp time `t` picks the text). A text layer's +/// name follows its text until the user renames it; an empty name makes it follow the text again +/// (After Effects, #3233). Committing the unchanged name changes nothing. +pub(super) fn set_layer_name(l: &mut Layer, name: &str, t: effectcraft_time::Tick) { + let text_name = + l.props.prop("text/sourceText").filter(|_| matches!(l.source, effectcraft_project::LayerSource::Text)).map(|pr| match pr.value_at(l.layer_time(t)) { + KV::Text(d) => effectcraft_project::text_layer_name(&d.text), + KV::Str(txt) => effectcraft_project::text_layer_name(&txt), + _ => String::new(), + }); + match text_name { + Some(first) if name.trim().is_empty() => { + l.name = if first.is_empty() { "Text".to_string() } else { first }; + l.name_follows_text = Some(true); + } + Some(_) if name != l.name => { + l.name = name.to_string(); + l.name_follows_text = Some(false); + } + _ => l.name = name.to_string(), + } +} + /// The Timeline's Comment column. pub(super) fn set_comment(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; diff --git a/crates/engine/src/cmd/layer/transform.rs b/crates/engine/src/cmd/layer/transform.rs index eb0f610f9..87403fb26 100644 --- a/crates/engine/src/cmd/layer/transform.rs +++ b/crates/engine/src/cmd/layer/transform.rs @@ -172,10 +172,12 @@ pub(super) fn layer_settings(s: &mut Session, p: &Value) -> Result { let pixel_aspect = pixel_aspect_p(p)?; let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true); + let t = s.time(); let solid = s.edit("Layer Settings", None, |proj, _| { let src = layer_mut(proj, cid, lid)?.source.clone(); if let Some(n) = &name { - layer_mut(proj, cid, lid)?.name = n.clone(); + // A user name: a text layer's name stops following its text (#3233). + super::switches::set_layer_name(layer_mut(proj, cid, lid)?, n, t); } let LayerSource::Solid { item } = src else { return Ok(None) }; let shared = proj.comps().flat_map(|(_, c)| &c.layers).filter(|l| l.source == src).count() > 1; diff --git a/crates/engine/src/cmd/text_edit.rs b/crates/engine/src/cmd/text_edit.rs index cdb650486..9240f0c1a 100644 --- a/crates/engine/src/cmd/text_edit.rs +++ b/crates/engine/src/cmd/text_edit.rs @@ -6,7 +6,7 @@ //! Positions are character indices. The selection is `anchor..caret` (either order). use effectcraft_keyframe::{CharStyle, TextDoc, Value as KV}; -use effectcraft_project::{ItemId, LayerId, LayerSource}; +use effectcraft_project::{ItemId, LayerId, LayerSource, text_layer_name}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -288,10 +288,6 @@ fn session_on(s: &mut Session, lid: LayerId) -> TextEdit { } } -fn first_line_name(text: &str) -> String { - text.lines().next().unwrap_or("").chars().take(40).collect() -} - /// Edit a layer's Source Text at the CTI (one undo step, or merged by `merge`), then set the /// session's selection. pub(crate) fn edit_doc( @@ -319,12 +315,13 @@ pub(crate) fn edit_doc( // already has) must not turn a static Str into Text or add a keyframe at the CTI: that // would change the project and record an empty undo step. if doc != original { - let before = first_line_name(&original.text); - let after = first_line_name(&doc.text); + let before = text_layer_name(&original.text); + let after = text_layer_name(&doc.text); pr.set_value_at(lt, KV::Text(Box::new(doc))); - // Text layers are named after their text until renamed. - if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) { + // Text layers are named after their text until renamed (#3233). + if before != after && !after.is_empty() && l.name_follows_text(&before) { l.name = after; + l.name_follows_text = Some(true); } } Ok(()) diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index eb1de6f89..bcb4f0559 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -1417,3 +1417,102 @@ fn duplicate_of_an_empty_type_tool_layer_ends_the_edit_and_copies_it() { assert_eq!(layer_count(&s), before + 1, "the empty layer is kept, as text.endEdit keeps it, and copied"); assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "and the copy is selected"); } + +fn layer_name(s: &Session, t: u64) -> String { + s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().name.clone() +} + +/// Replace the text with `text` in the on-canvas editor (everything selected, typed, committed). +fn retype(s: &mut Session, t: u64, text: &str) { + begin_edit(s, t); + s.execute("text.insert", json!({"text": text})).unwrap(); + s.execute("text.endEdit", json!({})).unwrap(); +} + +/// #3233: a text layer's name follows its Source Text, edited on canvas or by `layer.setText`, +/// until the user renames it (After Effects). A user name stays, even one that starts with +/// "Text", and so does a copy's; renaming to an empty name follows the text again. +#[test] +fn a_text_layer_name_follows_its_text_until_renamed() { + let (mut s, t) = session("Hello"); + assert_eq!(layer_name(&s, t), "Hello"); + retype(&mut s, t, "World"); + assert_eq!(layer_name(&s, t), "World", "a new text layer follows its text"); + s.execute("layer.setText", json!({"layer": t, "text": "Again"})).unwrap(); + assert_eq!(layer_name(&s, t), "Again"); + s.execute("layer.rename", json!({"layer": t, "name": "Text Title"})).unwrap(); + s.execute("layer.setText", json!({"layer": t, "text": "Changed"})).unwrap(); + assert_eq!(layer_name(&s, t), "Text Title", "layer.setText keeps the user's name"); + retype(&mut s, t, "Typed"); + assert_eq!(layer_name(&s, t), "Text Title", "typing keeps the user's name"); + s.execute("layer.select", json!({"layers": [t]})).unwrap(); + let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap(); + let copy_name = layer_name(&s, copy); + s.execute("layer.setText", json!({"layer": copy, "text": "Copied"})).unwrap(); + assert_eq!(layer_name(&s, copy), copy_name, "a copy keeps the user's name too"); + // Renaming to an empty name: the name is the text's again, and follows it. + s.execute("layer.rename", json!({"layer": t, "name": ""})).unwrap(); + assert_eq!(layer_name(&s, t), "Typed", "an empty name shows the text"); + retype(&mut s, t, "Followed"); + assert_eq!(layer_name(&s, t), "Followed", "and follows it again"); + // Committing the unchanged name (Enter, Enter) does not stop the following. + s.execute("layer.rename", json!({"layer": t, "name": "Followed"})).unwrap(); + s.execute("layer.setText", json!({"layer": t, "text": "Still"})).unwrap(); + assert_eq!(layer_name(&s, t), "Still"); + // A name given in Layer Settings is the user's too. The UI offers Layer Settings only for + // solids, nulls, cameras and lights (as After Effects does); this guards the command, which + // agents can call on any layer. + s.execute("layer.settings", json!({"layer": t, "name": "Settings Name"})).unwrap(); + s.execute("layer.setText", json!({"layer": t, "text": "Later"})).unwrap(); + assert_eq!(layer_name(&s, t), "Settings Name", "Layer Settings names it for good"); + // A layer given a name when it is made does not follow. + let named = s.execute("layer.newText", json!({"text": "Body", "name": "Caption"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setText", json!({"layer": named, "text": "Other"})).unwrap(); + assert_eq!(layer_name(&s, named), "Caption"); +} + +/// #3233: After Effects breaks paragraphs with a bare carriage return; the name is the first +/// paragraph, without the break, and an older layer named after it counts as following. +#[test] +fn a_text_layer_name_ends_at_a_bare_carriage_return() { + let (mut s, t) = session("A\rB"); + assert_eq!(layer_name(&s, t), "A"); + s.execute("layer.setText", json!({"layer": t, "text": "C\rD"})).unwrap(); + assert_eq!(layer_name(&s, t), "C"); + retype(&mut s, t, "E\rF"); + assert_eq!(layer_name(&s, t), "E"); + let cid = s.active_comp_id().unwrap(); + let l = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(effectcraft_project::LayerId(t)).unwrap(); + l.name = "Para1".into(); + l.name_follows_text = None; + l.props.prop_mut("text/sourceText").unwrap().value = effectcraft_keyframe::Value::Text(Box::new(TextDoc::plain("Para1\rPara2"))); + s.execute("layer.setText", json!({"layer": t, "text": "Next\rLine"})).unwrap(); + assert_eq!(layer_name(&s, t), "Next", "an older layer named after its first paragraph follows"); +} + +/// #3233: projects saved before the state was recorded load without it; there a name that is +/// the text's, empty, or the default "Text"/"Text 2"… follows the text, any other name stays. +#[test] +fn older_text_layers_without_the_name_state_follow_as_before() { + let (mut s, t) = session("Hello"); + let mut v = serde_json::to_value(s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap()).unwrap(); + v.as_object_mut().unwrap().remove("name_follows_text"); + let old: effectcraft_project::Layer = serde_json::from_value(v).unwrap(); + assert_eq!(old.name_follows_text, None, "an older project has no state"); + for (name, follows) in [("Hello", true), ("", true), ("Text", true), ("Text 3", true), ("Text Title", false), ("Text 3b", false), ("Title", false)] { + let l = effectcraft_project::Layer { name: name.into(), ..old.clone() }; + assert_eq!(l.name_follows_text("Hello"), follows, "{name:?}"); + } + let legacy = |s: &mut Session, name: &str| { + let cid = s.active_comp_id().unwrap(); + let l = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(effectcraft_project::LayerId(t)).unwrap(); + l.name = name.into(); + l.name_follows_text = None; + }; + legacy(&mut s, "Hello"); + s.execute("layer.setText", json!({"layer": t, "text": "World"})).unwrap(); + assert_eq!(layer_name(&s, t), "World", "a name equal to the text follows it"); + legacy(&mut s, "Title"); + retype(&mut s, t, "Typed"); + assert_eq!(layer_name(&s, t), "Title", "another name stays"); +} diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 1aa8d7c90..7a960dbfb 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -935,6 +935,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource Layer { id, name: comp.unique_layer_name(name), + name_follows_text: None, source, label, comment: String::new(), diff --git a/crates/project/src/layer.rs b/crates/project/src/layer.rs index 34eeb50c0..f7b3b5e51 100644 --- a/crates/project/src/layer.rs +++ b/crates/project/src/layer.rs @@ -299,6 +299,8 @@ impl Layer { let Layer { id, name, + // Whether the name follows the text changes no pixels until the name changes. + name_follows_text: _, source, label, comment, @@ -474,6 +476,11 @@ pub enum AutoOrient { pub struct Layer { pub id: LayerId, pub name: String, + /// A text layer's name follows the first line of its Source Text until the user renames it + /// (After Effects). `Some(true)`: it follows, `Some(false)`: the user named it. `None` (older + /// projects, layers made before this was recorded): see [`Layer::name_follows_text()`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name_follows_text: Option, pub source: LayerSource, pub label: Label, #[serde(default)] @@ -512,7 +519,26 @@ pub struct Layer { pub props: PropGroup, } +/// The name a text layer takes from its text: the first 40 characters of the first line. A line +/// ends at a line feed or at a bare carriage return (After Effects' paragraph break). +pub fn text_layer_name(text: &str) -> String { + text.split(['\r', '\n']).next().unwrap_or("").chars().take(40).collect() +} + +/// A text layer's default name when its text gives none: "Text", numbered "Text 2", "Text 3"… +fn is_default_text_name(name: &str) -> bool { + name == "Text" || name.strip_prefix("Text ").is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())) +} + impl Layer { + /// Whether this text layer's name follows its Source Text, whose first line gives the name + /// `current` (see [`text_layer_name`]). Without a recorded state (an older project), a name + /// that is empty, the text's own, or the default "Text"/"Text 2"… follows; any other name was + /// given by the user. + pub fn name_follows_text(&self, current: &str) -> bool { + self.name_follows_text.unwrap_or_else(|| self.name.is_empty() || self.name == current || is_default_text_name(&self.name)) + } + /// The layers this layer reads: its parent, track matte, layer parameters (Displacement Map, /// Audio Spectrum's Audio Layer…) and tracker targets. pub fn referenced_layers(&self) -> Vec { diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 079b81765..fabcd607f 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -43,7 +43,7 @@ pub use item::NO_LAYER; pub use item::{Heard, Item, ItemId, ItemKind, LayerId}; pub use layer::{ AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte, is_audio_effect, - reads_other_frames, + reads_other_frames, text_layer_name, }; pub use layers::Layers; pub use settings::{ diff --git a/docs/agents.md b/docs/agents.md index ac77439b7..27664cb73 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -713,6 +713,8 @@ in the Plug-in Library: [publishing-plugins.md](publishing-plugins.md). step (separated X/Y/Z Position; Orientation and X/Y/Z Rotation on 3D layers). * Rename a layer with Enter (or double-click a name): the whole name is selected, Enter or a click elsewhere commits, Escape cancels; `layer.rename {layer, name}` does it directly. + A text layer's name follows its text until it is renamed; renaming it to an empty name makes + it follow the text again (After Effects). * Ctrl/Cmd-click on the current-time display (Timeline or Composition panel) toggles the Time Display Style between Timecode and Frames: `file.projectSettings {"timeDisplay": "frames"}`. * `prop.pickWhip {layer, prop, target: {layer, prop, dimension?}}` links a property as its From 77c3fad6c5800ec951359cd031c4ec215f8b15e8 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 02:32:11 +0100 Subject: [PATCH 2/3] #3233: an empty Layer Settings name follows the text again; document the name rule on layer.newText, layer.setText and layer.settings --- crates/engine/src/cmd/layer.rs | 6 +++--- crates/engine/src/cmd/layer/transform.rs | 7 ++++++- crates/engine/src/tests/text_edit.rs | 5 +++++ docs/agents.md | 5 ++++- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index b015b72b4..f48fa26f8 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -49,7 +49,7 @@ pub fn specs() -> Vec { "Text", ["Layer", "New"], Some("Cmd+Alt+Shift+T"), - "{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?, lineJoin?} (attributes as layer.setText)", + "{text?, name? (layer name; default the text, which the name then follows; a given name stays), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?, lineJoin?} (attributes as layer.setText)", has_comp, new_text ), @@ -70,7 +70,7 @@ pub fn specs() -> Vec { "Layer Settings...", ["Layer"], Some("Cmd+Shift+Y"), - "{layer?, name?, color?, width?, height?, pixelAspect?, affectAll?: bool (default true; false gives this layer its own copy of a solid other layers use)} → {layer, solid?}", + "{layer?, name? (an empty name makes a text layer's name follow its text again), color?, width?, height?, pixelAspect?, affectAll?: bool (default true; false gives this layer its own copy of a solid other layers use)} → {layer, solid?}", has_layer_settings, layer_settings ), @@ -218,7 +218,7 @@ pub fn specs() -> Vec { "Edit Text", [], None, - "{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, lineJoin?: miter|round|bevel, box?: [x,y,w,h]|null (layer space), vertical?}", + "{layer?, range?: [start, end] (characters; default all), text? (also renames a text layer whose name still follows its text), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, lineJoin?: miter|round|bevel, box?: [x,y,w,h]|null (layer space), vertical?}", has_layers, set_text ), diff --git a/crates/engine/src/cmd/layer/transform.rs b/crates/engine/src/cmd/layer/transform.rs index 87403fb26..acccfc7df 100644 --- a/crates/engine/src/cmd/layer/transform.rs +++ b/crates/engine/src/cmd/layer/transform.rs @@ -157,7 +157,7 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result { /// layers, which are solids) edit their Solid Settings. With `affectAll: false`, a solid other /// layers also use is copied into a new solid for this layer instead of being changed for all of /// them (After Effects' "Affect all layers that use this solid"). `name` renames the layer and -/// its solid. +/// its solid; an empty one makes a text layer's name follow its text again. pub(super) fn layer_settings(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.settings")?; // Cameras and lights open their own settings (Camera Settings / Light Settings). @@ -171,6 +171,9 @@ pub(super) fn layer_settings(s: &mut Session, p: &Value) -> Result { let h = p.get("height").and_then(Value::as_u64).map(limits::size); let pixel_aspect = pixel_aspect_p(p)?; let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); + // An empty name gives a text layer its text's name again, as `layer.rename` does (#3233); + // other layers keep theirs. + let follow_text = str_p(p, "name").is_some_and(|n| n.trim().is_empty()); let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true); let t = s.time(); let solid = s.edit("Layer Settings", None, |proj, _| { @@ -178,6 +181,8 @@ pub(super) fn layer_settings(s: &mut Session, p: &Value) -> Result { if let Some(n) = &name { // A user name: a text layer's name stops following its text (#3233). super::switches::set_layer_name(layer_mut(proj, cid, lid)?, n, t); + } else if follow_text && matches!(src, LayerSource::Text) { + super::switches::set_layer_name(layer_mut(proj, cid, lid)?, "", t); } let LayerSource::Solid { item } = src else { return Ok(None) }; let shared = proj.comps().flat_map(|(_, c)| &c.layers).filter(|l| l.source == src).count() > 1; diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index bcb4f0559..db8d4679a 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -1465,6 +1465,11 @@ fn a_text_layer_name_follows_its_text_until_renamed() { s.execute("layer.settings", json!({"layer": t, "name": "Settings Name"})).unwrap(); s.execute("layer.setText", json!({"layer": t, "text": "Later"})).unwrap(); assert_eq!(layer_name(&s, t), "Settings Name", "Layer Settings names it for good"); + // An empty Layer Settings name follows the text again, as `layer.rename` does. + s.execute("layer.settings", json!({"layer": t, "name": ""})).unwrap(); + assert_eq!(layer_name(&s, t), "Later", "an empty Layer Settings name shows the text"); + s.execute("layer.setText", json!({"layer": t, "text": "Follows"})).unwrap(); + assert_eq!(layer_name(&s, t), "Follows", "and follows it again"); // A layer given a name when it is made does not follow. let named = s.execute("layer.newText", json!({"text": "Body", "name": "Caption"})).unwrap()["layer"].as_u64().unwrap(); s.execute("layer.setText", json!({"layer": named, "text": "Other"})).unwrap(); diff --git a/docs/agents.md b/docs/agents.md index 27664cb73..ebf158029 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -714,7 +714,10 @@ in the Plug-in Library: [publishing-plugins.md](publishing-plugins.md). * Rename a layer with Enter (or double-click a name): the whole name is selected, Enter or a click elsewhere commits, Escape cancels; `layer.rename {layer, name}` does it directly. A text layer's name follows its text until it is renamed; renaming it to an empty name makes - it follow the text again (After Effects). + it follow the text again (After Effects). `layer.setText` and typing on canvas rename a text + layer whose name still follows its text; `layer.newText {name}` and a name set by + `layer.settings` or a script's `layer.name = …` (which runs `layer.rename`) stay, and an empty + `layer.settings` name follows the text again too. * Ctrl/Cmd-click on the current-time display (Timeline or Composition panel) toggles the Time Display Style between Timecode and Frames: `file.projectSettings {"timeDisplay": "frames"}`. * `prop.pickWhip {layer, prop, target: {layer, prop, dimension?}}` links a property as its From c8ccf807e1996728bb14d4c3b412a673e1c9673a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 04:53:01 +0100 Subject: [PATCH 3/3] #3233: AEP import names an unnamed text layer as a following name is cut, so export and import agree --- crates/aep/src/import/text.rs | 7 ++++--- crates/aep/tests/it/export/text.rs | 21 +++++++++++++++++++++ docs/aep-import.md | 2 +- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/aep/src/import/text.rs b/crates/aep/src/import/text.rs index d9f75d5db..5333efcb0 100644 --- a/crates/aep/src/import/text.rs +++ b/crates/aep/src/import/text.rs @@ -26,10 +26,11 @@ pub(crate) const MAX_TEXT_CHARS: usize = 1 << 20; /// U+0003 (END OF TEXT): After Effects' other paragraph break besides `\r`. const ETX: char = '\u{3}'; -/// The first paragraph of a stored text, at most 64 characters: the name After Effects shows for -/// a text layer without a name of its own (U+0003 ends a paragraph like `\r`). +/// The name of a text layer without a name of its own: its first paragraph, cut as EffectCraft +/// cuts a following name ([`effectcraft_project::text_layer_name`]), so a following name that +/// export writes as no name comes back unchanged (#3233). U+0003 ends a paragraph like `\r`. pub(crate) fn first_paragraph(raw: &str) -> String { - raw.split(['\r', ETX]).next().unwrap_or_default().chars().take(64).collect() + effectcraft_project::text_layer_name(raw.split(ETX).next().unwrap_or_default()) } /// The Source Text value of `doc`, with what cannot be mapped reported for the layer `what`. diff --git a/crates/aep/tests/it/export/text.rs b/crates/aep/tests/it/export/text.rs index 32e55a269..befc2f47e 100644 --- a/crates/aep/tests/it/export/text.rs +++ b/crates/aep/tests/it/export/text.rs @@ -238,6 +238,27 @@ fn a_text_layer_name_follows_its_text_through_export_and_import() { assert_eq!((l.name.as_str(), l.name_follows_text), ("Text", Some(true)), "an empty text layer"); } +/// #3233: a following name cut from a first line longer than 40 characters is written as no name +/// and comes back as the same cut name, still following; a long name the user gave stays whole. +#[test] +fn a_long_first_line_names_a_text_layer_the_same_after_export_and_import() { + let line = "A headline that runs past forty characters, to 64"; + assert!((41..=64).contains(&line.chars().count())); + let text = format!("{line}\rSecond"); + let cut = effectcraft_project::text_layer_name(&text); + assert_eq!(cut.chars().count(), 40); + for (name, follows, back_name) in [(cut.as_str(), Some(true), cut.as_str()), (line, Some(false), line)] { + let (mut p, c) = text_project(vec![TextDoc::plain(&text)]); + with_layer(&mut p, c, 0, |l| { + l.name = name.into(); + l.name_follows_text = follows; + }); + let back = reimport(&export_all(&p).bytes); + let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer"); + assert_eq!((l.name.as_str(), l.name_follows_text), (back_name, follows), "{name:?}"); + } +} + #[test] fn what_a_text_document_cannot_hold_is_reported() { let mut d = TextDoc::plain("x"); diff --git a/docs/aep-import.md b/docs/aep-import.md index ed3ab57f5..9320d408c 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -51,7 +51,7 @@ refused losses) leaves the project unchanged. | Project settings | colour depth, working space (None, sRGB, Rec. 709 Gamma 2.4; another one is reported as `project settings`), working gamma, Linearize Working Space, Blend Colors Using 1.0 Gamma, time display (timecode, frames, feet + frames 35 / 16 mm), frame count start (620-634). A colour depth, time display, frames-per-foot or frame count start code outside the sampled ones keeps the current value and is reported as `project settings`. After Effects renders in the working space (902: in a Rec. 709 Gamma 2.4 project an sRGB PNG step of 128 comes out as 135 and a grey 0.5 solid as 128, in the Render Queue output and in the captures), while EffectCraft converts its output to `output_space` (sRGB when unset): a Rec. 709 working space therefore also sets the output space to Rec. 709 (902 within 1 level of After Effects, was up to 13). They come in `ImportResult::settings`; the library never changes the project's own settings, `file.importAep` applies them when the project has no compositions yet (or with `applySettings: true`) | | Composition | name, size, exact frame rate, duration, work area, background colour (8 bit), pixel aspect, composition motion blur switch, shutter angle and phase (as stored), display start time, Classic/Advanced 3D renderer | -| Layers | solid, null, adjustment, footage, precomposition and **text** layers: name (the source's name when the layer has none; for a text layer its first line), in/out/start and time stretch (exact ratios, e.g. 50 % and 150 %); a layer of a clip, sequence or sound ends at its source's end as After Effects ends it (unless remapped, looped or less than a frame past; f1), enabled/audio/solo/shy/locked/effects/motion blur/collapse/3D/guide switches, draft quality, label, blend mode (Normal, Multiply, Add, Screen, Overlay, Difference; Soft Light, Hard Light, Darken, Lighten, Exclusion, Color Dodge, Color Burn by name, 906; Classic Difference, Hue, Saturation, Color, Luminosity, the Stencil and Silhouette modes, Classic Color Dodge / Burn, Linear Dodge / Burn, Linear, Vivid and Pin Light, Hard Mix by render, 907: each code rendered by After Effects matched exactly one EffectCraft mode; 906 renders within 1/255 of After Effects), parent, **track matte** (alpha, alpha inverted, luma, luma inverted; the matte layer anywhere in the composition, also below its target; 402-408) | +| Layers | solid, null, adjustment, footage, precomposition and **text** layers: name (the source's name when the layer has none; for a text layer the first 40 characters of its first line, as EffectCraft names a text layer after its text), in/out/start and time stretch (exact ratios, e.g. 50 % and 150 %); a layer of a clip, sequence or sound ends at its source's end as After Effects ends it (unless remapped, looped or less than a frame past; f1), enabled/audio/solo/shy/locked/effects/motion blur/collapse/3D/guide switches, draft quality, label, blend mode (Normal, Multiply, Add, Screen, Overlay, Difference; Soft Light, Hard Light, Darken, Lighten, Exclusion, Color Dodge, Color Burn by name, 906; Classic Difference, Hue, Saturation, Color, Luminosity, the Stencil and Silhouette modes, Classic Color Dodge / Burn, Linear Dodge / Burn, Linear, Vivid and Pin Light, Hard Mix by render, 907: each code rendered by After Effects matched exactly one EffectCraft mode; 906 renders within 1/255 of After Effects), parent, **track matte** (alpha, alpha inverted, luma, luma inverted; the matte layer anywhere in the composition, also below its target; 402-408) | | Transform | Anchor Point, Position, Scale, Rotation, Opacity, **X and Y Rotation** (486, 487) and **Orientation**, static (488) or keyed (703: key values, times, linear and hold sides; a bezier side becomes linear and is reported; After Effects slerps between the keys, EffectCraft interpolates each angle, so in-between values differ, e.g. 703 at 1 s `[6.26, 9.35, 15.22]` against `[5, 10, 15]`; an expression-driven orientation is reported): the stored value, or the default when the file stores none; keyframes with time, linear/bezier/hold on each side, temporal ease (speed and influence, per dimension for Scale), automatic or manual spatial tangents, roving and key labels | | Expressions | on the transform and mask properties and on effect, camera and light parameters (534): source text and enable switch | | Cameras and lights | **cameras** (one-node and two-node, from `ldta` byte 38 bit `0x40`): point of interest, Position as stored (also the auto Position `[0, 0, -zoom]`, where After Effects renders it, 537), orientation and rotations, Zoom (absent: `-z` of the auto Position, else the 50 mm default), Depth of Field, Focus Distance (absent: the zoom), Aperture (absent: 25.309 at 1920 px, read back from 256; scaled with the width, which is not sampled, E); **spot, point, parallel and ambient lights** (light type 1, 2, 0, 3: 701, 702): point of interest (not for point and ambient lights), Position, Intensity, Color, Cone Angle, Cone Feather, Casts Shadows; keys and expressions as for the transform; 251-256, 341-345, 535-537, 701, 702. Environment lights are reported |