CMP-7: Essential Graphics controls, master properties, .ectemplate templates, Responsive Design — Time

Comps expose controls (properties typed as text/color/slider/checkbox/point/angle/dropdown,
Media Replacement, groups, comments); precomp layers get an Essential Properties group whose
edits override per instance (Push to Comp / Revert); templates export/import as an open ZIP
(manifest.json + project subset + media + poster). Stretched precomps keep protected regions
at their original speed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 22:44:06 -04:00
co-authored by Claude Opus 5.5
parent ee8a1484bb
commit abd39f5fd8
25 changed files with 1862 additions and 5 deletions
+1
View File
@@ -330,6 +330,7 @@ mod tests {
missing: false,
sequence: vec![],
color_profile: None,
..Default::default()
};
let fid = p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(f));
let comp = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0));
+76
View File
@@ -100,6 +100,15 @@ impl<'a> EvalCtx<'a> {
if let Some(tr) = layer.props.get("timeRemap") {
return Tick::from_seconds_f64(self.value(layer, tr).as_f64());
}
// Responsive Design — Time: a time-stretched precomp keeps its protected regions at
// their original speed.
if let LayerSource::Comp { item } = layer.source
&& (layer.stretch - 100.0).abs() > 1e-9
&& let Some(nc) = self.project.comp(item)
&& let Some(t) = responsive_source_time(nc, layer.stretch / 100.0, (self.time - layer.start_time).seconds())
{
return t;
}
layer.layer_time(self.time)
}
pub fn layer(&self, id: LayerId) -> Option<&'a Layer> {
@@ -185,6 +194,73 @@ impl<'a> EvalCtx<'a> {
}
}
/// The protected regions of a comp (Responsive Design — Time markers), merged, sorted and
/// clamped to the comp, in seconds.
pub fn protected_regions(comp: &Comp) -> Vec<(f64, f64)> {
let d = comp.duration.seconds();
let mut v: Vec<(f64, f64)> = comp
.markers
.iter()
.filter(|m| m.protected && m.duration > Tick::ZERO)
.map(|m| (m.time.seconds().clamp(0.0, d), (m.time + m.duration).seconds().clamp(0.0, d)))
.filter(|(a, b)| b > a)
.collect();
v.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut out: Vec<(f64, f64)> = vec![];
for (a, b) in v {
match out.last_mut() {
Some(last) if a <= last.1 => last.1 = last.1.max(b),
_ => out.push((a, b)),
}
}
out
}
/// Source time of a precomp time-stretched by `stretch` (1 = 100%), `elapsed` comp seconds
/// after the layer's start, with Responsive Design — Time: protected regions play at their
/// original speed and the unprotected parts absorb the whole stretch. `None` when the comp has
/// no protected regions or the stretch can't be absorbed (reversed, or shorter than the
/// protected regions), so the plain stretch applies.
pub fn responsive_source_time(comp: &Comp, stretch: f64, elapsed: f64) -> Option<Tick> {
let regions = protected_regions(comp);
if regions.is_empty() || stretch <= 0.0 {
return None;
}
let d = comp.duration.seconds();
let lp: f64 = regions.iter().map(|(a, b)| b - a).sum();
let lu = d - lp;
let k = if lu > 1e-9 { (d * stretch - lp) / lu } else { 1.0 };
if k <= 1e-9 {
return None;
}
if elapsed < 0.0 {
return Some(Tick::from_seconds_f64(elapsed / k));
}
// Segments in source order: (start, end, protected).
let mut segs = vec![];
let mut at = 0.0;
for (a, b) in &regions {
if *a > at {
segs.push((at, *a, false));
}
segs.push((*a, *b, true));
at = *b;
}
if d > at {
segs.push((at, d, false));
}
let mut pos = 0.0;
for (a, b, prot) in segs {
let speed = if prot { 1.0 } else { k };
let len = (b - a) * speed;
if elapsed < pos + len {
return Some(Tick::from_seconds_f64(a + (elapsed - pos) / speed));
}
pos += len;
}
Some(Tick::from_seconds_f64(d + (elapsed - pos) / k))
}
/// Size of a layer's source in layer pixels.
pub fn source_size(project: &Project, layer: &Layer) -> (u32, u32) {
match &layer.source {
+29 -1
View File
@@ -154,6 +154,22 @@ impl EffectHost for FxHost<'_, '_, '_> {
}
}
/// The Essential Properties overrides of a precomp layer, evaluated at the context time.
pub fn essential_overrides(ctx: &EvalCtx, layer: &Layer) -> Vec<effectcraft_project::essential::Override> {
use effectcraft_project::essential;
let over = essential::overridden(layer);
let Some(g) = essential::group(layer).filter(|_| !over.is_empty()) else { return vec![] };
let mut out = vec![];
g.walk("", &mut |_, p| {
if over.contains(&p.uid)
&& let Some(control) = essential::control_of(&p.match_id)
{
out.push(essential::Override { control, value: ctx.value(layer, p) });
}
});
out
}
/// No footage available (renders footage layers as transparent).
pub struct NoFootage;
impl FootageSource for NoFootage {
@@ -332,6 +348,10 @@ impl<'a> Renderer<'a> {
if styles::active(ctx, layer) {
return None;
}
// Instance overrides (Essential Properties) need the nested comp rendered on its own.
if !effectcraft_project::essential::overridden(layer).is_empty() {
return None;
}
Some(*item)
}
@@ -655,7 +675,15 @@ impl<'a> Renderer<'a> {
if self.project.comp_contains(*item, ctx.comp_id) {
return None;
}
let sub = self.nested();
// Essential Properties overrides render the nested comp with this instance's
// values.
let ov = essential_overrides(ctx, layer);
let tmp = if ov.is_empty() { None } else { effectcraft_project::essential::with_overrides(self.project, *item, &ov) };
let base = self.nested();
let sub = match &tmp {
Some(p) => Renderer { project: p, ..base },
None => base,
};
let lt = ctx.source_time(layer);
// Frame blending between the nested comp's frames (time-stretched/remapped).
let mode = frame_blend_mode(ctx, layer);
+1
View File
@@ -51,6 +51,7 @@ fn audio_footage() -> Footage {
missing: false,
sequence: vec![],
color_profile: None,
..Default::default()
}
}
+1
View File
@@ -172,6 +172,7 @@ fn footage(profile: Option<ColorSpace>) -> Footage {
missing: false,
sequence: vec![],
color_profile: profile,
..Default::default()
}
}
+1
View File
@@ -80,6 +80,7 @@ fn setup(mode: FrameBlend, size: (u32, u32)) -> (Project, ItemId) {
missing: false,
sequence: vec![],
color_profile: None,
..Default::default()
};
let fid = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(f));
let mut l = build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, size, None);