mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 10:20:47 -06:00
Merge pull request #3248 from vangel76/text-layer-name-follow-3233
Text layers: the name follows the text until the user renames the layer
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -497,7 +497,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());
|
||||
@@ -555,6 +560,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);
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -196,6 +196,69 @@ 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");
|
||||
}
|
||||
|
||||
/// #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");
|
||||
|
||||
@@ -49,7 +49,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
@@ -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!(
|
||||
@@ -210,7 +218,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -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(())
|
||||
})?;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -157,7 +157,7 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// 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<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "layer.settings")?;
|
||||
// Cameras and lights open their own settings (Camera Settings / Light Settings).
|
||||
@@ -171,11 +171,18 @@ pub(super) fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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, _| {
|
||||
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);
|
||||
} 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;
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -1417,3 +1417,107 @@ 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");
|
||||
// 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();
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
@@ -488,6 +490,11 @@ fn scaled(t: Tick, mul: f64, div: f64) -> Tick {
|
||||
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)]
|
||||
@@ -527,7 +534,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> {
|
||||
|
||||
@@ -44,7 +44,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 reach::{ExprReach, PixelReach, expression_reach};
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
@@ -713,6 +713,11 @@ 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). `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
|
||||
|
||||
Reference in New Issue
Block a user