mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 22:42:24 -06:00
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:
co-authored by
Claude Opus 5.5
parent
ee8a1484bb
commit
abd39f5fd8
@@ -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));
|
||||
|
||||
@@ -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 ®ions {
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -51,6 +51,7 @@ fn audio_footage() -> Footage {
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ fn footage(profile: Option<ColorSpace>) -> Footage {
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: profile,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user