Files
effectcraft/crates/project/src/comp.rs
T
30d6c6e1d3 Frames cache: edits keep the frames they can't reach (#2795)
* frames: keep cached frames an edit can't reach

Nudging, trimming or splitting a layer re-rendered the whole comp. The RAM
preview now carries every frame whose stack of active layers is unchanged
to the new comp identity; only the segments an edit touches re-render.

* disk cache: never drop frame writes; save frames kept after edits

Frames get their own write lane (bounded by bytes, written before layer
buffers), so a burst of layer writes no longer drops frames and leaves gaps
in the blue bar after reopening. Frames an edit kept in RAM are written to
disk under the comp's new content key when idle.

* frames: music and hidden layers keep the caches

Layers that can't change pixels (audio-only footage, AV layers with Video
off, when nothing reads them and there are no expressions) no longer count
for the RAM preview's dirty segments or the disk cache's comp and footage
keys. Adding, trimming or moving music keeps both caches.
RENDER_VERSION bumps to effectcraft-render-3 for the new key formula.

* ui-egui: background caching benchmark

cache_bench fills a comp's RAM preview the way idle caching does and
prints frames/s with decoder, footage-cache and disk-cache counters.

* frames: soloed music and hidden layers aren't silent

Solo hides every other layer, so soloing a music track or a layer with
Video off changes the picture. silent_layers no longer drops soloed
layers, so the RAM preview and the disk cache see Solo toggles.

* disk cache: audio files a layer reads stay in the layer salt

Audio-only footage left the disk layer salt so importing music keeps the
keys, but Audio Spectrum and Audio Waveform draw from an audio layer.
Footage another layer reads (or any, when a read layer is a precomp)
counts again, so overwriting that file doesn't reuse stale buffers.

* disk cache: retry frame writes the queue refused

put_frame reports whether the frame is on disk or queued. persist_missing
forgets the key of a refused write, so a full frame lane no longer leaves
RAM frames that are never written.

* ui-egui: cache_bench measures idle caching; checks --comp and --slots

Frames go through request_background with one slot per frame thread, as
Cache Frames When Idle does. An unknown --comp is an error (the active
comp is used only without --comp), --slots is at least 1, and the disk
report flushes queued writes instead of sleeping.

* disk cache: RENDER_VERSION effectcraft-render-6

This branch changes the cache-key formula (silent layers, Solo, read
audio in the footage salt), and upstream #2759 already took render-5
for the clip-clock change, so entries from either must not be reused.

* frames: rewrite RAM frames the disk cache evicted

persist_missing kept every disk key it ever queued, so a frame the disk
cache evicted later was never written back. The disk cache now tracks
frame writes still queued or in progress (frame_pending), and
persist_missing only remembers keys while it converts them.

* frames: idle passes continue while RAM frames wait for disk

persist_missing reports whether there may be more to write (a content key
being computed, frames being converted). Cache Frames When Idle then
schedules another pass, so a refused write is retried without waiting for
a UI event, and the passes stop once every frame is on disk or queued.

* frames: rewrite an evicted frame once, not on every idle pass

A disk cache smaller than the RAM preview, or a frame too large for it,
made idle passes write, evict and rewrite the same frames every 500 ms.
Each disk key is now written at most twice under one disk limit (the
write and one rewrite after an eviction); a new limit resets the count.
The persist test waits for the conversion to forget its keys before
checking that no work is left.

* frames: the work area doesn't change pixels

Setting the work area (B, N) cleared every cached frame, in RAM and on
disk: it was compared with the comp's pixel settings and hashed into the
disk key. It is a preview and render range only, like the Resolution, so
it no longer counts.

* frames: audio edits keep frames unless something draws the audio

Changing a clip's Audio Levels, its audio effects or its Audio switch
cleared the clip's cached frames, and the comp's disk cache, although
nothing drew that audio. The audio now counts only for the layers whose
audio is heard (Project::heard_layers): those a layer picks in an effect
(an Audio Waveform or Spectrum, even on itself), the layers of a picked
precomp, and every layer while an expression (which can read Audio
Levels) exists. Music in a precomp that a waveform in the outer comp
picks is no longer treated as silent either.

* frames: an expression only reaches the frames of its own layer

One expression anywhere in the project made every edit clear the
comp's whole cache, and so did editing a layer another layer reads
(its parent, matte, or a layer it picks). Expressions can read
anything, but they only draw in their own layer: an edit now re-renders
the frames where it changed something, plus those of the layers with an
expression and of the layers that read a reached layer. With
expressions, an edit elsewhere (another comp, an item's name, a marker,
music) reaches only the expression layers' frames.

* frames: an edit inside a precomp re-renders only where the precomp shows it

Editing anything inside a precomp cleared every cached frame of the
comps that nest it, even a music level in it. Project::dirty_spans now
follows precomps: it takes the spans their comp changed in, shifts and
scales them by the precomp layer's start and stretch (its whole span
under a time remap or Responsive Design timing), and keeps the outer
comp's other frames. Layers that read a changed precomp (a matte, a
waveform) re-render where they are active.

* disk cache: keep the frames an edit can't reach

The disk cache keys frames on a hash of the whole comp, so every edit
lost the frames that were only on disk (the blue bar), even a rename;
only those still in RAM were written again under the new key. When an
edit keeps RAM frames, it now also queues a disk carry: on the frame
pool, each kept frame of every disk series in use for that comp is hard
linked (or copied) to its new key with DiskCache::link_frame, oldest
edit first. The old entries stay, so undo finds them too.

* frames: a keyframe edit re-renders only around the keys it changed

Changing one keyframe (its value, interpolation or ease, in the
Timeline or the Graph Editor) re-rendered the layer's whole length, so
a key on a full-length layer cleared the whole cache. A layer that
differs only in keyframes of its Transform properties now re-renders
from two keys before the first changed key to two after the last
(auto-Bezier and continuous tangents reach the neighbouring segments),
to its in or out point past the first or last key. Static values,
expressions, roving keys, time remap and time effects still reach the
whole layer; effect keyframes do too for now (simulations and time
effects read other frames).

* frames: music volume keeps frames while no expression reads audio

Any expression in the project (a wiggle on another layer, even a
disabled one) made every layer's audio count as read, so changing a
music layer's Audio Levels re-rendered every expression layer and
changed the comp's disk key. Audio now counts for expressions only when
one may read it (Heard::audio_exprs): its text names audio or walks
properties by index. An audio-only change nothing reads no longer counts
as a change elsewhere in dirty_spans either.

* frames: a keyframe edit with few keys or beside a self-contained expression keeps the other frames

Changing a Scale key of a layer with two or three keys re-rendered the
layer's whole length: with fewer than two keys before (or after) the
changed one, the span fell back to the in (or out) point instead of the
first (or last) key, past which the value holds. And any layer with an
expression re-rendered on every edit, so a wiggle on a full-length layer
cleared the whole cache. An expression layer is now reached by edits
elsewhere only when its text may read outside its layer (it names a
comp, layer, footage, the project or its source); its parent and the
layers it picks are still read through referenced_layers.

* viewer: the caching chip stays up between uncached frames, with a spinner

The "Caching frames…" chip showed only while playback waited for a
frame, so during caching it flickered on and off with every frame that
arrived. It now stays up 0.6 s after the last wait (and hides at once
when playback stops). It is also smaller and quieter: a spinner, then
"Caching frames" without the ellipsis in dimmed smaller text, on a
fainter chip sized to its text.

* preview: the Preview panel's cache bar counts the preview's Resolution

With a preview Resolution set (Half, Third…), the frames a preview
plays are cached at that scale, but the Preview panel's bar counted the
series the viewer shows. When playback stopped, the viewer went back to
the comp's Resolution and the bar dropped from full to the frames cached
there. It now counts the preview's own series (Auto still follows the
viewer). The viewer and the Timeline's cache bar are unchanged.

* Contributors: add nazar-gavrylyk's name

* frames: an expression reading its own layer's markers or audio re-renders that layer

With expressions in the project, a layer's markers count when comparing it, and its audio counts
when an expression may read audio, so a marker or volume edit re-renders the edited layer over
its own span. Comp markers still count only through layers whose expressions name thisComp.

* frames: a keyframe edit on a layer with an expression re-renders the layer

An expression on the keyed layer can read the keyed property at other times (Rotation reading
Position with valueAtTime), so the frames past the changed keys may change too.

* cache_bench: read decoder counters through effectcraft-media

Only crates/media, crates/export and crates/interchange may depend on the FilmCraft crates.

* cache_bench: clips end where the next one starts

* frames: write-back uses the worker's disk keys while a Roto Brush model is active

persist_missing keyed disk frames on FrameKey::opts, which also carries the Roto Brush model
stamp; the worker and disk_frames key on the render opts' hash alone. With a model active every
RAM frame looked missing, was written again under keys nobody reads, and recorded a second disk
series.

* frames: an edit two precomps down reaches the outer comp

A precomp layer whose comp Arc didn't change was skipped, but the comps nested in it may have
(an edit in Inner leaves Mid's Arc as it was), so Outer kept every frame. Every nested comp is
walked now, each once per edit.

* cache_bench: stop when the disk cache can't be cleared

* frames: one disk carry job at a time

carry_disk runs on every timeline paint and idle pass, and spawned a pool job whenever carries
were queued; during a drag the extra jobs waited on the carry lock and held frame-pool threads
from the viewer. A scheduled flag keeps one job, which drains the queue in order until it's
empty.

* frames: a panicking disk carry is skipped, not left holding the job

The carry job clears carry_scheduled only on its normal exit; a panic in a carry would have left
it set for the session, so no later carry ran. Each carry is caught like the content key's
hashing, so the job always reaches its exit.

* frames: an expression reading its own in point or index re-renders its layer

A trim or rename kept the layer's frames outside the cut when the layer
drew the same apart from its span, but an expression on it can read its
own inPoint, outPoint or name (a fade from inPoint), so every frame of
the layer changes. Such a layer is no longer treated as only respanned.

An expression reading `index` reads the layers above it too: a layer
added or removed there shifts it, so `index` now counts as reading
outside the layer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* frames: an edit of a comp reaches the expressions of the precomps it nests

An expression in a precomp can read the comp that nests it
(`comp("Main").layer("Controls")`), but the walk into nested comps only
counted changes outside the edited top comp. Editing a hidden Controls
layer there (or any layer, when the precomp is time-shifted or reads it
at other times) kept every precomp frame in RAM and linked them all on
disk under the new key. Nested comps now see an edit of the comp above
them as an edit elsewhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* frames: an edit in a precomp under a time effect reaches its whole layer

A precomp layer running Echo, Posterize Time, Time Displacement or
another effect that reads the layer at other times shows a nested change
far from where it maps, but only the mapped span (a nested frame either
side) re-rendered. Such a layer now gives its whole span, like a time
remap. `reads_other_frames` lists those effects (every `ec.time.` and
`ec.sim.` effect and the others that call `self_at`); keyframe spans use
it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* frames: a keyframe edit beside muted keys re-renders the whole layer

Muted keys are skipped when a property is evaluated, so the keys that
play around an edited key can be any distance away, but the span counted
two stored keys either side. A segment from an earlier playing key kept
its stale frames. Muted keys now give the layer's whole span, like
roving keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* frames: one echo in the precomp time-effect test (three saturate alike), rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:46:39 +01:00

537 lines
23 KiB
Rust

use super::item::LayerId;
use super::layer::{Layer, LayerSource};
use super::settings::{Marker, yes};
use super::{Deserialize, FrameRate, Node, PropGroup, Serialize, Tick, essential, styles};
/// 3D renderer of a composition.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Renderer {
#[default]
Classic3D,
Advanced3D,
}
/// A composition's Resolution (the Composition panel's Resolution/Down Sample Factor popup, View ▸
/// Resolution): how many of its pixels the viewer renders. Auto follows the magnification.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Resolution {
#[default]
Auto,
Full,
Half,
Third,
Quarter,
/// View ▸ Resolution ▸ Custom: render every n-th pixel (2..=40).
Custom(u8),
}
impl Resolution {
pub const ALL: [Resolution; 5] = [Resolution::Auto, Resolution::Full, Resolution::Half, Resolution::Third, Resolution::Quarter];
pub fn label(self) -> &'static str {
match self {
Resolution::Auto => "Auto",
Resolution::Full => "Full",
Resolution::Half => "Half",
Resolution::Third => "Third",
Resolution::Quarter => "Quarter",
Resolution::Custom(_) => "Custom",
}
}
pub fn is_auto(&self) -> bool {
*self == Resolution::Auto
}
/// The down-sample factor (render every n-th pixel); `None` for Auto.
pub fn factor(self) -> Option<u8> {
match self {
Resolution::Auto => None,
Resolution::Full => Some(1),
Resolution::Half => Some(2),
Resolution::Third => Some(3),
Resolution::Quarter => Some(4),
Resolution::Custom(n) => Some(n),
}
}
/// The resolution that renders every `n`-th pixel (1 = Full … 4 = Quarter; up to 40).
pub fn from_factor(n: u64) -> Resolution {
match n.clamp(1, 40) {
1 => Resolution::Full,
2 => Resolution::Half,
3 => Resolution::Third,
4 => Resolution::Quarter,
n => Resolution::Custom(n as u8),
}
}
/// Render scale for a viewer magnification (and display pixel density).
pub fn scale(self, zoom: f32, ppp: f32) -> f64 {
match self.factor() {
Some(n) => 1.0 / f64::from(n.clamp(1, 40)),
// As in After Effects, Auto renders the pixels the magnification needs: the coarsest
// whole factor that still gives every screen pixel a rendered one (Full above 50 %,
// Half down to 33.3 %, Third down to 25 %, Quarter below), never fewer (#417).
None => {
let n = (1.0 / (zoom * ppp) as f64 + 1e-3).floor();
if n >= 1.0 { 1.0 / n.min(4.0) } else { 1.0 }
}
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Comp {
pub width: u32,
pub height: u32,
pub pixel_aspect: f64,
pub frame_rate: FrameRate,
pub duration: Tick,
/// Start timecode/frame shown for time 0.
pub display_start: Tick,
pub background: [f32; 3],
pub work_area: (Tick, Tick),
/// Layer #1 first (top of the stack). Each layer is shared between undo states until edited.
pub layers: super::Layers,
#[serde(default)]
pub markers: Vec<Marker>,
pub shutter_angle: f64,
pub shutter_phase: f64,
/// Samples Per Frame: the minimum motion-blur samples (and the count for 3D layers).
pub motion_blur_samples: u32,
/// Adaptive Sample Limit (Composition Settings ▸ Advanced): the most samples a 2D layer gets
/// when it moves far within the shutter.
#[serde(default = "adaptive_limit_default")]
pub motion_blur_adaptive_limit: u32,
#[serde(default)]
pub renderer: Renderer,
/// Comp switches (timeline toolbar).
#[serde(default)]
pub hide_shy: bool,
#[serde(default = "yes")]
pub enable_motion_blur: bool,
#[serde(default = "yes")]
pub enable_frame_blending: bool,
#[serde(default)]
pub draft_3d: bool,
/// Composition Settings ▸ Advanced ▸ Preserve frame rate when nested or in render queue:
/// nested in another comp, or rendered at another rate, it shows only its own frames.
#[serde(default)]
pub preserve_frame_rate: bool,
/// Composition Settings ▸ Advanced ▸ Preserve resolution when nested: nested in a comp
/// previewed at a lower resolution, it still renders at full size.
#[serde(default)]
pub preserve_resolution: bool,
#[serde(default)]
pub poster_time: Tick,
/// Global Light for layer styles (Layer ▸ Layer Styles ▸ Blending Options).
#[serde(default)]
pub global_light: styles::GlobalLight,
/// Viewer guides (View ▸ Add Guide…), in comp pixels.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub guides: Vec<Guide>,
/// Essential Graphics: the controls this comp exposes to instances and templates.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub essential: Option<essential::EssentialGraphics>,
/// The Composition panel's Resolution, saved with the project as in After Effects. It changes
/// what the viewer renders, not the comp's pixels ([`Comp::same_pixels`]), and stays out of
/// the undo history.
#[serde(default, skip_serializing_if = "Resolution::is_auto")]
pub resolution: Resolution,
}
/// A viewer guide line: vertical guides sit at an x position, horizontal ones at a y position.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Guide {
pub vertical: bool,
/// Comp pixels, or percent of the comp's width (vertical) / height (horizontal) when
/// [`Guide::percent`] is set.
pub position: f64,
/// A percentage guide (After Effects 26.5): `position` is a percent of the comp's size, so the
/// guide keeps its relative place when the comp is resized. Absent in older projects.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub percent: bool,
}
impl Guide {
/// The comp's extent along this guide's axis, never zero.
fn extent(&self, width: u32, height: u32) -> f64 {
f64::from(if self.vertical { width } else { height }).max(1.0)
}
/// The guide's position in comp pixels for a comp of this size.
pub fn px(&self, width: u32, height: u32) -> f64 {
if self.percent { self.position / 100.0 * self.extent(width, height) } else { self.position }
}
/// Place the guide at `px` comp pixels, keeping its unit.
pub fn set_px(&mut self, px: f64, width: u32, height: u32) {
self.position = if self.percent { px / self.extent(width, height) * 100.0 } else { px };
}
/// Switch between pixels and percent without moving the guide on the comp.
pub fn set_percent(&mut self, percent: bool, width: u32, height: u32) {
let px = self.px(width, height);
self.percent = percent;
self.set_px(px, width, height);
}
}
fn adaptive_limit_default() -> u32 {
128
}
impl Comp {
pub fn new(width: u32, height: u32, frame_rate: FrameRate, duration: Tick) -> Comp {
Comp {
width,
height,
pixel_aspect: 1.0,
frame_rate,
duration,
display_start: Tick::ZERO,
background: [0.0, 0.0, 0.0],
work_area: (Tick::ZERO, duration),
layers: super::Layers::new(),
markers: vec![],
shutter_angle: 180.0,
shutter_phase: -90.0,
motion_blur_samples: 16,
motion_blur_adaptive_limit: 128,
renderer: Renderer::Classic3D,
hide_shy: false,
enable_motion_blur: true,
enable_frame_blending: true,
draft_3d: false,
preserve_frame_rate: false,
preserve_resolution: false,
poster_time: Tick::ZERO,
guides: vec![],
global_light: styles::GlobalLight::default(),
essential: None,
resolution: Resolution::Auto,
}
}
/// Whether the composition has a frame at its time `t`: nested, it shows nothing before it
/// starts or after it ends (a precomp layer trimmed or remapped past it).
pub fn covers(&self, t: Tick) -> bool {
t >= Tick::ZERO && t < self.duration
}
/// A new, empty composition with this one's settings (frame rate, pixel aspect, background,
/// motion blur, frame blending and the 3D renderer), as Pre-compose makes.
pub fn nested_like(&self, width: u32, height: u32, duration: Tick) -> Comp {
Comp {
pixel_aspect: self.pixel_aspect,
background: self.background,
shutter_angle: self.shutter_angle,
shutter_phase: self.shutter_phase,
motion_blur_samples: self.motion_blur_samples,
motion_blur_adaptive_limit: self.motion_blur_adaptive_limit,
renderer: self.renderer,
enable_motion_blur: self.enable_motion_blur,
enable_frame_blending: self.enable_frame_blending,
preserve_frame_rate: self.preserve_frame_rate,
preserve_resolution: self.preserve_resolution,
..Comp::new(width, height, self.frame_rate, duration)
}
}
pub fn layer(&self, id: LayerId) -> Option<&Layer> {
self.layers.iter().find(|l| l.id == id)
}
pub fn layer_mut(&mut self, id: LayerId) -> Option<&mut Layer> {
self.layers.find_mut(|l| l.id == id)
}
/// 1-based layer number (#) of a layer.
pub fn index_of(&self, id: LayerId) -> Option<usize> {
self.layers.iter().position(|l| l.id == id).map(|i| i + 1)
}
pub fn layer_by_name(&self, name: &str) -> Option<&Layer> {
self.layers.iter().find(|l| l.name == name)
}
pub fn frame_duration(&self) -> Tick {
self.frame_rate.frame_duration()
}
/// Any 3D layer present (camera/lights or a 3D switch).
pub fn has_3d(&self) -> bool {
self.layers.iter().any(|l| l.switches.three_d || matches!(l.source, LayerSource::Camera | LayerSource::Light { .. }))
}
/// The active camera at `t`: the topmost enabled camera layer active at that time.
pub fn active_camera(&self, t: Tick) -> Option<&Layer> {
self.layers.iter().find(|l| matches!(l.source, LayerSource::Camera) && l.switches.video && l.is_active_at(t))
}
/// `base`, or the next free numbered name when a layer already has it (`Shape Layer 2`,
/// `v-b1.mp4 2`; see `numbered_name`).
pub fn unique_layer_name(&self, base: &str) -> String {
let taken = |n: &str| self.layers.iter().any(|l| l.name == n);
if taken(base) { crate::item::numbered_name(base, taken) } else { base.to_string() }
}
}
/// A protected time marker with a duration sets the comp's timing (Responsive Design), so it is
/// part of the pixels even where plain markers are not.
fn is_timing_marker(m: &Marker) -> bool {
m.protected && m.duration > Tick::ZERO
}
fn timing_markers(m: &[Marker]) -> impl Iterator<Item = (Tick, Tick)> + '_ {
m.iter().filter(|m| is_timing_marker(m)).map(|m| (m.time, m.duration))
}
impl Comp {
/// Draws the same pixels as `o`: equal but for its layers' Lock and Shy switches, Hide Shy
/// Layers, the viewer's Resolution, the work area (a preview and render range only) and, as
/// nothing draws their audio, its layers' Audio switches, Audio groups and audio effects.
pub fn same_pixels(&self, o: &Comp) -> bool {
self.same_pixels_with(o, true, &|_| false)
}
/// [`Self::same_pixels`], with the comp's and its layers' markers counting only when
/// `with_markers`: only expressions read them, so while none does, adding or moving a marker
/// (even during playback) leaves every rendered frame valid. Protected time markers with a
/// duration set the timing (Responsive Design), so those always count. A layer's audio counts
/// when `heard` says something draws it ([`crate::Project::heard_layers`]).
pub fn same_pixels_with(&self, o: &Comp, with_markers: bool, heard: &dyn Fn(LayerId) -> bool) -> bool {
self.same_settings(o, with_markers)
&& self.layers.len() == o.layers.len()
&& self.layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels_with(b, with_markers, heard(a.id)))
}
/// [`Self::same_pixels_with`] but for the layers.
fn same_settings(&self, o: &Comp, with_markers: bool) -> bool {
// Every field (no `..`: a new field must be considered here).
let Comp {
width,
height,
pixel_aspect,
frame_rate,
duration,
display_start,
background,
work_area: _,
layers: _,
markers,
shutter_angle,
shutter_phase,
motion_blur_samples,
motion_blur_adaptive_limit,
renderer,
hide_shy: _,
enable_motion_blur,
enable_frame_blending,
draft_3d,
preserve_frame_rate,
preserve_resolution,
poster_time,
global_light,
guides,
essential,
resolution: _,
} = self;
*width == o.width
&& *height == o.height
&& *pixel_aspect == o.pixel_aspect
&& *frame_rate == o.frame_rate
&& *duration == o.duration
&& *display_start == o.display_start
&& *background == o.background
&& (if with_markers { *markers == o.markers } else { timing_markers(markers).eq(timing_markers(&o.markers)) })
&& *shutter_angle == o.shutter_angle
&& *shutter_phase == o.shutter_phase
&& *motion_blur_samples == o.motion_blur_samples
&& *motion_blur_adaptive_limit == o.motion_blur_adaptive_limit
&& *renderer == o.renderer
&& *enable_motion_blur == o.enable_motion_blur
&& *enable_frame_blending == o.enable_frame_blending
&& *draft_3d == o.draft_3d
&& *preserve_frame_rate == o.preserve_frame_rate
&& *preserve_resolution == o.preserve_resolution
&& *poster_time == o.poster_time
&& *global_light == o.global_light
&& *guides == o.guides
&& *essential == o.essential
}
/// The comp-time spans where `self` may draw other pixels than `o` after an edit of some of
/// its layers (moved, trimmed, split, edited, added, removed): where the active layers differ
/// or a changed one draws differently, widened by the shutter. A trim reaches only the part it
/// cut, a split only the copy's half (#1641). A layer that reads a changed one (its parent,
/// track matte, a layer it picks in an effect or tracker) draws differently wherever it is
/// active, and so does a layer with an expression that may read other layers or items when anything
/// changed: here or, `elsewhere`, outside these layers (markers, layers that draw nothing,
/// other comps and items). `None` when the edit can reach any frame: a comp setting, a layer
/// reorder, Solo turning on or off, an environment layer. A layer's audio counts when `heard`
/// says something draws it, a layer's markers when `with_markers`. `touched` layers draw differently in spans the caller adds (a
/// precomp whose comp changed): they reach the layers that read them, not their own frames.
pub fn changed_spans(
&self,
o: &Comp,
with_markers: bool,
heard: &dyn Fn(LayerId) -> bool,
elsewhere: bool,
touched: &[LayerId],
) -> Option<Vec<(Tick, Tick)>> {
// An expression that may read outside its own layer: it names another comp, layer,
// footage, the project or its precomp's source, or its layer's `index` (a layer added or
// removed above shifts it). Its parent and the layers it picks in an effect are read
// through `referenced_layers` below.
// ponytail: text heuristic; misses another layer reached through a variable from a call
// without those words. Upgrade: record reads in the expression host.
fn reads_outside(g: &PropGroup) -> bool {
g.children.iter().any(|c| match c {
Node::Prop(p) => p.expr.as_ref().is_some_and(|e| {
let t = e.text.to_lowercase().replace("thislayer", "");
["comp", "layer", "footage", "project", "source", "index"].iter().any(|w| t.contains(w))
}),
Node::Group(g) => reads_outside(g),
})
}
fn any_expression(g: &PropGroup) -> bool {
g.children.iter().any(|c| match c {
Node::Prop(p) => p.expr.is_some(),
Node::Group(g) => any_expression(g),
})
}
// Solo hides every other layer at every frame. (Comp markers count through `elsewhere`:
// an expression reading them names `thisComp`.)
if !self.same_settings(o, false) || self.any_solo() != o.any_solo() {
return None;
}
let common = |a: &Comp, b: &Comp| a.layers.iter().map(|l| l.id).filter(|id| b.layer(*id).is_some()).collect::<Vec<_>>();
if common(self, o) != common(o, self) {
return None;
}
// Changed, added or removed layers (each from both sides: its old and its new span), and
// those that draw the same apart from their span (a trim or split). Ids count: they seed
// randomness (noise, text randomisers), so a split's copy may draw differently.
let mut changed = Vec::new();
let mut respanned = Vec::new();
let mut keyed = Vec::new();
let mut cuts = Vec::new();
for (l, other) in self.layers.iter().map(|l| (l, o.layer(l.id))).chain(o.layers.iter().map(|l| (l, self.layer(l.id)))) {
if other.is_some_and(|b| l.same_pixels_with(b, with_markers, heard(l.id))) {
continue;
}
if l.environment || l.environment_background {
return None;
}
// (an expression on the layer can read its own in and out points and name, `inPoint`,
// `thisLayer.name`: then a trim or rename reaches all of it)
if let Some(b) = other
&& !any_expression(&l.props)
&& !any_expression(&b.props)
&& (Layer { in_point: b.in_point, out_point: b.out_point, name: b.name.clone(), label: b.label, comment: b.comment.clone(), ..l.clone() })
.same_pixels_with(b, with_markers, heard(l.id))
{
respanned.push(l.id);
} else if let Some(b) = other
&& let Some(spans) = l.keyframe_spans(b, with_markers, heard(l.id))
{
// (drawn the same outside the keys it changed)
respanned.push(l.id);
keyed.extend(spans);
}
changed.push(l.id);
cuts.extend([l.in_point, l.out_point]);
}
// The layers the change reaches besides: expressions, then whatever reads a reached layer.
let both = || self.layers.iter().chain(&o.layers);
let mut reached: Vec<LayerId> = Vec::new();
if !changed.is_empty() || elsewhere || !touched.is_empty() {
reached.extend(both().filter(|l| reads_outside(&l.props)).map(|l| l.id));
}
// (each pass adds a layer or stops: at most one pass per layer)
loop {
let read = |l: &&Layer| {
!reached.contains(&l.id)
&& l.referenced_layers().iter().any(|r| *r != l.id && (changed.contains(r) || reached.contains(r) || touched.contains(r)))
};
let more: Vec<LayerId> = both().filter(read).map(|l| l.id).collect();
if more.is_empty() {
break;
}
reached.extend(more);
}
reached.sort_unstable();
reached.dedup();
for l in both().filter(|l| reached.contains(&l.id)) {
if l.environment || l.environment_background {
return None;
}
cuts.extend([l.in_point, l.out_point]);
}
let (before, after) = self.shutter_margin();
// Between two cuts the same layers are active; outside them no changed layer is.
cuts.sort_unstable();
cuts.dedup();
let draws_same = |l: &Layer| (!changed.contains(&l.id) || respanned.contains(&l.id)) && !reached.contains(&l.id);
let spans = cuts
.windows(2)
.filter_map(|w| {
let (&s, &e) = (w.first()?, w.last()?);
let active = |c: &Comp| c.layers.iter().filter(|l| l.is_active_at(s)).map(|l| l.id).collect::<Vec<_>>();
let same = active(self) == active(o) && self.layers.iter().filter(|l| l.is_active_at(s)).all(draws_same);
(!same).then(|| (s - before, e + after))
})
.chain(keyed.into_iter().map(|(s, e)| (s - before, e + after)))
.collect();
Some(spans)
}
/// How far a change at a time reaches the frames before and after it through the shutter: a
/// frame at `t` samples cameras, lights and transforms over its shutter,
/// `t + (phase + angle * 0..=1) / 360` frames, and draws the layers active at `t` itself.
pub fn shutter_margin(&self) -> (Tick, Tick) {
let (a, p) = (self.shutter_angle / 360.0, self.shutter_phase / 360.0);
let fd = self.frame_duration().seconds();
(Tick::from_seconds_f64(p.max(p + a).max(0.0) * fd), Tick::from_seconds_f64(-p.min(p + a).min(0.0) * fd))
}
/// Some layer is soloed: only the soloed layers draw.
pub fn any_solo(&self) -> bool {
self.layers.iter().any(|l| l.switches.solo && l.source.is_av())
}
/// The comp with those switches, the Resolution and the work area at their defaults (borrowed
/// when they already are), for keys that hash the whole comp.
pub fn pixel_form(&self) -> std::borrow::Cow<'_, Comp> {
self.pixel_form_with(true, &|_| false)
}
/// [`Self::pixel_form`], also without the comp's plain markers and its layers' markers when not
/// `with_markers` (see [`Self::same_pixels_with`] and [`Project::reads_markers`]), and with the
/// audio of the layers `heard` doesn't name at its defaults ([`Layer::picture_form`]).
pub fn pixel_form_with(&self, with_markers: bool, heard: &dyn Fn(LayerId) -> bool) -> std::borrow::Cow<'_, Comp> {
let pictures: Vec<Option<Layer>> = self.layers.iter().map(|l| l.picture_form(heard(l.id))).collect();
if !self.hide_shy
&& self.resolution.is_auto()
&& self.work_area == (Tick::ZERO, Tick::ZERO)
&& pictures.iter().all(Option::is_none)
&& (with_markers || (self.markers.iter().all(is_timing_marker) && self.layers.iter().all(|l| l.markers.is_empty())))
{
return std::borrow::Cow::Borrowed(self);
}
let mut c = self.clone();
c.hide_shy = false;
c.resolution = Resolution::Auto;
c.work_area = (Tick::ZERO, Tick::ZERO);
if !with_markers {
c.markers.retain(is_timing_marker);
}
for (l, picture) in c.layers.iter_mut().zip(pictures) {
if let Some(p) = picture {
*l = p;
}
if !with_markers {
l.markers.clear();
}
}
std::borrow::Cow::Owned(c)
}
}