#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
This commit is contained in:
vangel76
2026-11-07 15:41:14 +01:00
parent bb2d2005ce
commit 61af9e5b72
14 changed files with 236 additions and 23 deletions
+3 -2
View File
@@ -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::<String>() == name)
&& layer.name_follows_text(&effectcraft_project::text_layer_name(&tp.text))
{
name.clear();
}
+10 -1
View File
@@ -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<u32, String>, 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);
+42
View File
@@ -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");
+9 -1
View File
@@ -114,7 +114,15 @@ pub fn specs() -> Vec<CommandSpec> {
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!(
+6 -6
View File
@@ -207,14 +207,14 @@ pub(super) fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
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(())
})?;
+3 -1
View File
@@ -187,8 +187,10 @@ pub(super) fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
}
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()));
}
+25 -1
View File
@@ -139,13 +139,37 @@ fn drop_3d_values(l: &mut Layer) {
pub(super) fn rename(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
let (cid, ids) = layers_p(s, p)?;
+3 -1
View File
@@ -172,10 +172,12 @@ pub(super) fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
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;
+6 -9
View File
@@ -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(())
+99
View File
@@ -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");
}
+1
View File
@@ -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(),
+26
View File
@@ -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<bool>,
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<LayerId> {
+1 -1
View File
@@ -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::{
+2
View File
@@ -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