Merge remote-tracking branch 'upstream/main' into text-layer-name-follow-3233

This commit is contained in:
vangel76 committed 2026-11-08 04:42:27 +01:00
commit e41d6d9d68
104 files changed
+6694 -1263

No files matched your search

+2
View File
@@ -35,6 +35,7 @@ mod footage;
mod item;
mod layer;
mod layers;
mod reach;
mod settings;
pub use comp::{Comp, Guide, Renderer, Resolution};
@@ -46,6 +47,7 @@ pub use layer::{
reads_other_frames, text_layer_name,
};
pub use layers::Layers;
pub use reach::{ExprReach, PixelReach, expression_reach};
pub use settings::{
APP_VERSION, BitDepth, ColorEngine, CuePoint, HdrMode, Marker, ProjectError, ProjectSettings, SCHEMA_EFFECT_SPACE, SCHEMA_VERSION, SavedView,
TimeDisplayStyle, is_newer_version, saved_by, saved_view,
+405
View File
@@ -0,0 +1,405 @@
//! What a composition's pixels can depend on, from the project in memory: the items it draws
//! and, when expressions are among them, what those expressions can read (#3184). The disk
//! cache keys a comp's frames by exactly these items, so an edit elsewhere in the project keeps
//! them.
//!
//! An expression reaches its own comp's layers (`thisComp`, `thisLayer`), the sources of the
//! layers it can read (`layer.source`, a precomp's layers through it), and other items by name:
//! `comp("Name")` and `footage("Name")`. A name given as a plain string literal is followed; a
//! computed name (`comp("Shot " + index)`), the object model's constructors and internals
//! (`new Comp(7)`, `__h`), dynamic code (`eval`, `Function`) and anything the scanner can't read
//! for sure (a regular expression literal, a template string, an escape in a name) count as
//! reaching every item of the project. The scan errs on the side of reaching more: an item
//! wrongly left out would serve a stale frame, one wrongly let in only renders again.
//!
//! Known limit (`ponytail`): an expression that builds the names of the object model's private
//! fields at run time into a variable and writes them with a computed key it reads from that
//! variable could forge another comp's id. That is deliberate tampering with the internals, not
//! an expression one writes; the scan refuses every direct form of it (`__` names, the
//! constructors, `Object`, `JSON`, `call`/`apply`/`bind`, computed writes and computed keys).
//! Upgrade: have the expression host record which items an evaluation reached.
use std::collections::{BTreeMap, BTreeSet};
use crate::{Comp, ItemId, ItemKind, LayerId, Node, Project, PropGroup, Value};
/// What an expression's text can reach outside its own comp and the layers it can read.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ExprReach {
/// Only items named by string literals: comps by `comp("…")`, footage by `footage("…")`.
Names { comps: Vec<String>, footage: Vec<String> },
/// Any item of the project.
Anything,
}
/// Names that hand an expression another item by id, run code built at run time, or give it the
/// tools to forge the object model's private fields.
const REACHES_ANYTHING: &[&str] = &[
"Comp",
"Footage",
"Layer",
"Prop",
"Markers",
"eval",
"Function",
"globalThis",
"this",
"self",
"window",
"arguments",
"constructor",
"prototype",
"Object",
"Reflect",
"Proxy",
"JSON",
"call",
"apply",
"bind",
"class",
"with",
"import",
"require",
"fromCharCode",
"fromCodePoint",
"getOwnPropertyDescriptor",
"getOwnPropertyDescriptors",
"getPrototypeOf",
"setPrototypeOf",
"defineProperty",
];
/// Keywords after which a `/` starts a regular expression.
const BEFORE_REGEX: &[&str] = &["return", "typeof", "instanceof", "in", "of", "new", "delete", "void", "throw", "case", "do", "else", "yield", "await"];
/// Keywords whose `( … )` a regular expression may follow (`if (a) /x/.test(s)`).
const CONTROL: &[&str] = &["if", "while", "for", "with"];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Tok<'a> {
Ident(&'a str),
/// A string literal's text between its quotes; `plain` when it has no escape.
Str {
text: &'a str,
plain: bool,
},
Num,
Punct(u8),
}
/// The tokens of `text`, or `None` when it can't be read for sure.
fn tokens(text: &str) -> Option<Vec<Tok<'_>>> {
let b = text.as_bytes();
let mut out: Vec<Tok<'_>> = Vec::new();
// Per open `(`: whether a control keyword is before it; whether the last `)` closed one
// (only looked at right after a `)`).
let mut parens: Vec<bool> = Vec::new();
let mut control_close = false;
let mut i = 0;
while let Some(&c) = b.get(i) {
match c {
b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c => i += 1,
b'/' if b.get(i + 1) == Some(&b'/') => {
while b.get(i).is_some_and(|c| *c != b'\n') {
i += 1;
}
}
b'/' if b.get(i + 1) == Some(&b'*') => {
let end = text.get(i + 2..)?.find("*/")?;
i += 2 + end + 2;
}
b'/' => {
if regex_may_start(out.last(), control_close) {
return None;
}
out.push(Tok::Punct(c));
i += 1;
}
b'"' | b'\'' => {
let start = i + 1;
let mut j = start;
let mut plain = true;
loop {
match *b.get(j)? {
q if q == c => break,
b'\n' | b'\r' => return None,
b'\\' => {
// Only escapes that can't spell a name: `\n`, `\t`, a quote...
if !matches!(b.get(j + 1)?, b'n' | b'r' | b't' | b'b' | b'f' | b'v' | b'\'' | b'"' | b'\\') {
return None;
}
plain = false;
j += 2;
}
_ => j += 1,
}
}
let s = text.get(start..j)?;
if s.contains("__") {
return None;
}
out.push(Tok::Str { text: s, plain });
i = j + 1;
}
b'0'..=b'9' => {
while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'.') {
i += 1;
}
out.push(Tok::Num);
}
b'.' if b.get(i + 1).is_some_and(u8::is_ascii_digit) => {
i += 1;
while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'.') {
i += 1;
}
out.push(Tok::Num);
}
c if c.is_ascii_alphabetic() || c == b'_' || c == b'$' => {
let start = i;
while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'$') {
i += 1;
}
out.push(Tok::Ident(text.get(start..i)?));
}
// A template string (code inside `${}`), an escaped identifier, a name in another
// alphabet: not followed.
b'`' | b'\\' => return None,
c if !c.is_ascii() => return None,
c => {
match c {
b'(' => parens.push(matches!(out.last(), Some(Tok::Ident(k)) if CONTROL.contains(k))),
b')' => control_close = parens.pop().unwrap_or(false),
_ => {}
}
out.push(Tok::Punct(c));
i += 1;
}
}
}
Some(out)
}
/// Whether a `/` after `prev` may start a regular expression literal (which the scanner doesn't
/// read: its text could hide a quote).
fn regex_may_start(prev: Option<&Tok<'_>>, control_close: bool) -> bool {
match prev {
None => true,
Some(Tok::Ident(k)) => BEFORE_REGEX.contains(k),
Some(Tok::Num | Tok::Str { .. }) => false,
Some(Tok::Punct(b')')) => control_close,
Some(Tok::Punct(b']')) => false,
Some(Tok::Punct(_)) => true,
}
}
/// What the expression `text` can reach by name, or [`ExprReach::Anything`].
pub fn expression_reach(text: &str) -> ExprReach {
expression_names(text).map_or(ExprReach::Anything, |(comps, footage)| ExprReach::Names { comps, footage })
}
/// The comp names and footage names an expression references by literal name.
/// Returns `None` when it can reach anything by a computed name or cannot be tokenised.
fn expression_names(text: &str) -> Option<(Vec<String>, Vec<String>)> {
let toks = tokens(text)?;
let (mut comps, mut footage) = (Vec::new(), Vec::new());
// Per open `[`: a computed key (`{[k]: v}`), a computed member (`o[k]`).
let mut brackets: Vec<(bool, bool)> = Vec::new();
for (i, t) in toks.iter().enumerate() {
let prev = i.checked_sub(1).and_then(|p| toks.get(p));
let next = |n: usize| toks.get(i + n);
match *t {
Tok::Ident(name) if name.starts_with("__") || REACHES_ANYTHING.contains(&name) => return None,
Tok::Ident(name @ ("comp" | "footage")) => {
// Only a call with a plain literal, not a property of something else.
let (Some(Tok::Punct(b'(')), Some(Tok::Str { text, plain: true }), Some(Tok::Punct(b')'))) = (next(1), next(2), next(3)) else { return None };
if matches!(prev, Some(Tok::Punct(b'.'))) {
return None;
}
if name == "comp" { comps.push((*text).to_string()) } else { footage.push((*text).to_string()) }
}
Tok::Punct(b'[') => {
let key = matches!(prev, Some(Tok::Punct(b'{' | b',')) | Some(Tok::Ident("get" | "set" | "static" | "async")) | Some(Tok::Punct(b'*')));
let member = matches!(prev, Some(Tok::Ident(_) | Tok::Str { .. } | Tok::Num | Tok::Punct(b')' | b']' | b'.')));
brackets.push((key, member));
}
Tok::Str { .. } if brackets.last().is_some_and(|(_, member)| *member) => return None,
Tok::Punct(b']') => {
let (key, _) = brackets.pop().unwrap_or((true, true));
// A computed key (`{[k]: v}`, `{[k]() {}}`) or a write through brackets (`o[k] = v`,
// `o[k] ||= v`, `for (o[k] in …)`, `[o[k]] = v`) could name a private field.
if key && matches!(next(1), Some(Tok::Punct(b':' | b'('))) {
return None;
}
if assigns(&toks, i + 1) || matches!(next(1), Some(Tok::Ident("in" | "of"))) {
return None;
}
}
// A destructuring write (`({a: o[k]} = v)`).
Tok::Punct(b'}') if assigns(&toks, i + 1) => return None,
_ => {}
}
}
Some((comps, footage))
}
/// Whether the tokens from `at` are an assignment operator (`=`, `+=`, `>>>=`, `??=`...), not a
/// comparison (`==`, `<=`) or an arrow (`=>`).
fn assigns(toks: &[Tok<'_>], at: usize) -> bool {
let mut op = Vec::new();
for t in toks.iter().skip(at).take(5) {
match t {
Tok::Punct(c) if b"+-*/%&|^<>?=".contains(c) => op.push(*c),
_ => break,
}
}
let Some(eq) = op.iter().position(|c| *c == b'=') else { return false };
let (before, after) = op.split_at(eq);
if after.get(1).is_some_and(|c| *c == b'=' || *c == b'>') {
return false;
}
matches!(before, b"" | b"+" | b"-" | b"*" | b"/" | b"%" | b"&" | b"|" | b"^" | b"**" | b"<<" | b">>" | b">>>" | b"&&" | b"||" | b"??")
}
/// The items a comp's pixels can depend on ([`Project::pixel_reach`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PixelReach {
/// The items whose state counts; `None`: every item of the project.
pub items: Option<BTreeSet<ItemId>>,
/// Expressions are among them: they can read every layer of the comps they reach (hidden
/// and silent ones too) and the markers, which therefore count as well (#1083).
pub expressions: bool,
/// The names expressions look items up by (`true`: a comp's, `false`: footage's), each with
/// the ids of the items of that kind that have it now, in id order (the first one is what
/// the lookup finds; another item taking or giving up the name changes it).
pub names: Vec<(bool, String, Vec<ItemId>)>,
}
/// Each property of a group and its subgroups (iterative: no depth limit to hit).
fn each_prop<'a>(g: &'a PropGroup, f: &mut dyn FnMut(&'a crate::Property) -> Option<()>) -> Option<()> {
let mut stack = vec![g];
while let Some(g) = stack.pop() {
for n in &g.children {
match n {
Node::Prop(p) => f(p)?,
Node::Group(sub) => stack.push(sub),
}
}
}
Some(())
}
impl Project {
/// The items comp `comp` draws ([`Self::silent_layers`] aside, with `heard`) and the
/// footage its precomp layers swap in (Essential Graphics Media Replacement); with an
/// expression in any of its comps, everything those expressions can reach
/// ([`expression_reach`]). Memory only: no file is looked at.
pub fn pixel_reach(&self, comp: ItemId, heard: &crate::Heard) -> PixelReach {
let Some(drawn) = self.reach_walk(comp, &|id, c| self.silent_layers(id, c, heard), &mut |_| Some(vec![])) else { return PixelReach::every() };
let has_expression = |g: &PropGroup| each_prop(g, &mut |p| p.expr.is_none().then_some(())).is_none();
if !drawn.iter().filter_map(|id| self.comp(*id)).any(|c| c.layers.iter().any(|l| has_expression(&l.props))) {
return PixelReach { items: Some(drawn), expressions: false, names: vec![] };
}
// Expressions: every layer of a comp they reach counts, and what they name.
let mut names: BTreeMap<(bool, String), Vec<ItemId>> = BTreeMap::new();
let mut scan = |p: &crate::Property| match p.expr.as_ref().map(|e| expression_reach(&e.text)) {
None => Some(vec![]),
Some(ExprReach::Anything) => None,
Some(ExprReach::Names { comps, footage }) => {
let mut found = vec![];
for (is_comp, name) in comps.into_iter().map(|n| (true, n)).chain(footage.into_iter().map(|n| (false, n))) {
let ids = self.named(is_comp, &name);
found.extend(ids.iter().copied());
names.insert((is_comp, name), ids);
}
Some(found)
}
};
let Some(mut items) = self.reach_walk(comp, &|_, _| BTreeSet::new(), &mut scan) else { return PixelReach::every() };
// A source's data is read by its name (`layer.source.sourceData`).
let footage_names: BTreeSet<&str> =
items.iter().filter_map(|id| self.item(*id)).filter(|i| matches!(i.kind, ItemKind::Footage(_))).map(|i| i.name.as_str()).collect();
let same_name: Vec<ItemId> =
self.items.values().filter(|i| matches!(i.kind, ItemKind::Footage(_)) && footage_names.contains(i.name.as_str())).map(|i| i.id).collect();
items.extend(same_name);
PixelReach { items: Some(items), expressions: true, names: names.into_iter().map(|((c, n), ids)| (c, n, ids)).collect() }
}
/// The items of one kind (`is_comp`: comps, else footage) named `name`, in id order.
fn named(&self, is_comp: bool, name: &str) -> Vec<ItemId> {
self.items
.values()
.filter(|i| i.name == name && if is_comp { matches!(i.kind, ItemKind::Comp(_)) } else { matches!(i.kind, ItemKind::Footage(_)) })
.map(|i| i.id)
.collect()
}
/// The items reached from `comp` through its layers (but those `quiet` names) and the items
/// `scan` returns for each of their properties; `None` when something reaches every item.
/// (A stack and a visited set: each item is visited once, cycles included.)
fn reach_walk(
&self,
comp: ItemId,
quiet: &dyn Fn(ItemId, &Comp) -> BTreeSet<LayerId>,
scan: &mut dyn FnMut(&crate::Property) -> Option<Vec<ItemId>>,
) -> Option<BTreeSet<ItemId>> {
let mut seen = BTreeSet::new();
let mut stack = vec![comp];
while let Some(id) = stack.pop() {
if !seen.insert(id) {
continue;
}
let Some(c) = self.comp(id) else { continue };
let quiet = quiet(id, c);
for l in c.layers.iter().filter(|l| !quiet.contains(&l.id)) {
// Every source item: a 3D model's too (it is drawn, and `layer.source` reads its
// name and size).
if let Some(item) = l.source.item() {
stack.push(item);
}
stack.extend(self.media_replacements(l)?);
let mut reached = vec![];
each_prop(&l.props, &mut |p| {
reached.extend(scan(p)?);
Some(())
})?;
stack.extend(reached);
}
}
Some(seen)
}
/// The footage a precomp layer swaps in through its Essential Properties (Media Replacement):
/// the items its overridden properties name. `None` when they can't be listed (an
/// expression or keyframes choose the item over time).
fn media_replacements(&self, layer: &crate::Layer) -> Option<Vec<ItemId>> {
let over = crate::essential::overridden(layer);
let Some(g) = crate::essential::group(layer).filter(|_| !over.is_empty()) else { return Some(vec![]) };
let mut out = vec![];
each_prop(g, &mut |p| {
if !over.contains(&p.uid) {
return Some(());
}
if p.expr.is_some() || !p.keys.is_empty() {
return None;
}
if let Value::Scalar(id) = p.value {
let item = ItemId(id.max(0.0) as u64);
if matches!(self.item(item).map(|i| &i.kind), Some(ItemKind::Footage(_))) {
out.push(item);
}
}
Some(())
})?;
Some(out)
}
}
impl PixelReach {
/// Every item of the project, with expressions.
fn every() -> PixelReach {
PixelReach { items: None, expressions: true, names: vec![] }
}
}
#[cfg(test)]
mod tests;
+198
View File
@@ -0,0 +1,198 @@
use super::*;
use crate::{Expression, LayerSource, Solid, build};
use effectcraft_time::{FrameRate, Tick};
fn names(comps: &[&str], footage: &[&str]) -> ExprReach {
ExprReach::Names { comps: comps.iter().map(|s| s.to_string()).collect(), footage: footage.iter().map(|s| s.to_string()).collect() }
}
/// Expressions that read their own comp, or other items by literal names, are followed; every
/// way to reach items by a computed name or an id, or text the scanner can't read for sure,
/// reaches everything.
#[test]
fn expression_reach_follows_literal_names_and_refuses_the_rest() {
let follows = [
("wiggle(2, 20)", names(&[], &[])),
("thisComp.layer(\"Comp 1 Layer\").transform.opacity * 2 / 3", names(&[], &[])),
("value + [10, 0]; value[0] / 2", names(&[], &[])),
("// comp(name) in a comment\n/* footage(x) */ value", names(&[], &[])),
("\"a string that says comp(x) and Layer\"", names(&[], &[])),
("comp(\"Other\").layer(1).transform.position", names(&["Other"], &[])),
("comp( 'A' ).width + comp(\"B\").height", names(&["A", "B"], &[])),
("footage(\"data.json\").sourceData.x", names(&[], &["data.json"])),
("x = a[0] <= b ? a[1] : 0; y = a[0] == 1", names(&[], &[])),
("thisLayer.source.layer(1).name", names(&[], &[])),
("if (a) x = 1; else x = 2; (a + b) / 2", names(&[], &[])),
("\"Ünïcödé name\"", names(&[], &[])),
("const f = (v) => v * 2; f(1)", names(&[], &[])),
];
for (text, want) in follows {
assert_eq!(expression_reach(text), want, "{text}");
}
let anything = [
"comp(\"Shot \" + index).layer(1)",
"comp(name)",
"var n = \"Other\"; comp(n)",
"comp(\"O\\\"ther\")",
"x.comp(\"Other\")",
"var comp = 1",
"footage(thisComp.name)",
"new Comp(7).layer(1)",
"Layer(7, 3).name",
"__h(__COMP, 7)",
"o.__c = 7",
"eval('1')",
"Function('return 1')()",
"this.x",
"({}).constructor",
"Object.getPrototypeOf(thisComp)",
"JSON.parse(s)",
"thisComp.layer.call(o, 1)",
"o['_' + '_c']",
"o[k] = 7",
"o[k] ||= 7",
"o[k] += 7",
"[o[k]] = [7]",
"({a: o[k]} = {a: 7})",
"for (o[k] in x) {}",
"({[k]: 7})",
"({a: 1, [k]: 7})",
"o = {'__c': 7}",
"'\\x5f\\x5fc'",
"'\\_\\_c'",
"`${x}`",
"c\\u006fmp('A')",
"comp\u{0430}('A')",
"/\"/.test(s); comp(n); \"",
"return /x/",
"if (a) /\"/.test(s)",
"x = '",
"/* comp(n)",
"class X {}",
"String.fromCharCode(95)",
];
for text in anything {
assert_eq!(expression_reach(text), ExprReach::Anything, "{text}");
}
}
/// `a / b` divides after a name, a number, a string and a closing bracket or parenthesis.
#[test]
fn division_is_not_a_regular_expression() {
for text in ["a / b", "2 / 3", "'a' / 2", "x[0] / 2", "(a + b) / 2", "f(x) / 2 / g(y)"] {
assert_eq!(expression_reach(text), names(&[], &[]), "{text}");
}
}
struct Fixture {
p: Project,
main: ItemId,
other: ItemId,
third: ItemId,
unused: ItemId,
solid: ItemId,
}
/// Comps "Main" (a solid), "Other" (a solid), "Third" (empty), an unused footage item.
fn fixture() -> Fixture {
let mut p = Project::default();
let comp = Comp::new(64, 64, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let solid =
p.add_item("Solid", effectcraft_color::Label::None, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 64, height: 64, pixel_aspect: 1.0 }));
let main = p.add_item("Main", effectcraft_color::Label::None, None, ItemKind::Comp(comp.clone().into()));
let other = p.add_item("Other", effectcraft_color::Label::None, None, ItemKind::Comp(comp.clone().into()));
let third = p.add_item("Third", effectcraft_color::Label::None, None, ItemKind::Comp(comp.clone().into()));
let unused = p.add_item("unused.png", effectcraft_color::Label::None, None, ItemKind::Footage(crate::Footage { has_video: true, ..Default::default() }));
for c in [main, other] {
let l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: solid }, (64, 64), None);
p.comp_mut(c).unwrap().layers.push(l);
}
Fixture { p, main, other, third, unused, solid }
}
fn set_expr(p: &mut Project, comp: ItemId, text: &str) {
let l = p.comp_mut(comp).unwrap().layers.first_mut().unwrap();
l.props.prop_mut("transform/opacity").unwrap().expr = Some(Expression { text: text.into(), enabled: true });
}
fn reach(p: &Project, comp: ItemId) -> PixelReach {
p.pixel_reach(comp, &p.heard_layers())
}
#[test]
fn pixel_reach_is_what_the_comp_draws_and_what_its_expressions_name() {
let mut f = fixture();
let ids = |r: &PixelReach| r.items.clone().map(|s| s.into_iter().collect::<Vec<_>>());
assert_eq!(reach(&f.p, f.main), PixelReach { items: Some([f.solid, f.main].into()), expressions: false, names: vec![] });
// An expression reading only its own comp: what it draws, with expressions.
set_expr(&mut f.p, f.main, "thisComp.layer(1).transform.opacity");
let r = reach(&f.p, f.main);
assert!(r.expressions);
assert_eq!(ids(&r), Some(vec![f.solid, f.main]));
// An expression in another comp doesn't reach this one.
set_expr(&mut f.p, f.other, "wiggle(1, 1)");
assert_eq!(ids(&reach(&f.p, f.main)), Some(vec![f.solid, f.main]));
// A literal name: that comp and what it draws, with the name and the comps that have it.
set_expr(&mut f.p, f.main, "comp(\"Other\").layer(1).transform.opacity");
let r = reach(&f.p, f.main);
assert_eq!(ids(&r), Some(vec![f.solid, f.main, f.other]));
assert_eq!(r.names, vec![(true, "Other".to_string(), vec![f.other])]);
// Another comp taking the name is part of it, whichever the lookup finds.
f.p.item_mut(f.third).unwrap().name = "Other".into();
let r = reach(&f.p, f.main);
assert_eq!(r.names, vec![(true, "Other".to_string(), vec![f.other, f.third])]);
assert!(r.items.unwrap().contains(&f.third));
// A name no item has yet still counts (with no item).
set_expr(&mut f.p, f.main, "footage(\"later.json\").sourceData");
assert_eq!(reach(&f.p, f.main).names, vec![(false, "later.json".to_string(), vec![])]);
// A computed name reaches everything.
set_expr(&mut f.p, f.main, "comp(\"Ot\" + \"her\").width");
assert_eq!(reach(&f.p, f.main), PixelReach { items: None, expressions: true, names: vec![] });
// Footage reached as a layer's source brings the footage of its name (its data is read by
// name).
let comp = f.p.comp(f.main).unwrap().clone();
let l = build::layer(&mut f.p, &comp, "U", LayerSource::Footage { item: f.unused }, (64, 64), None);
f.p.comp_mut(f.main).unwrap().layers.push(l);
let twin = f.p.add_item("unused.png", effectcraft_color::Label::None, None, ItemKind::Footage(crate::Footage::default()));
set_expr(&mut f.p, f.main, "value");
let r = reach(&f.p, f.main).items.unwrap();
assert!(r.contains(&f.unused) && r.contains(&twin), "{r:?}");
}
/// A comp nested in the reach is scanned too: its expression's names count for the outer comp.
#[test]
fn nested_comps_bring_their_expressions_reach() {
let mut f = fixture();
let comp = f.p.comp(f.main).unwrap().clone();
let pre = build::layer(&mut f.p, &comp, "Pre", LayerSource::Comp { item: f.other }, (64, 64), None);
f.p.comp_mut(f.main).unwrap().layers.push(pre);
set_expr(&mut f.p, f.other, "comp(\"Third\").width");
let r = reach(&f.p, f.main);
assert!(r.expressions && r.items.unwrap().contains(&f.third));
// A cycle through names ends.
let l = build::layer(&mut f.p, &comp, "S", LayerSource::Solid { item: f.solid }, (64, 64), None);
f.p.comp_mut(f.third).unwrap().layers.push(l);
set_expr(&mut f.p, f.third, "comp(\"Main\").width");
assert!(reach(&f.p, f.main).items.unwrap().contains(&f.main));
}
/// A 3D model layer's item is reached like any other source: it is drawn, and an expression
/// reads its name and size through `layer.source` (a renamed model left the comp's disk key and
/// its old frames in place).
#[test]
fn model_sources_are_reached() {
let mut f = fixture();
let model = f.p.add_item(
"chair.glb",
effectcraft_color::Label::None,
None,
ItemKind::Footage(crate::Footage { kind: crate::FootageKind::Model, ..Default::default() }),
);
let comp = f.p.comp(f.main).unwrap().clone();
let l = build::layer(&mut f.p, &comp, "Model", LayerSource::Model { item: model }, (64, 64), None);
f.p.comp_mut(f.main).unwrap().layers.push(l);
assert!(reach(&f.p, f.main).items.unwrap().contains(&model), "drawn");
set_expr(&mut f.p, f.main, "thisComp.layer(\"Model\").source.name.length");
let r = reach(&f.p, f.main);
assert!(r.expressions && r.items.unwrap().contains(&model), "read by an expression");
}