mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 20:41:25 +00:00
* Plug-ins: API 2 (PluginHost, padding, layer_span) Split out of the OpenFX work so it can be reviewed on its own. Additive; the WebAssembly ABI stays at 1. - point3 / layer / text / hidden parameters, integer sliders, parameter groups. - EffectPlugin::padding (transparent pixels around the buffer, at most 4096 per side). - EffectPlugin::render_v2 with a PluginHost: the input at other times, other layers, parameters at other times, frame rate, per-instance key, the layer's source range. - EffectEnv::layer_span from the renderer; MAX_PLUGINS 1024. - plugin_error: a plug-in effect's last render error, for the UI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxacwTpUwqyfpcLgKW8yjS * Plug-ins: external effect helpers (crates/helper-host) Native plug-in formats run in separate helper programs, never in EffectCraft's process (docs/adr/0001-external-effect-helpers.md). New safe-Rust crate effectcraft-helper-host (L3, native only, unsafe forbidden): it finds helper programs, starts each as a child process and registers its effects through plug-in API 2, so the UI and command surface need nothing new. - Protocol v1 (docs/external-helpers.md): JSON lines over stdin / stdout (helper replies are prefixed so plug-in chatter is ignored); pixels go through a frame exchange file (/dev/shm on Linux), never through the pipe. Render-time callbacks for the input at other times, other layers and parameters at other times, with nested renders through the same helper. - A helper that exits, hangs past its timeout or breaks the protocol is stopped. The render fails with a message, the frame is left as it was, and the helper restarts for the next render. An effect that stops its helper 3 times is turned off (helper_blocklist.txt). - Opt-in: nothing starts helpers unless the user asks (Effect > Scan for Effect Helpers), or opts in to starting them at launch with EFFECTCRAFT_HELPERS=1, an environment variable, so writing to the settings folder alone can't run code. effect.helpers.scan / load are refused for confined agent sessions (confine.rs), like file.executeFile. - Discovery: helpers/ next to the app (Contents/Helpers on macOS), helpers/ in the settings folder, EFFECTCRAFT_HELPER_PATH. - Commands effect.helpers.list / scan / load; Effect menu entries (translated); the last render error of an effect shows as a warning sign after its name in Effect Controls. - serve(): the helper side for Rust helpers. tests/it/main.rs runs the whole protocol (render, padding, callbacks, nesting, threads, crash, restart, block list, hang) against a fake helper, with nothing to install. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxacwTpUwqyfpcLgKW8yjS * Effect helpers: address review (start failures, frame file mode, nextest listing) - A helper that can't start (not installed any more, restarting too often, no frame exchange file) is reported but no longer charged to the effect being rendered: it is Failure::Refused, not Failure::Broken, so it never counts toward turning that effect off. New check in tests/it: once the helper is out of restarts, renders of an effect fail without blocking it (fails without the fix: the effect was turned off after 3 such renders). - The frame exchange file is created owner-only (0o600) on Unix: it holds the user's frames and /dev/shm is shared. Asserted in the frames unit test. - tests/it (harness = false) speaks the libtest listing protocol cargo-nextest uses: `--list --format terse` prints `helper_protocol: test` (nothing with `--ignored`), and `helper_protocol --exact` runs the checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxacwTpUwqyfpcLgKW8yjS * Effect helpers: bound writes to a helper, drain stderr, keep callbacks in step - Writes to a helper go through a writer thread with the request timeout: a helper that stops reading its stdin (hung with a large callback answer or render request pending) no longer blocks the render thread forever while holding the helper. - stderr is drained as bytes (lossy, in 64 KiB pieces) to the end: output that isn't UTF-8 stopped the reader, closing the pipe under the helper (EPIPE / SIGPIPE on its next write); lines were unbounded. - Callback images that can't be written are answered with `null` instead of failing the render while the helper waits (which left the protocol out of step); a frame that can't be written before the request is sent is not charged to the effect. - Mid-render calls use the render's own process: no respawn or heartbeat ping while the helper is waiting for an answer. - Waiting for a busy helper is bounded (load timeout) instead of forever; status() no longer blocks on a running render. - Reap the child when start-up fails after spawning; cap the block list read. - Scripts start helpers only with Allow Scripts to Write Files and Access Network on (they run native code), as for File.execute(). - helper-host declares the workspace's no-panic crate lints; the desktop main() keeps its doc comment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Effect helpers: rustfmt the fake provider, build the test session in its initializer (clippy) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Plug-in Library: offer WebAssembly API 1 releases only; a helper already stopped mid-render isn't charged again - WasmPlugin loads only modules at the WebAssembly ABI version, which stays 1 while PLUGIN_API_VERSION is 2: share it as effects::plugin::WASM_API_VERSION and have the library's compatibility check use it, so it never installs an API 2 release that can't load. - with_running: a process that is already gone was stopped by what broke it (a nested render, charged there), so this render fails without charging its own effect. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * frames: plug-in effects count as reading other frames (plug-in API 2) With main's narrowed frame invalidation (#2795), an edit inside a precomp under an effect that reads its layer at other times must reach the layer's whole span. Plug-in API 2 gives every plug-in effect the input at other times, so any effect outside ec. counts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
616 lines
23 KiB
Rust
616 lines
23 KiB
Rust
use super::item::{ItemId, LayerId};
|
|
use super::settings::Marker;
|
|
use super::{BlendMode, Deserialize, GroupKind, Label, Node, ParamUi, PropGroup, Property, Serialize, Tick, Value};
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum LightKind {
|
|
Parallel,
|
|
Spot,
|
|
#[default]
|
|
Point,
|
|
Ambient,
|
|
Environment,
|
|
}
|
|
|
|
impl LightKind {
|
|
pub const ALL: [LightKind; 5] = [LightKind::Parallel, LightKind::Spot, LightKind::Point, LightKind::Ambient, LightKind::Environment];
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
LightKind::Parallel => "Parallel",
|
|
LightKind::Spot => "Spot",
|
|
LightKind::Point => "Point",
|
|
LightKind::Ambient => "Ambient",
|
|
LightKind::Environment => "Environment",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A parametric 3D primitive (Layer ▸ New ▸ Cube/Sphere/Plane/Torus/Cone/Cylinder).
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum PrimitiveKind {
|
|
#[default]
|
|
Cube,
|
|
Sphere,
|
|
Plane,
|
|
Torus,
|
|
Cone,
|
|
Cylinder,
|
|
}
|
|
|
|
impl PrimitiveKind {
|
|
pub const ALL: [PrimitiveKind; 6] =
|
|
[PrimitiveKind::Cube, PrimitiveKind::Sphere, PrimitiveKind::Plane, PrimitiveKind::Torus, PrimitiveKind::Cone, PrimitiveKind::Cylinder];
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
PrimitiveKind::Cube => "Cube",
|
|
PrimitiveKind::Sphere => "Sphere",
|
|
PrimitiveKind::Plane => "Plane",
|
|
PrimitiveKind::Torus => "Torus",
|
|
PrimitiveKind::Cone => "Cone",
|
|
PrimitiveKind::Cylinder => "Cylinder",
|
|
}
|
|
}
|
|
pub fn parse(s: &str) -> Option<PrimitiveKind> {
|
|
PrimitiveKind::ALL.into_iter().find(|k| k.label().eq_ignore_ascii_case(s.trim()))
|
|
}
|
|
}
|
|
|
|
/// What a layer shows.
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
#[serde(tag = "type")]
|
|
pub enum LayerSource {
|
|
/// Footage item (video, still, sequence, audio).
|
|
Footage {
|
|
item: ItemId,
|
|
},
|
|
/// Nested composition (precomp).
|
|
Comp {
|
|
item: ItemId,
|
|
},
|
|
/// Solid item (solids also back adjustment layers).
|
|
Solid {
|
|
item: ItemId,
|
|
},
|
|
Text,
|
|
Shape,
|
|
Null,
|
|
Camera,
|
|
Light {
|
|
kind: LightKind,
|
|
},
|
|
/// An imported 3D model (a footage item of kind [`FootageKind::Model`]; Advanced 3D).
|
|
Model {
|
|
item: ItemId,
|
|
},
|
|
/// A parametric 3D primitive (Advanced 3D).
|
|
Primitive {
|
|
kind: PrimitiveKind,
|
|
},
|
|
}
|
|
|
|
impl LayerSource {
|
|
pub fn item(&self) -> Option<ItemId> {
|
|
match self {
|
|
LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } | LayerSource::Model { item } => Some(*item),
|
|
_ => None,
|
|
}
|
|
}
|
|
pub fn type_name(&self) -> &'static str {
|
|
match self {
|
|
LayerSource::Footage { .. } => "Footage",
|
|
LayerSource::Comp { .. } => "Composition",
|
|
LayerSource::Solid { .. } => "Solid",
|
|
LayerSource::Text => "Text",
|
|
LayerSource::Shape => "Shape",
|
|
LayerSource::Null => "Null",
|
|
LayerSource::Camera => "Camera",
|
|
LayerSource::Light { .. } => "Light",
|
|
LayerSource::Model { .. } => "3D Model",
|
|
LayerSource::Primitive { .. } => "3D Primitive",
|
|
}
|
|
}
|
|
/// Layers with pixels (cameras, lights and nulls have none).
|
|
pub fn is_av(&self) -> bool {
|
|
!matches!(self, LayerSource::Camera | LayerSource::Light { .. } | LayerSource::Null)
|
|
}
|
|
/// A 3D model or primitive layer (meshes; drawn by the Advanced 3D renderer).
|
|
pub fn is_model(&self) -> bool {
|
|
matches!(self, LayerSource::Model { .. } | LayerSource::Primitive { .. })
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Quality {
|
|
#[default]
|
|
Best,
|
|
Draft,
|
|
Wireframe,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Sampling {
|
|
#[default]
|
|
Bilinear,
|
|
Bicubic,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum FrameBlend {
|
|
#[default]
|
|
Off,
|
|
FrameMix,
|
|
PixelMotion,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct Switches {
|
|
pub video: bool,
|
|
pub audio: bool,
|
|
pub solo: bool,
|
|
pub locked: bool,
|
|
pub shy: bool,
|
|
/// Collapse transformations (precomps) / continuously rasterize (vector layers).
|
|
pub collapse: bool,
|
|
pub quality: Quality,
|
|
pub sampling: Sampling,
|
|
pub effects: bool,
|
|
pub frame_blend: FrameBlend,
|
|
pub motion_blur: bool,
|
|
pub adjustment: bool,
|
|
pub three_d: bool,
|
|
pub guide: bool,
|
|
}
|
|
|
|
impl Default for Switches {
|
|
fn default() -> Self {
|
|
Switches {
|
|
video: true,
|
|
audio: true,
|
|
solo: false,
|
|
locked: false,
|
|
shy: false,
|
|
collapse: false,
|
|
quality: Quality::Best,
|
|
sampling: Sampling::Bilinear,
|
|
effects: true,
|
|
frame_blend: FrameBlend::Off,
|
|
motion_blur: false,
|
|
adjustment: false,
|
|
three_d: false,
|
|
guide: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
// What a frame's pixels depend on. Caches key on it: the Lock and Shy switches and Hide Shy
|
|
// Layers never change pixels, and the Audio switch, the Audio group and audio effects change them
|
|
// only when something draws the layer's audio (a waveform or spectrum picks it, see
|
|
// [`crate::Project::heard_layers`]), so editing those keeps cached frames (#103).
|
|
|
|
/// Is `id` an audio effect (applied by the audio mix rather than the compositor)?
|
|
pub fn is_audio_effect(id: &str) -> bool {
|
|
id.starts_with("ec.audio.")
|
|
}
|
|
|
|
/// Does effect `id` read its own layer at other times (`EffectHost::self_at`, `layer_at` on
|
|
/// itself): the time effects (Echo, Posterize Time, Timewarp…), simulations, temporal smoothing
|
|
/// and analysis (Remove Grain, Auto Levels, Color Stabilizer's reference frame, Warp Stabilizer,
|
|
/// Roto Brush…) and Clone strokes with a time offset? On a precomp layer, a change at one nested
|
|
/// time then reaches frames of the layer far from where it shows. Plug-in effects (every id
|
|
/// outside `ec.`) count too: plug-in API 2 reads the input at other times (`PluginHost`).
|
|
// ponytail: hand-kept list of the effects that call `self_at`; a new one must be added here.
|
|
pub fn reads_other_frames(id: &str) -> bool {
|
|
!id.starts_with("ec.") || id.starts_with("ec.time.") || id.starts_with("ec.sim.") || READS_OTHER_FRAMES.contains(&id)
|
|
}
|
|
|
|
/// The effects outside `ec.time.` and `ec.sim.` that read their own layer at other times.
|
|
const READS_OTHER_FRAMES: &[&str] = &[
|
|
"ec.noise.removegrain",
|
|
"ec.color.autolevels",
|
|
"ec.color.autocontrast",
|
|
"ec.color.autocolor",
|
|
"ec.color.shadowhighlight",
|
|
"ec.color.colorstabilizer",
|
|
"ec.matte.refinesoft",
|
|
"ec.matte.refinehard",
|
|
"ec.matte.rotobrush",
|
|
"ec.matte.objectmatte",
|
|
"ec.blur.camerashakedeblur",
|
|
"ec.distort.rollingshutterrepair",
|
|
"ec.distort.warpstabilizer",
|
|
"ec.key.ccsimplewireremoval",
|
|
"ec.paint.paint",
|
|
];
|
|
|
|
impl Switches {
|
|
/// These switches with Audio, Lock and Shy at their defaults.
|
|
pub fn pixels(&self) -> Switches {
|
|
Switches { audio: true, locked: false, shy: false, ..self.clone() }
|
|
}
|
|
|
|
/// The switches that change the pixels of a layer: [`Self::pixels`], keeping Audio when
|
|
/// something draws the layer's audio (`heard`).
|
|
pub fn picture(&self, heard: bool) -> Switches {
|
|
Switches { audio: if heard { self.audio } else { true }, ..self.pixels() }
|
|
}
|
|
}
|
|
|
|
/// A node only the audio mix reads: the layer's Audio group, an audio effect.
|
|
fn audio_only(n: &Node, root: bool) -> bool {
|
|
match n {
|
|
Node::Group(g) => (root && g.match_id == "audio") || matches!(&g.kind, GroupKind::Effect { effect } if is_audio_effect(effect)),
|
|
Node::Prop(_) => false,
|
|
}
|
|
}
|
|
|
|
/// `a` and `b` are equal but for their audio-only nodes ([`audio_only`]: the root's Audio group,
|
|
/// the effects in its Effects group that are audio effects).
|
|
fn same_picture_props(a: &PropGroup, b: &PropGroup) -> bool {
|
|
fn head(a: &PropGroup, b: &PropGroup) -> bool {
|
|
// Every field (no `..`: a new field must be considered here).
|
|
let PropGroup { uid, match_id, name, kind, enabled, label, children: _ } = a;
|
|
*uid == b.uid && *match_id == b.match_id && *name == b.name && *kind == b.kind && *enabled == b.enabled && *label == b.label
|
|
}
|
|
fn children(a: &PropGroup, b: &PropGroup, root: bool) -> bool {
|
|
let mut x = a.children.iter().filter(|n| !audio_only(n, root));
|
|
let mut y = b.children.iter().filter(|n| !audio_only(n, root));
|
|
loop {
|
|
match (x.next(), y.next()) {
|
|
(None, None) => return true,
|
|
(Some(Node::Group(g)), Some(Node::Group(h))) if root && g.match_id == "effects" && h.match_id == "effects" => {
|
|
if !(head(g, h) && children(g, h, false)) {
|
|
return false;
|
|
}
|
|
}
|
|
(Some(m), Some(n)) if m == n => {}
|
|
_ => return false,
|
|
}
|
|
}
|
|
}
|
|
head(a, b) && children(a, b, true)
|
|
}
|
|
|
|
/// `g` (a layer's property tree) without its audio-only nodes ([`same_picture_props`]).
|
|
fn picture_props(g: &PropGroup) -> PropGroup {
|
|
let mut g = g.clone();
|
|
g.children.retain(|n| !audio_only(n, true));
|
|
for n in &mut g.children {
|
|
if let Node::Group(e) = n
|
|
&& e.match_id == "effects"
|
|
{
|
|
e.children.retain(|n| !audio_only(n, false));
|
|
}
|
|
}
|
|
g
|
|
}
|
|
|
|
impl Layer {
|
|
/// Draws the same pixels as `o`: equal but for the Audio, Lock and Shy switches and what only
|
|
/// the audio mix reads (nothing draws its audio).
|
|
pub fn same_pixels(&self, o: &Layer) -> bool {
|
|
self.same_pixels_with(o, true, false)
|
|
}
|
|
|
|
/// [`Self::same_pixels`], with the markers counting only when `with_markers` (they are read by
|
|
/// expressions alone, so a project without any can edit them freely) and the audio (the Audio
|
|
/// switch, the Audio group, audio effects) only when `heard`: something draws it.
|
|
pub fn same_pixels_with(&self, o: &Layer, with_markers: bool, heard: bool) -> bool {
|
|
// Every field (no `..`: a new field must be considered here); the property tree last.
|
|
let Layer {
|
|
id,
|
|
name,
|
|
source,
|
|
label,
|
|
comment,
|
|
start_time,
|
|
in_point,
|
|
out_point,
|
|
stretch,
|
|
switches,
|
|
blend_mode,
|
|
preserve_transparency,
|
|
track_matte,
|
|
parent,
|
|
markers,
|
|
markers_locked,
|
|
auto_orient,
|
|
environment,
|
|
environment_background,
|
|
props,
|
|
} = self;
|
|
*id == o.id
|
|
&& *name == o.name
|
|
&& *source == o.source
|
|
&& *label == o.label
|
|
&& *comment == o.comment
|
|
&& *start_time == o.start_time
|
|
&& *in_point == o.in_point
|
|
&& *out_point == o.out_point
|
|
&& *stretch == o.stretch
|
|
&& switches.picture(heard) == o.switches.picture(heard)
|
|
&& *blend_mode == o.blend_mode
|
|
&& *preserve_transparency == o.preserve_transparency
|
|
&& *track_matte == o.track_matte
|
|
&& *parent == o.parent
|
|
&& (!with_markers || *markers == o.markers)
|
|
&& *markers_locked == o.markers_locked
|
|
&& *auto_orient == o.auto_orient
|
|
&& *environment == o.environment
|
|
&& *environment_background == o.environment_background
|
|
&& (if heard { *props == o.props } else { same_picture_props(props, &o.props) })
|
|
}
|
|
|
|
/// The comp-time spans where `self` draws differently than `o` when the two differ only in
|
|
/// the keyframes of Transform properties (a key's value, ease or interpolation moved in the
|
|
/// Timeline or Graph Editor): from two keys before the first changed key to two after the
|
|
/// last (auto-Bezier and continuous tangents reach the neighbouring segments), to the
|
|
/// layer's in or out point past the first or last key (values hold there). `None` for any
|
|
/// other difference, a static value, an expression on the layer (it can read the keyed
|
|
/// property at other times), roving or muted keys, a time remap or a time effect (it reads other
|
|
/// times): those reach the layer's whole span.
|
|
pub fn keyframe_spans(&self, o: &Layer, with_markers: bool, heard: bool) -> Option<Vec<(Tick, Tick)>> {
|
|
let transform = |l: &Layer| l.props.group("transform").cloned();
|
|
let (t1, t0) = (transform(self)?, transform(o)?);
|
|
let reads_time = |l: &Layer| {
|
|
l.props.get("timeRemap").is_some()
|
|
|| l.props.group("effects").is_some_and(|e| {
|
|
e.children.iter().any(|n| matches!(n, Node::Group(f) if matches!(&f.kind, GroupKind::Effect { effect } if reads_other_frames(effect))))
|
|
})
|
|
};
|
|
let has_expr = |l: &Layer| {
|
|
let mut any = false;
|
|
l.props.walk("", &mut |_, p| any |= p.has_expression());
|
|
any
|
|
};
|
|
if reads_time(self) || reads_time(o) || has_expr(self) || has_expr(o) {
|
|
return None;
|
|
}
|
|
// Everything but the Transform group draws the same.
|
|
let rest = |l: &Layer| {
|
|
let mut l = l.clone();
|
|
l.props.children.retain(|n| !matches!(n, Node::Group(g) if g.match_id == "transform"));
|
|
l
|
|
};
|
|
let head = |g: &PropGroup| PropGroup { children: vec![], ..g.clone() };
|
|
if !rest(self).same_pixels_with(&rest(o), with_markers, heard) || head(&t1) != head(&t0) || t1.children.len() != t0.children.len() {
|
|
return None;
|
|
}
|
|
let mut spans = Vec::new();
|
|
for (n1, n0) in t1.children.iter().zip(&t0.children) {
|
|
if n1 == n0 {
|
|
continue;
|
|
}
|
|
let (Node::Prop(p1), Node::Prop(p0)) = (n1, n0) else { return None };
|
|
let bare = |p: &Property| Property { keys: vec![], value: Value::Scalar(0.0), ..p.clone() };
|
|
// (muted keys are skipped: the keys around an edit that play can be any distance away)
|
|
if bare(p1) != bare(p0) || p1.keys.is_empty() || p0.keys.is_empty() || p1.keys.iter().chain(&p0.keys).any(|k| k.roving || k.muted) {
|
|
return None;
|
|
}
|
|
// Keys only one side has as they are (added, removed, edited, moved).
|
|
let changed: Vec<Tick> =
|
|
p1.keys.iter().filter(|k| !p0.keys.contains(k)).chain(p0.keys.iter().filter(|k| !p1.keys.contains(k))).map(|k| k.time).collect();
|
|
let (Some(first), Some(last)) = (changed.iter().min(), changed.iter().max()) else { continue };
|
|
// Two keys before the first changed one and two after the last, on either side (the
|
|
// first or last key when there are fewer: values hold beyond it). Past the first or
|
|
// last key the change reaches the in or out point (`None`).
|
|
let mut lo = Some(Tick(i64::MAX));
|
|
let mut hi = Some(Tick(i64::MIN));
|
|
for keys in [&p1.keys, &p0.keys] {
|
|
let before = keys.iter().filter(|k| k.time < *first).count();
|
|
let back = if before == 0 { None } else { keys.get(before.saturating_sub(2)) };
|
|
lo = lo.zip(back).map(|(a, k)| a.min(k.time));
|
|
let upto = keys.iter().filter(|k| k.time <= *last).count();
|
|
let ahead = if upto >= keys.len() { None } else { keys.get(upto.saturating_add(1).min(keys.len().saturating_sub(1))) };
|
|
hi = hi.zip(ahead).map(|(a, k)| a.max(k.time));
|
|
}
|
|
let (x, y) = (lo.map_or(self.in_point, |t| self.comp_time(t)), hi.map_or(self.out_point, |t| self.comp_time(t)));
|
|
let (x, y) = (x.min(y).max(self.in_point), x.max(y).min(self.out_point));
|
|
if x <= y {
|
|
spans.push((x, y));
|
|
}
|
|
}
|
|
Some(spans)
|
|
}
|
|
|
|
/// This layer with what doesn't change its pixels at its defaults (see
|
|
/// [`Self::same_pixels_with`]), for keys that hash it; `None` when it already is.
|
|
pub fn picture_form(&self, heard: bool) -> Option<Layer> {
|
|
let switches = self.switches.picture(heard);
|
|
let audio = !heard
|
|
&& (self.props.children.iter().any(|n| audio_only(n, true))
|
|
|| self
|
|
.props
|
|
.children
|
|
.iter()
|
|
.any(|n| matches!(n, Node::Group(e) if e.match_id == "effects" && e.children.iter().any(|n| audio_only(n, false)))));
|
|
if switches == self.switches && !audio {
|
|
return None;
|
|
}
|
|
let mut l = Layer { switches, ..self.clone() };
|
|
if audio {
|
|
l.props = picture_props(&self.props);
|
|
}
|
|
Some(l)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum MatteKind {
|
|
#[default]
|
|
Alpha,
|
|
AlphaInverted,
|
|
Luma,
|
|
LumaInverted,
|
|
}
|
|
|
|
impl MatteKind {
|
|
pub const ALL: [MatteKind; 4] = [MatteKind::Alpha, MatteKind::AlphaInverted, MatteKind::Luma, MatteKind::LumaInverted];
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
MatteKind::Alpha => "Alpha Matte",
|
|
MatteKind::AlphaInverted => "Alpha Inverted Matte",
|
|
MatteKind::Luma => "Luma Matte",
|
|
MatteKind::LumaInverted => "Luma Inverted Matte",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct TrackMatte {
|
|
pub layer: LayerId,
|
|
pub kind: MatteKind,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum AutoOrient {
|
|
#[default]
|
|
Off,
|
|
AlongPath,
|
|
TowardsCamera,
|
|
TowardsPointOfInterest,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct Layer {
|
|
pub id: LayerId,
|
|
pub name: String,
|
|
pub source: LayerSource,
|
|
pub label: Label,
|
|
#[serde(default)]
|
|
pub comment: String,
|
|
/// Comp time where the layer's time 0 sits.
|
|
pub start_time: Tick,
|
|
/// Visible span in comp time.
|
|
pub in_point: Tick,
|
|
pub out_point: Tick,
|
|
/// Time stretch in percent (100 = normal, negative = reversed).
|
|
pub stretch: f64,
|
|
pub switches: Switches,
|
|
pub blend_mode: BlendMode,
|
|
#[serde(default)]
|
|
pub preserve_transparency: bool,
|
|
#[serde(default)]
|
|
pub track_matte: Option<TrackMatte>,
|
|
#[serde(default)]
|
|
pub parent: Option<LayerId>,
|
|
#[serde(default)]
|
|
pub markers: Vec<Marker>,
|
|
/// Layer ▸ Markers ▸ Lock Markers.
|
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
|
pub markers_locked: bool,
|
|
#[serde(default)]
|
|
pub auto_orient: AutoOrient,
|
|
/// Layer ▸ Environment Layer: the layer is the environment (image-based light and
|
|
/// reflections) of Advanced 3D comps instead of being drawn.
|
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
|
pub environment: bool,
|
|
/// Layer ▸ Light ▸ Create Environment Light Background Layer: the layer's (equirectangular)
|
|
/// image is drawn as the 3D scene's backdrop, seen through the camera, instead of as a card.
|
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
|
pub environment_background: bool,
|
|
/// The property tree (Masks, Effects, Transform, Text, Contents, Camera/Light options…).
|
|
pub props: PropGroup,
|
|
}
|
|
|
|
impl Layer {
|
|
/// 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> {
|
|
fn walk(g: &PropGroup, depth: usize, out: &mut Vec<LayerId>) {
|
|
if depth > 64 {
|
|
return;
|
|
}
|
|
if let GroupKind::Tracker { settings } = &g.kind
|
|
&& let Some(t) = settings.target
|
|
{
|
|
out.push(t);
|
|
}
|
|
for c in &g.children {
|
|
match c {
|
|
Node::Prop(p) => {
|
|
for v in std::iter::once(&p.value).chain(p.keys.iter().map(|k| &k.value)) {
|
|
if let Value::Layer(Some(id)) = v {
|
|
out.push(LayerId(*id));
|
|
}
|
|
}
|
|
}
|
|
Node::Group(sub) => walk(sub, depth + 1, out),
|
|
}
|
|
}
|
|
}
|
|
let mut out: Vec<LayerId> = self.parent.iter().copied().chain(self.track_matte.iter().map(|m| m.layer)).collect();
|
|
walk(&self.props, 0, &mut out);
|
|
out
|
|
}
|
|
|
|
/// Layers this one picks in an effect or tracker (an Audio Spectrum's Audio Layer, a
|
|
/// Displacement Map's map layer…), itself included: [`Self::referenced_layers`] without the
|
|
/// parent and the track matte.
|
|
pub fn picked_layers(&self) -> Vec<LayerId> {
|
|
let skip = usize::from(self.parent.is_some()) + usize::from(self.track_matte.is_some());
|
|
self.referenced_layers().into_iter().skip(skip).collect()
|
|
}
|
|
|
|
pub fn is_active_at(&self, t: Tick) -> bool {
|
|
t >= self.in_point && t < self.out_point
|
|
}
|
|
|
|
/// Layer time for a comp time (stretch-aware).
|
|
pub fn layer_time(&self, comp_t: Tick) -> Tick {
|
|
let d = comp_t - self.start_time;
|
|
if (self.stretch - 100.0).abs() < 1e-9 { d } else { Tick((d.0 as f64 * 100.0 / self.stretch) as i64) }
|
|
}
|
|
|
|
/// Comp time for a layer time.
|
|
pub fn comp_time(&self, layer_t: Tick) -> Tick {
|
|
if (self.stretch - 100.0).abs() < 1e-9 { self.start_time + layer_t } else { self.start_time + Tick((layer_t.0 as f64 * self.stretch / 100.0) as i64) }
|
|
}
|
|
|
|
pub fn transform(&self) -> Option<&PropGroup> {
|
|
self.props.sub("transform")
|
|
}
|
|
|
|
pub fn transform_mut(&mut self) -> Option<&mut PropGroup> {
|
|
self.props.sub_mut("transform")
|
|
}
|
|
|
|
pub fn effects(&self) -> Option<&PropGroup> {
|
|
self.props.sub("effects")
|
|
}
|
|
|
|
pub fn masks(&self) -> Option<&PropGroup> {
|
|
self.props.sub("masks")
|
|
}
|
|
|
|
/// The labels popup property `p` of this layer offers: Text Path Options ▸ Path lists None and
|
|
/// the layer's masks (value k = mask k), every other popup its own options. `None` when `p` is
|
|
/// not a popup.
|
|
pub fn popup_options<'a>(&'a self, p: &'a Property) -> Option<std::borrow::Cow<'a, [String]>> {
|
|
if self.props.prop("text/pathOptions/path").is_some_and(|q| q.uid == p.uid) {
|
|
let masks = self.masks().into_iter().flat_map(|m| m.groups().map(|g| g.name.clone()));
|
|
return Some(std::iter::once("None".to_string()).chain(masks).collect::<Vec<_>>().into());
|
|
}
|
|
match &p.ui {
|
|
ParamUi::Popup { options } => Some(options.as_slice().into()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn is_camera(&self) -> bool {
|
|
matches!(self.source, LayerSource::Camera)
|
|
}
|
|
|
|
pub fn is_light(&self) -> bool {
|
|
matches!(self.source, LayerSource::Light { .. })
|
|
}
|
|
|
|
/// The layer participates in 3D (3D switch, or cameras/lights which are always 3D).
|
|
pub fn is_3d(&self) -> bool {
|
|
self.switches.three_d || self.is_camera() || self.is_light()
|
|
}
|
|
|
|
pub fn has_video(&self) -> bool {
|
|
self.source.is_av() && self.switches.video
|
|
}
|
|
}
|