effectcraft/crates/engine/src/roto.rs

666 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Roto Brush & Refine Edge runtime: propagation and Freeze jobs, Input Key maintenance and the
//! tool options.
//!
//! Segmentations are derived data in `effectcraft_effects::roto`'s content-keyed cache (see that
//! module). `roto.propagate` fills it at full resolution in the background (or blocking with
//! `wait`; in the browser the background is a Web Worker whose segmentations stream back,
//! [`crate::offload`]), frame by frame outward from the base frame, rendering the effect's input
//! (`Renderer::layer_input`, source → masks → the effects above it). `roto.freeze` runs the same
//! pass and stores the final mattes in the effect (one undo step). [`sync`], run after every
//! edit, keeps each instance's hidden Input Key equal to a signature of what its frames are made
//! of (the layer's source and timing, its masks and the effects above it) and drops frozen
//! mattes made from other frames.
use crate::offload::JobKind;
use std::collections::{BTreeMap, HashMap};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_effects::roto::{self as fx, FROZEN, FrozenCache, INPUT_KEY};
use effectcraft_effects::{Params, flatten_params};
use effectcraft_keyframe::Value;
use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, Project, PropGroup, Uid};
use effectcraft_raster::Image;
use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
use effectcraft_time::Tick;
use serde::{Deserialize, Serialize};
use crate::tracking::lock;
use crate::{Event, Session};
/// Roto Brush / Refine Edge tool options (serde: `roto.options`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct RotoOptions {
/// Roto Brush tip diameter (layer pixels).
pub diameter: f64,
/// Refine Edge tip diameter (layer pixels).
pub refine_diameter: f64,
/// Layer panel view: `alphaBoundary` (pink outline), `alpha`, `alphaOverlay`, `none`.
pub view: String,
/// Alpha Overlay colour (straight RGB 0–1) and opacity (percent).
pub overlay_color: [f64; 3],
pub overlay_opacity: f64,
/// Alpha Boundary colour.
pub boundary_color: [f64; 3],
/// Start propagating in the background after each stroke.
pub auto_propagate: bool,
}
impl Default for RotoOptions {
fn default() -> Self {
RotoOptions {
diameter: 20.0,
refine_diameter: 10.0,
view: "alphaBoundary".into(),
overlay_color: [1.0, 0.0, 0.0],
overlay_opacity: 50.0,
boundary_color: [1.0, 0.25, 0.75],
auto_propagate: true,
}
}
}
pub const VIEWS: [&str; 4] = ["alphaBoundary", "alpha", "alphaOverlay", "none"];
/// What a job does.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RotoTask {
Propagate,
Freeze,
}
/// Which way to propagate.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Direction {
Forward,
Backward,
#[default]
Both,
}
impl Direction {
pub fn from_name(s: &str) -> Option<Direction> {
Some(match s.to_ascii_lowercase().as_str() {
"forward" | "fwd" => Direction::Forward,
"backward" | "back" => Direction::Backward,
"both" | "all" => Direction::Both,
_ => return None,
})
}
}
/// Live job progress (serde for agents / the control channel).
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RotoProgress {
pub task: Option<RotoTask>,
pub done: u64,
pub total: u64,
pub elapsed: f64,
pub fps: f64,
pub finished: bool,
pub cancelled: bool,
pub error: Option<String>,
}
impl RotoProgress {
pub fn banner(&self) -> String {
let pc = (100 * self.done).checked_div(self.total).unwrap_or(0);
match self.task {
Some(RotoTask::Freeze) => format!("Freezing Roto Brush & Refine Edge: {pc}%"),
_ => format!("Propagating Roto Brush: {pc}%"),
}
}
}
#[derive(Default)]
pub struct RotoShared {
pub cancel: AtomicBool,
pub state: Mutex<RotoProgress>,
frozen: Mutex<Option<FrozenCache>>,
}
pub struct RotoJob {
pub shared: Arc<RotoShared>,
thread: Option<std::thread::JoinHandle<()>>,
pub comp: ItemId,
pub layer: LayerId,
pub effect: Uid,
pub task: RotoTask,
/// The strokes the job was started with (a Freeze result is dropped when they changed).
strokes: String,
}
impl RotoJob {
pub fn is_finished(&self) -> bool {
lock(&self.shared.state).finished
}
pub fn progress(&self) -> RotoProgress {
lock(&self.shared.state).clone()
}
}
pub(crate) fn is_roto(g: &PropGroup) -> bool {
matches!(&g.kind, GroupKind::Effect { effect } if effect == fx::ID)
}
fn str_of(g: &PropGroup, m: &str) -> String {
match g.get(m).map(|p| &p.value) {
Some(Value::Str(s)) => s.clone(),
_ => String::new(),
}
}
fn set_str(g: &mut PropGroup, m: &str, v: String) {
if let Some(p) = g.get_mut(m) {
p.value = Value::Str(v);
p.keys.clear();
}
}
fn fnv(s: &str) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in s.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
h
}
/// What the effect's input frames are made of: the layer's source and timing, its masks and the
/// effects above the Roto Brush.
pub fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String {
let mut s = crate::warp::signature(p, comp, layer);
if let Some(m) = layer.masks() {
s.push_str(&format!("{m:?}"));
}
if let Some(fx) = layer.effects() {
for g in fx.groups() {
if g.uid == uid {
break;
}
s.push_str(&format!("{g:?}"));
}
}
format!("{:016x}", fnv(&s))
}
/// After an edit: refresh Input Keys; frozen mattes made from other frames are dropped.
/// Returns the instances whose segmentation changed (to propagate again).
pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerId, Uid)> {
let mut todo = vec![];
let mut set = vec![];
for (cid, it) in &after.items {
let ItemKind::Comp(c) = &it.kind else { continue };
for l in &c.layers {
let Some(fxg) = l.effects() else { continue };
for g in fxg.groups().filter(|g| is_roto(g)) {
let key = input_key(after, *cid, l, g.uid);
let old = before.comp(*cid).and_then(|bc| bc.layer(l.id)).and_then(|ol| ol.props.find_group(g.uid));
if str_of(g, INPUT_KEY) != key {
set.push((*cid, l.id, g.uid, key));
todo.push((*cid, l.id, g.uid));
} else if old.is_none_or(|o| str_of(o, fx::STROKES) != str_of(g, fx::STROKES)) {
todo.push((*cid, l.id, g.uid));
}
}
}
}
for (cid, lid, uid, key) in set {
if let Some(g) = after.comp_mut(cid).and_then(|c| c.layer_mut(lid)).and_then(|l| l.props.find_group_mut(uid)) {
let had = !str_of(g, INPUT_KEY).is_empty();
set_str(g, INPUT_KEY, key);
if had {
set_str(g, FROZEN, String::new());
}
}
}
todo.retain(|(c, l, u)| {
after.comp(*c).and_then(|c| c.layer(*l)).and_then(|l| l.props.find_group(*u)).is_some_and(|g| !fx::data(&params_static(g)).is_empty())
});
todo
}
/// Parameters of an instance with static values (strokes and keys are never animated).
pub(crate) fn params_static(g: &PropGroup) -> Params {
flatten_params(g, &mut |p| p.value.clone())
}
/// Evaluated parameters of an instance at comp time `t`.
pub(crate) fn params_at(p: &Project, cid: ItemId, layer: &Layer, g: &PropGroup, t: Tick, expr: Option<&dyn ExprHost>) -> Params {
match p.comp(cid) {
Some(c) => {
let ctx = EvalCtx { expr, ..EvalCtx::new(p, cid, c, t) };
flatten_params(g, &mut |pr| ctx.value(layer, pr))
}
None => params_static(g),
}
}
/// The layer's source size (layer pixels).
pub(crate) fn layer_size(p: &Project, comp: &effectcraft_project::Comp, layer: &Layer) -> [f64; 2] {
let (w, h) = effectcraft_render::source_size(p, layer);
if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] }
}
/// First and last layer frame of the layer inside the comp (inclusive).
pub fn frame_limits(comp: &effectcraft_project::Comp, layer: &Layer) -> [i64; 2] {
let fps = comp.frame_rate.as_f64().max(1e-6);
let lo = layer.layer_time(layer.in_point.max(Tick::ZERO)).seconds();
let hi = layer.layer_time(layer.out_point.min(comp.duration)).seconds();
let a = fx::frame_of(lo, fps);
let b = (fx::frame_of(hi, fps) - 1).max(a);
[a, b]
}
/// Comp time of layer frame `f`.
pub(crate) fn comp_time_of(comp: &effectcraft_project::Comp, layer: &Layer, f: i64) -> Tick {
layer.comp_time(comp.frame_rate.tick_of(f))
}
/// What the worker needs, detached from the session.
struct Work {
project: Arc<Project>,
footage: Arc<dyn FootageSource>,
expr: Option<Arc<dyn ExprHost>>,
cache: Arc<LayerCache>,
comp: ItemId,
layer: LayerId,
effect_index: usize,
params: Params,
size: [f64; 2],
fps: f64,
frames: Vec<i64>,
task: RotoTask,
}
/// Renders the effect's input frames for a worker, keeping the last few. With a browser job
/// worker's GPU (deferred readbacks), [`FrameSource::prefetch`] renders the frames a step
/// will read in passes beforehand; [`FrameSource::get`] renders on the CPU what it lacks.
pub(crate) struct FrameSource<'a> {
r: Renderer<'a>,
accel: Option<&'a dyn effectcraft_render::Accelerator>,
/// Frames in `memo`, oldest first.
order: std::collections::VecDeque<i64>,
ctx0: EvalCtx<'a>,
comp: &'a effectcraft_project::Comp,
layer: &'a Layer,
index: usize,
size: [f64; 2],
memo: HashMap<i64, Arc<Image>>,
}
impl<'a> FrameSource<'a> {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
project: &'a Project,
footage: &'a dyn FootageSource,
expr: Option<&'a dyn ExprHost>,
cache: &'a LayerCache,
cid: ItemId,
comp: &'a effectcraft_project::Comp,
layer: &'a Layer,
index: usize,
size: [f64; 2],
) -> FrameSource<'a> {
let mut r = Renderer::new(project, footage, RenderOpts { scale: 1.0, ..Default::default() });
r.expr = expr;
r.cache = Some(cache);
let ctx0 = EvalCtx { expr, ..EvalCtx::new(project, cid, comp, Tick::ZERO) };
FrameSource { r, accel: None, order: Default::default(), ctx0, comp, layer, index, size, memo: HashMap::new() }
}
/// Render with `accel` (deferred readbacks: see [`FrameSource::prefetch`]).
pub(crate) fn with_accel(mut self, accel: Option<&'a dyn effectcraft_render::Accelerator>) -> Self {
self.accel = accel;
self.r.opts = crate::offload::analysis_opts(accel);
self
}
/// Render frames `fs` (those not kept yet) in passes on the GPU, so the step that reads
/// them finds them ([`FrameSource::get`] can't wait for the GPU).
pub(crate) async fn prefetch(&mut self, fs: &[i64]) {
let Some(a) = self.accel else { return };
for &f in fs {
if self.memo.contains_key(&f) {
continue;
}
let this = &*self;
let img = crate::offload::in_passes(Some(a), |acc| this.render(f, &this.r.with_accel(acc))).await;
if let Some(img) = img {
self.keep(f, img);
}
}
}
fn keep(&mut self, f: i64, img: Arc<Image>) {
while self.order.len() >= 8 {
if let Some(old) = self.order.pop_front() {
self.memo.remove(&old);
}
}
self.order.push_back(f);
self.memo.insert(f, img);
}
pub(crate) fn get(&mut self, f: i64) -> Option<Arc<Image>> {
if let Some(i) = self.memo.get(&f) {
return Some(i.clone());
}
// Not prefetched: the CPU (a pass of the GPU could only return placeholders here).
let img = self.render(f, &self.r.with_accel(None))?;
self.keep(f, img.clone());
Some(img)
}
fn render(&self, f: i64, r: &Renderer) -> Option<Arc<Image>> {
let t = comp_time_of(self.comp, self.layer, f);
let buf = r.layer_input(&self.ctx0.at(t), self.layer, self.index)?;
let buf = if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 {
let w = (buf.img.width as f64 / buf.scale).round().max(1.0) as u32;
let h = (buf.img.height as f64 / buf.scale).round().max(1.0) as u32;
effectcraft_effects::Buf {
img: effectcraft_raster::resample(&buf.img, w, h),
offset: [buf.offset[0] / buf.scale, buf.offset[1] / buf.scale],
scale: 1.0,
}
} else {
(*buf).clone()
};
Some(Arc::new(fx::layer_grid(&buf, self.size)))
}
}
async fn run_work(w: Work, shared: &RotoShared) {
let t0 = web_time::Instant::now();
// A browser job worker's GPU (deferred readbacks): input frames render in passes.
let accel = crate::offload::job_accel();
let fail = |msg: &str| {
let mut s = lock(&shared.state);
s.error = Some(msg.to_string());
s.finished = true;
};
let Some(comp) = w.project.comp(w.comp) else { return fail("composition missing") };
let Some(layer) = comp.layer(w.layer) else { return fail("layer missing") };
let mut src =
FrameSource::new(&w.project, w.footage.as_ref(), w.expr.as_deref(), &w.cache, w.comp, comp, layer, w.effect_index, w.size).with_accel(accel.as_deref());
{
let mut s = lock(&shared.state);
s.task = Some(w.task);
s.total = w.frames.len() as u64;
}
let cancel = || shared.cancel.load(Ordering::Relaxed);
let chain = fx::Chain::new(&w.params, w.size, w.fps, 1.0);
let mut frozen = FrozenCache { key: fx::frozen_key(&w.params, w.size, w.fps), ..Default::default() };
for (k, f) in w.frames.iter().enumerate() {
if cancel() {
let mut s = lock(&shared.state);
s.cancelled = true;
s.finished = true;
return;
}
// The frame and its neighbours (what a step reads), on the GPU.
src.prefetch(&[*f, f - 1, f + 1]).await;
let mut get = |g: i64| src.get(g);
let ok = match w.task {
RotoTask::Propagate => chain.ensure(*f, &mut get, &cancel).is_some(),
RotoTask::Freeze => match fx::frame_matte(&w.params, w.size, w.fps, *f, &mut get, &cancel) {
Some((a, b)) => {
if let Some(img) = src.get(*f) {
frozen.size = [img.width as usize, img.height as usize];
}
frozen.frames.insert(*f, fx::freeze_frame(&a, &b));
true
}
None => false,
},
};
if !ok && !cancel() {
return fail(&format!("frame {f} could not be segmented"));
}
let mut s = lock(&shared.state);
s.done = k as u64 + 1;
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
crate::offload::report(s.done, s.total);
}
if w.task == RotoTask::Freeze {
*lock(&shared.frozen) = Some(frozen);
}
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
s.finished = true;
}
/// Frames to compute, outward from the base frame.
pub(crate) fn job_frames(d: &effectcraft_track::roto::RotoData, dir: Direction) -> Vec<i64> {
let Some(base) = d.base else { return vec![] };
let mut v = vec![base];
if dir != Direction::Backward {
v.extend(base + 1..=d.span[1]);
}
if dir != Direction::Forward {
v.extend((d.span[0]..base).rev());
}
v
}
impl Session {
/// The Roto Brush job running in a worker (Freeze or propagation), with its task.
fn offloaded_roto(&self) -> Option<(&crate::offload::OffloadedJob, RotoTask)> {
self.offloaded(JobKind::RotoFreeze).map(|j| (j, RotoTask::Freeze)).or_else(|| self.offloaded(JobKind::RotoPropagate).map(|j| (j, RotoTask::Propagate)))
}
pub fn is_roto_running(&self) -> bool {
self.roto_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded_roto().is_some()
}
pub fn roto_progress(&self) -> Option<RotoProgress> {
self.roto_job.as_ref().map(|j| j.progress()).or_else(|| {
self.offloaded_roto().map(|(j, task)| {
let p = &j.progress;
RotoProgress {
task: Some(task),
done: p.done,
total: p.total,
elapsed: p.elapsed,
fps: crate::offload::rate(p.done, p.elapsed),
..Default::default()
}
})
})
}
/// (comp, layer, effect uid, task) of the running job.
pub fn roto_target(&self) -> Option<(ItemId, LayerId, Uid, RotoTask)> {
self.roto_job.as_ref().map(|j| (j.comp, j.layer, j.effect, j.task)).or_else(|| self.offloaded_roto().map(|(j, task)| (j.comp, j.layer, j.uid, task)))
}
/// The segmentation chain a propagation of `effect` computes (its frames' cache keys).
pub fn roto_chain(&self, comp: ItemId, layer: LayerId, effect: Uid) -> Option<fx::Chain> {
let c = self.project.comp(comp)?;
let l = c.layer(layer)?;
let g = l.effects()?.groups().find(|g| g.uid == effect && is_roto(g))?;
let d = fx::data(&params_static(g));
let t = comp_time_of(c, l, d.base?);
let params = params_at(&self.project, comp, l, g, t, self.expr.as_deref());
Some(fx::Chain::new(&params, layer_size(&self.project, c, l), c.frame_rate.as_f64(), 1.0))
}
pub fn stop_roto(&mut self) -> bool {
if self.cancel_offloaded(JobKind::RotoFreeze) || self.cancel_offloaded(JobKind::RotoPropagate) {
return true;
}
match &self.roto_job {
Some(j) if !j.is_finished() => {
j.shared.cancel.store(true, Ordering::Relaxed);
true
}
_ => false,
}
}
/// Start propagating (or freezing) Roto Brush `effect` on `layer`. `wait`: block until done
/// (wasm32 always does). Returns the number of frames.
pub fn start_roto(&mut self, comp: ItemId, layer: LayerId, effect: Uid, task: RotoTask, dir: Direction, wait: bool) -> Result<usize, String> {
if self.is_roto_running() {
if !wait {
return Err("a Roto Brush job is already running".into());
}
self.stop_roto();
if let Some(mut j) = self.roto_job.take()
&& let Some(t) = j.thread.take()
{
let _ = t.join();
}
}
self.poll_roto_job();
self.roto_pending.retain(|x| *x != (comp, layer, effect));
let c = self.project.comp(comp).ok_or("no composition")?;
let l = c.layer(layer).ok_or("no layer")?;
let fxg = l.effects().ok_or("the layer has no effects")?;
let index = fxg.groups().position(|g| g.uid == effect).ok_or("no such effect")?;
let g = fxg.groups().nth(index).filter(|g| is_roto(g)).ok_or("not a Roto Brush & Refine Edge effect")?;
let size = layer_size(&self.project, c, l);
let fps = c.frame_rate.as_f64();
let pstat = params_static(g);
let d = fx::data(&pstat);
if d.is_empty() {
return Err("draw a foreground stroke with the Roto Brush tool first".into());
}
let t = comp_time_of(c, l, d.base.unwrap_or(0));
let params = params_at(&self.project, comp, l, g, t, self.expr.as_deref());
let frames = job_frames(&d, if task == RotoTask::Freeze { Direction::Both } else { dir });
let n = frames.len();
let work = Work {
project: self.project.clone(),
footage: self.footage.clone(),
expr: self.expr.clone(),
cache: self.layer_cache.clone(),
comp,
layer,
effect_index: index,
params,
size,
fps,
frames,
task,
};
let strokes = str_of(g, fx::STROKES);
if !wait && self.offloads() {
drop(work);
self.offload_analysis(match task {
RotoTask::Freeze => crate::offload::WorkerJob::RotoFreeze { comp, layer, effect },
RotoTask::Propagate => crate::offload::WorkerJob::RotoPropagate { comp, layer, effect, direction: dir },
})?;
return Ok(n);
}
let wait = wait || cfg!(target_arch = "wasm32");
let shared = Arc::new(RotoShared::default());
let thread = if wait {
crate::offload::run_or_queue({
let sh = shared.clone();
async move { run_work(work, &sh).await }
});
None
} else {
let sh = shared.clone();
Some(
std::thread::Builder::new()
.name("roto".into())
.spawn(move || effectcraft_render::passes::block_on(run_work(work, &sh)))
.map_err(|e| e.to_string())?,
)
};
self.roto_job = Some(RotoJob { shared, thread, comp, layer, effect, task, strokes });
self.events.push(Event::ProjectChanged { revision: self.revision });
self.poll_roto_job();
Ok(n)
}
/// Finish a done job: Freeze writes the frozen mattes (one undo step).
fn poll_roto_job(&mut self) -> bool {
let Some(job) = &self.roto_job else { return false };
if !job.is_finished() {
return false;
}
let Some(mut job) = self.roto_job.take() else { return false };
if let Some(t) = job.thread.take() {
let _ = t.join();
}
let st = job.progress();
if let Some(e) = &st.error {
self.events.push(Event::Toast { message: format!("Roto Brush: {e}"), error: true });
self.bump();
return true;
}
if st.cancelled {
self.events.push(Event::Toast { message: "Roto Brush: stopped".into(), error: false });
self.bump();
return true;
}
match job.task {
RotoTask::Propagate => {
self.events.push(Event::Toast { message: format!("Roto Brush: propagated {} frame(s) in {:.1} s", st.done, st.elapsed), error: false });
// Rendered frames depend on the cache: redraw.
self.bump();
}
RotoTask::Freeze => {
let Some(fc) = lock(&job.shared.frozen).take() else { return true };
let (cid, lid, uid, strokes) = (job.comp, job.layer, job.effect, job.strokes.clone());
let r = self.edit("Freeze", None, |p, _| {
let g = p
.comp_mut(cid)
.and_then(|c| c.layer_mut(lid))
.and_then(|l| l.props.find_group_mut(uid))
.ok_or_else(|| crate::EngineError::Other("the Roto Brush effect was removed".into()))?;
if str_of(g, fx::STROKES) != strokes {
return Err(crate::EngineError::Other("the strokes changed while freezing".into()));
}
set_str(g, FROZEN, fc.to_json());
Ok(())
});
let msg = match r {
Ok(()) => format!("Roto Brush: froze {} frame(s) in {:.1} s", fc.frames.len(), st.elapsed),
Err(e) => format!("Roto Brush: {e}"),
};
self.events.push(Event::Toast { message: msg, error: false });
}
}
true
}
/// Frontends call this every frame: finishes jobs and (with `auto`) propagates edited
/// instances in the background.
pub fn poll_roto(&mut self, auto: bool) -> bool {
self.poll_models();
let changed = self.poll_roto_job();
if auto && self.roto_job.is_none() && self.offloaded_roto().is_none() && self.state.roto.auto_propagate {
while let Some((c, l, e)) = self.roto_pending.first().copied() {
self.roto_pending.remove(0);
if self.start_roto(c, l, e, RotoTask::Propagate, Direction::Both, false).is_ok() {
return true;
}
}
}
changed
}
/// The computed frames of an instance (full resolution), by layer frame.
pub fn roto_computed(&self, comp: ItemId, layer: LayerId, effect: Uid) -> BTreeMap<i64, bool> {
let mut out = BTreeMap::new();
let Some(c) = self.project.comp(comp) else { return out };
let Some(l) = c.layer(layer) else { return out };
let Some(g) = l.props.find_group(effect) else { return out };
let ps = params_static(g);
let ch = fx::Chain::new(&ps, layer_size(&self.project, c, l), c.frame_rate.as_f64(), 1.0);
for f in ch.keys.keys() {
out.insert(*f, ch.is_cached(*f, 1.0));
}
out
}
}