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>
This commit is contained in:
authored and GitHub committed 2026-11-05 10:46:39 +01:00
1 parent 4e8398f8ca
commit 30d6c6e1d3
20 files changed
+2536 -130

No files matched your search

Generated
+1
View File
@@ -1958,6 +1958,7 @@ dependencies = [
"effectcraft-gpu",
"effectcraft-host",
"effectcraft-keyframe",
"effectcraft-media",
"effectcraft-path",
"effectcraft-project",
"effectcraft-render",
+3
View File
@@ -131,3 +131,6 @@ sync_github_name = false
[people.JonRC]
real_name = "Jonathan Cardoso"
display_name = "Jonathan Cardoso"
[people.nazar-gavrylyk]
sync_github_name = true
+1 -1
View File
@@ -193,7 +193,7 @@ pub fn specs() -> Vec<EffectSpec> {
/// Is `id` an audio effect (applied by the mixdown rather than the compositor)?
pub fn is_audio_effect(id: &str) -> bool {
id.starts_with("ec.audio.")
effectcraft_project::is_audio_effect(id)
}
/// Does the effect produce sound on its own (so a layer without audio becomes audible)?
+2
View File
@@ -29,6 +29,8 @@ mod stream;
mod wav_markers;
mod webp_anim;
/// Video decoder counters (time in decoders, samples decoded, seeks, cache hits), for benchmarks.
pub use filmcraft_codecs::{GopStats, gop_stats};
pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats};
pub use probe::{DEFAULT_SEQUENCE_RATE, probe, probe_bytes, probe_model};
+139 -47
View File
@@ -1,7 +1,7 @@
use super::item::LayerId;
use super::layer::{Layer, LayerSource};
use super::settings::{Marker, yes};
use super::{Deserialize, FrameRate, Node, PropGroup, Serialize, Tick, Value, essential, styles};
use super::{Deserialize, FrameRate, Node, PropGroup, Serialize, Tick, essential, styles};
/// 3D renderer of a composition.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -284,20 +284,22 @@ fn timing_markers(m: &[Marker]) -> impl Iterator<Item = (Tick, Tick)> + '_ {
}
impl Comp {
/// Draws the same pixels as `o`: equal but for its layers' Audio, Lock and Shy switches, Hide
/// Shy Layers and the viewer's Resolution.
/// 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)
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.
pub fn same_pixels_with(&self, o: &Comp, with_markers: bool) -> bool {
/// 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))
&& 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.
@@ -311,7 +313,7 @@ impl Comp {
duration,
display_start,
background,
work_area,
work_area: _,
layers: _,
markers,
shutter_angle,
@@ -338,7 +340,6 @@ impl Comp {
&& *duration == o.duration
&& *display_start == o.display_start
&& *background == o.background
&& *work_area == o.work_area
&& (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
@@ -356,54 +357,139 @@ impl Comp {
&& *essential == o.essential
}
/// The comp-time span (`in_point..out_point` of every layer changed, added or removed,
/// widened by the shutter) outside which `self` draws the same pixels as `o`, so cached
/// frames there stay valid (#1641). `None` when the edit can reach any frame: a comp setting,
/// a layer reorder, Solo turning on or off, an expression (it reads anything), or a changed
/// layer another layer parents to, mattes with or picks in an effect.
pub fn changed_span(&self, o: &Comp) -> Option<(Tick, Tick)> {
fn reads(g: &PropGroup, changed: &[LayerId]) -> bool {
/// 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.is_some()
|| std::iter::once(&p.value)
.chain(p.keys.iter().map(|k| &k.value))
.any(|v| matches!(v, Value::Layer(Some(id)) if changed.contains(&LayerId(*id))))
}
Node::Group(g) => reads(g, changed),
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),
})
}
// Solo hides every other layer at every frame.
if !self.same_settings(o, true) || self.any_solo() != o.any_solo() {
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 span: Option<(Tick, Tick)> = None;
// Each changed layer comes up from both sides: its old and its new span.
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(b)) {
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);
let (s, e) = (l.in_point.min(l.out_point), l.in_point.max(l.out_point));
span = Some(span.map_or((s, e), |(a, b)| (a.min(s), b.max(e))));
cuts.extend([l.in_point, l.out_point]);
}
let reaches = |l: &Layer| {
l.parent.is_some_and(|p| changed.contains(&p)) || l.track_matte.as_ref().is_some_and(|m| changed.contains(&m.layer)) || reads(&l.props, &changed)
};
if self.layers.iter().chain(&o.layers).any(reaches) {
return None;
// 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));
}
// 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.
// (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();
let (before, after) = (Tick::from_seconds_f64(p.max(p + a).max(0.0) * fd), Tick::from_seconds_f64(-p.min(p + a).min(0.0) * fd));
span.map(|(s, e)| (s - before, e + after))
(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.
@@ -411,18 +497,21 @@ impl Comp {
self.layers.iter().any(|l| l.switches.solo && l.source.is_av())
}
/// The comp with those switches and the Resolution at their defaults (borrowed when they
/// already are), for keys that hash the whole comp.
/// 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)
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`]).
pub fn pixel_form_with(&self, with_markers: bool) -> std::borrow::Cow<'_, Comp> {
/// `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.layers.iter().all(|l| l.switches == l.switches.pixels())
&& 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);
@@ -430,11 +519,14 @@ impl Comp {
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 in &mut c.layers {
l.switches = l.switches.pixels();
for (l, picture) in c.layers.iter_mut().zip(pictures) {
if let Some(p) = picture {
*l = p;
}
if !with_markers {
l.markers.clear();
}
+112
View File
@@ -0,0 +1,112 @@
//! Which frames of a comp an edit can change: frame caches keep the others.
use super::comp::Comp;
use super::item::{Heard, ItemId};
use super::layer::{Layer, LayerSource, reads_other_frames};
use super::{Arc, GroupKind, Node, Project, Tick};
/// Precomps nested deeper than this count as changed wherever they show.
const MAX_DEPTH: usize = 32;
type Seen = std::collections::HashMap<ItemId, Option<Vec<(Tick, Tick)>>>;
impl Project {
/// The comp-time spans of `comp` where `self` may draw other pixels than `old` (the project
/// before an edit): [`Comp::changed_spans`] of its layers, and for a precomp whose comp
/// changed, the spans that comp changed in, where the precomp shows them (#1641). Layers that
/// can't change the picture ([`Project::silent_layers`]) don't count, nor does the audio of
/// layers no effect draws it from (`heard`, best of both states). Expressions can read
/// anything, so any change (`outside` these comps too: another comp, an item's name) reaches
/// the layers that have one. `None` when the edit can reach any frame.
pub fn dirty_spans(&self, old: &Project, comp: ItemId, heard: &Heard, outside: bool) -> Option<Vec<(Tick, Tick)>> {
self.dirty_spans_at(old, comp, heard, outside, 0, &mut std::collections::HashMap::new())
}
/// `seen`: the spans of the comps already walked in this call, so a precomp shared by many
/// layers is walked once.
fn dirty_spans_at(&self, old: &Project, comp: ItemId, heard: &Heard, outside: bool, depth: usize, seen: &mut Seen) -> Option<Vec<(Tick, Tick)>> {
if depth > MAX_DEPTH {
return None;
}
if let Some(spans) = seen.get(&comp) {
return spans.clone();
}
let spans = self.comp_dirty_spans(old, comp, heard, outside, depth, seen);
seen.insert(comp, spans.clone());
spans
}
fn comp_dirty_spans(&self, old: &Project, comp: ItemId, heard: &Heard, outside: bool, depth: usize, seen: &mut Seen) -> Option<Vec<(Tick, Tick)>> {
let (new_c, old_c) = (self.comp_arc(comp)?, old.comp_arc(comp)?);
// Expressions may read what changed elsewhere, even from comps that didn't change.
let reads_elsewhere = heard.expressions && outside;
let quiet = self.silent_layers(comp, &new_c, heard);
// An edit of this comp is outside the comps it nests: their expressions may read it
// (`comp("Main").layer("Controls")`, at their own time).
let nested_outside = outside || !Arc::ptr_eq(&new_c, &old_c);
// Precomps (drawing the same comp before and after) whose comp changed: where they show it.
let mut touched = Vec::new();
let mut nested = Vec::new();
for l in new_c.layers.iter().filter(|l| !quiet.contains(&l.id)) {
let LayerSource::Comp { item } = l.source else { continue };
if old_c.layer(l.id).is_none_or(|before| before.source != l.source) {
continue;
}
// (an unchanged `Arc` says nothing of the comps nested in it: walk it anyway)
let (Some(n), Some(_)) = (self.comp_arc(item), old.comp_arc(item)) else { continue };
let inner = self.dirty_spans_at(old, item, heard, nested_outside, depth + 1, seen);
if inner.as_ref().is_some_and(Vec::is_empty) {
continue;
}
touched.push(l.id);
nested.extend(shown(l, &new_c, &n, inner));
}
if Arc::ptr_eq(&new_c, &old_c) && touched.is_empty() && !reads_elsewhere {
return Some(vec![]);
}
let picture = |p: &Project, c: &Comp| {
let mut c = c.clone();
let quiet = p.silent_layers(comp, &c, heard);
c.layers.retain(|l| !quiet.contains(&l.id));
c
};
let (a, b) = (picture(old, &old_c), picture(self, &new_c));
let elsewhere = heard.expressions && (outside || !new_c.same_pixels_with(&old_c, true, &|l| heard.contains(comp, l)) || !touched.is_empty());
let mut spans = b.changed_spans(&a, heard.expressions, &|l| heard.contains(comp, l), elsewhere, &touched)?;
spans.extend(nested);
Some(spans)
}
}
/// The comp-time spans of `outer` where its precomp layer `l` shows the spans `inner` of its comp
/// `n` (`None`: all of it), widened by `outer`'s shutter. A time remap, Responsive Design
/// timing or an effect reading the layer at other times (Echo, Posterize Time…: [`reads_other_frames`],
/// or an effect picking the layer itself) can show any nested time anywhere: then the layer's
/// whole span.
fn shown(l: &Layer, outer: &Comp, n: &Comp, inner: Option<Vec<(Tick, Tick)>>) -> Vec<(Tick, Tick)> {
let (before, after) = outer.shutter_margin();
let whole = vec![(l.in_point - before, l.out_point + after)];
let responsive = (l.stretch - 100.0).abs() > 1e-9 && n.markers.iter().any(|m| m.protected && m.duration > Tick::ZERO);
let Some(inner) = inner else { return whole };
if l.props.get("timeRemap").is_some() || responsive || reads_itself_elsewhen(l) {
return whole;
}
// (a nested frame either side: Preserve Frame Rate holds nested frames, Frame Blending mixes
// neighbours)
let fd = n.frame_duration();
inner
.into_iter()
.filter_map(|(s, e)| {
let (x, y) = (l.comp_time(s - fd), l.comp_time(e + fd));
let (lo, hi) = (x.min(y).max(l.in_point), x.max(y).min(l.out_point));
(lo <= hi).then(|| (lo - before, hi + after))
})
.collect()
}
/// Some effect of `l` reads the layer at other times ([`reads_other_frames`]) or picks the layer
/// itself (a layer parameter set to its own layer).
fn reads_itself_elsewhen(l: &Layer) -> bool {
let time_fx = |c: &Node| matches!(c, Node::Group(f) if matches!(&f.kind, GroupKind::Effect { effect } if reads_other_frames(effect)));
l.picked_layers().contains(&l.id) || l.props.group("effects").is_some_and(|e| e.children.iter().any(time_fx))
}
+105
View File
@@ -131,6 +131,78 @@ impl Project {
self.comps().any(|(_, c)| c.layers.iter().any(|l| any_expr(&l.props)))
}
/// The layers whose audio something draws ([`Heard`]): those a layer picks in an effect (an
/// Audio Spectrum or Audio Waveform's Audio Layer, even on itself), and the layers inside a
/// picked precomp, whose audio it mixes. With an expression anywhere (it can read any layer's
/// Audio Levels) every layer may be heard ([`Heard::contains`]).
pub fn heard_layers(&self) -> Heard {
let mut layers = std::collections::BTreeSet::new();
let mut stack: Vec<(ItemId, LayerId)> =
self.comps().flat_map(|(id, c)| c.layers.iter().flat_map(move |l| l.picked_layers().into_iter().map(move |r| (*id, r)))).collect();
// (bounded: each (comp, layer) is visited once)
while let Some((cid, lid)) = stack.pop() {
if !layers.insert((cid, lid)) {
continue;
}
let inner = self.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| match l.source {
LayerSource::Comp { item } => Some(item),
_ => None,
});
if let Some(inner) = inner
&& let Some(c) = self.comp(inner)
{
stack.extend(c.layers.iter().map(|l| (inner, l.id)));
}
}
Heard { expressions: self.reads_markers(), audio_exprs: self.reads_audio(), layers }
}
/// Some expression may read a layer's audio: its text names it (`audio`, `"Audio Levels"`,
/// `"ADBE Audio Group"`) or walks properties by index (`layer(1)(3)`).
// ponytail: text heuristic; misses an index walk through a variable (`g = layer(1); g(3)`).
// Upgrade: record audio reads in the expression host.
fn reads_audio(&self) -> bool {
fn reads(text: &str) -> bool {
let t: String = text.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_lowercase();
t.contains("audio") || t.match_indices(")(").any(|(i, _)| t.get(i + 2..).and_then(|r| r.chars().next()).is_some_and(|c| c.is_ascii_digit()))
}
fn any(g: &crate::PropGroup) -> bool {
g.children.iter().any(|c| match c {
crate::Node::Prop(p) => p.expr.as_ref().is_some_and(|e| reads(&e.text)),
crate::Node::Group(sub) => any(sub),
})
}
self.comps().any(|(_, c)| c.layers.iter().any(|l| any(&l.props)))
}
/// Layers of `comp` (item `id`) that can't change its pixels: footage layers of files without
/// video (music, voice-over) and layers with the Video switch off, unless another layer reads
/// them (a hidden track matte) or something draws their audio ([`Self::heard_layers`]: the
/// Audio Spectrum's Audio Layer, also from a comp that nests this one). Cameras, lights,
/// nulls, environment layers and soloed layers (Solo hides every other layer) are never
/// silent. Expressions, which can read any layer, are the caller's concern.
pub fn silent_layers(&self, id: ItemId, comp: &Comp, heard: &Heard) -> std::collections::BTreeSet<LayerId> {
let read: std::collections::BTreeSet<LayerId> =
comp.layers.iter().flat_map(|l| l.referenced_layers().into_iter().filter(move |r| *r != l.id)).collect();
comp.layers
.iter()
.filter(|l| {
let no_picture = match &l.source {
LayerSource::Footage { item } => matches!(self.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if !f.has_video),
_ => false,
};
// (environment layers light Advanced 3D scenes without being drawn)
(no_picture || l.source.is_av() && !l.switches.video)
&& !l.switches.solo
&& !l.environment
&& !l.environment_background
&& !read.contains(&l.id)
&& !heard.picked(id, l.id)
})
.map(|l| l.id)
.collect()
}
/// Allocate an id (items, layers and property uids share one counter).
pub fn alloc(&mut self) -> u64 {
let id = self.next_id;
@@ -324,3 +396,36 @@ impl Project {
Ok((p, notes))
}
}
/// The layers whose audio something draws ([`Project::heard_layers`]): their Audio switch, Audio
/// group and audio effects change pixels.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Heard {
/// Some layer has an expression (it can read anything).
pub expressions: bool,
/// Some expression may read a layer's audio ([`Project::reads_audio`]).
audio_exprs: bool,
layers: std::collections::BTreeSet<(ItemId, LayerId)>,
}
impl Heard {
/// Something may draw the audio of layer `layer` of comp `comp`: a layer picks it
/// ([`Self::picked`]) or an expression may read it.
pub fn contains(&self, comp: ItemId, layer: LayerId) -> bool {
self.audio_exprs || self.picked(comp, layer)
}
/// A layer picks layer `layer` of comp `comp` in an effect (or a precomp of it), so draws its
/// audio. Expressions aside: they draw in their own layers.
pub fn picked(&self, comp: ItemId, layer: LayerId) -> bool {
self.layers.contains(&(comp, layer))
}
/// Heard in `self` or in `o` (the states before and after an edit).
pub fn union(mut self, o: &Heard) -> Heard {
self.expressions |= o.expressions;
self.audio_exprs |= o.audio_exprs;
self.layers.extend(o.layers.iter().copied());
self
}
}
+233 -9
View File
@@ -1,6 +1,6 @@
use super::item::{ItemId, LayerId};
use super::settings::Marker;
use super::{BlendMode, Deserialize, Label, ParamUi, PropGroup, Property, Serialize, Tick};
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 {
@@ -182,25 +182,118 @@ impl Default for Switches {
}
}
// What a frame's pixels depend on. Caches key on it: the Audio, Lock and Shy switches and Hide
// Shy Layers don't change pixels, so toggling them keeps cached frames (#103).
// 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.
// 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.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.
/// 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)
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).
pub fn same_pixels_with(&self, o: &Layer, with_markers: bool) -> bool {
/// 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,
@@ -233,7 +326,7 @@ impl Layer {
&& *in_point == o.in_point
&& *out_point == o.out_point
&& *stretch == o.stretch
&& switches.pixels() == o.switches.pixels()
&& switches.picture(heard) == o.switches.picture(heard)
&& *blend_mode == o.blend_mode
&& *preserve_transparency == o.preserve_transparency
&& *track_matte == o.track_matte
@@ -243,7 +336,100 @@ impl Layer {
&& *auto_orient == o.auto_orient
&& *environment == o.environment
&& *environment_background == o.environment_background
&& *props == o.props
&& (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)
}
}
@@ -326,6 +512,44 @@ pub struct Layer {
}
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
}
+6 -2
View File
@@ -30,6 +30,7 @@ pub use effectcraft_keyframe as keyframe;
pub use effectcraft_keyframe::{Keyframe, Value};
mod comp;
mod dirty;
mod footage;
mod item;
mod layer;
@@ -39,8 +40,11 @@ mod settings;
pub use comp::{Comp, Guide, Renderer, Resolution};
pub use footage::{AlphaMode, CineonSettings, CineonUnits, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer, VIDEO_EXTENSIONS, cineon_log_to_lin};
pub use item::NO_LAYER;
pub use item::{Item, ItemId, ItemKind, LayerId};
pub use layer::{AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte};
pub use item::{Heard, Item, ItemId, ItemKind, LayerId};
pub use layer::{
AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte, is_audio_effect,
reads_other_frames,
};
pub use layers::Layers;
pub use settings::{
APP_VERSION, BitDepth, ColorEngine, CuePoint, HdrMode, Marker, ProjectError, ProjectSettings, SCHEMA_EFFECT_SPACE, SCHEMA_VERSION, SavedView,
+72
View File
@@ -410,3 +410,75 @@ fn upgrading_deeply_nested_shape_groups_fits_a_small_stack() {
let ids: Vec<&str> = g.children.iter().map(|c| c.match_id()).collect();
assert_eq!(ids, ["size", "detail", "points", "speed", "correlation", "phase", "spatialPhase", "seed"]);
}
/// Music in a precomp is heard, and so not silent, when a layer of a comp nesting it picks the
/// precomp layer (an Audio Waveform drawing the precomp's mix).
#[test]
fn music_in_a_precomp_a_waveform_picks_is_not_silent() {
use crate::{Footage, FootageKind, GroupKind};
let (mut p, inner) = project_with_layer();
let song = p.add_item(
"song.wav",
effectcraft_color::Label::None,
None,
ItemKind::Footage(Footage { kind: FootageKind::Audio, has_video: false, has_audio: true, ..Default::default() }),
);
let comp = p.comp(inner).unwrap().clone();
let music = build::layer(&mut p, &comp, "Music", LayerSource::Footage { item: song }, (1920, 1080), None);
let music_id = music.id;
p.comp_mut(inner).unwrap().layers.push(music);
let outer = p.add_item("Outer", effectcraft_color::Label::None, None, ItemKind::Comp(comp.clone().into()));
let pre = build::layer(&mut p, &comp, "Pre", LayerSource::Comp { item: inner }, (1920, 1080), None);
let pre_id = pre.id;
p.comp_mut(outer).unwrap().layers.push(pre);
let silent = |p: &Project| p.silent_layers(inner, p.comp(inner).unwrap(), &p.heard_layers()).contains(&music_id);
assert!(silent(&p), "nothing draws the precomp's audio");
let mut n = 1_000_000;
let mut ids = build::Ids(&mut n);
let mut g = ids.group("waveform", "Audio Waveform");
g.kind = GroupKind::Effect { effect: "ec.generate.audiowaveform".into() };
g.children.push(ids.prop("audioLayer", "Audio Layer", Value::Layer(Some(pre_id.0))).into());
let solid = p.comp(outer).unwrap().layers[0].id;
p.comp_mut(outer).unwrap().layer_mut(solid).unwrap().props.group_mut("effects").unwrap().children.push(g.into());
assert!(!silent(&p), "the outer comp's waveform draws the music");
}
/// Layers that can't change the picture: music, hidden layers nothing reads. A hidden matte is
/// read by the layer it mattes; nulls and cameras are never silent.
#[test]
fn silent_layers_are_music_and_unread_hidden_layers() {
use crate::{Footage, FootageKind, LayerId, MatteKind, TrackMatte};
let (mut p, cid) = project_with_layer();
let comp = p.comp(cid).unwrap().clone();
let song = p.add_item(
"song.wav",
effectcraft_color::Label::None,
None,
ItemKind::Footage(Footage { kind: FootageKind::Audio, has_video: false, has_audio: true, ..Default::default() }),
);
let add = |p: &mut Project, name: &str, source: LayerSource| {
let l = build::layer(p, &comp, name, source, (1920, 1080), None);
let id = l.id;
p.comp_mut(cid).unwrap().layers.push(l);
id
};
let music = add(&mut p, "Music", LayerSource::Footage { item: song });
let solid = p.comp(cid).unwrap().layers[0].source.clone();
let hidden = add(&mut p, "Hidden", solid.clone());
let matte = add(&mut p, "Matte", solid);
let null = add(&mut p, "Null", LayerSource::Null);
let first = p.comp(cid).unwrap().layers[0].id;
let c = p.comp_mut(cid).unwrap();
for id in [hidden, matte, null] {
c.layer_mut(id).unwrap().switches.video = false;
}
c.layer_mut(first).unwrap().track_matte = Some(TrackMatte { layer: matte, kind: MatteKind::Alpha });
let c = p.comp(cid).unwrap();
assert_eq!(p.silent_layers(cid, c, &p.heard_layers()).into_iter().collect::<Vec<LayerId>>(), vec![music, hidden]);
// Solo hides every other layer, so a soloed music or hidden layer changes the picture.
for (soloed, rest) in [(music, hidden), (hidden, music)] {
let mut q = p.clone();
q.comp_mut(cid).unwrap().layer_mut(soloed).unwrap().switches.solo = true;
assert_eq!(q.silent_layers(cid, q.comp(cid).unwrap(), &q.heard_layers()).into_iter().collect::<Vec<LayerId>>(), vec![rest]);
}
}
+243 -30
View File
@@ -33,9 +33,14 @@ use effectcraft_raster::Image;
/// File format version (part of the folder layout).
const VERSION: &str = "v1";
const MAGIC: &[u8; 4] = b"ECDC";
/// Pending writes before new ones are dropped.
/// Pending layer and matte writes before new ones are dropped.
#[cfg(not(target_arch = "wasm32"))]
const QUEUE_LIMIT: usize = 64;
/// Pending frame bytes before new frames are dropped. Frames have their own lane, written
/// before layer buffers: a burst of large layer writes used to fill the shared queue and drop
/// frames, so a fully cached comp came back with gaps in its blue bar.
#[cfg(not(target_arch = "wasm32"))]
const FRAME_QUEUE_BYTES: usize = 1 << 30;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Kind {
@@ -225,8 +230,30 @@ enum Job {
}
struct Queue {
/// Frame writes (taken first) and their payload bytes.
frames: std::collections::VecDeque<Job>,
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
frame_bytes: usize,
/// Layer and matte writes.
jobs: std::collections::VecDeque<Job>,
busy: bool,
/// Keys of frames queued or being written.
pending_frames: std::collections::HashSet<u128>,
}
impl Queue {
#[cfg(not(target_arch = "wasm32"))]
fn pop(&mut self) -> Option<Job> {
if let Some(j) = self.frames.pop_front() {
let Job::Write(_, _, p) = &j;
self.frame_bytes = self.frame_bytes.saturating_sub(p.len());
return Some(j);
}
self.jobs.pop_front()
}
fn is_empty(&self) -> bool {
self.frames.is_empty() && self.jobs.is_empty()
}
}
pub struct DiskCache {
@@ -320,7 +347,16 @@ impl DiskCache {
dropped: AtomicU64::new(0),
failed: AtomicU64::new(0),
tmp_counter: AtomicU64::new(0),
queue: Arc::new((Mutex::new(Queue { jobs: Default::default(), busy: cfg!(not(target_arch = "wasm32")) }), Condvar::new())),
queue: Arc::new((
Mutex::new(Queue {
frames: Default::default(),
frame_bytes: 0,
jobs: Default::default(),
busy: cfg!(not(target_arch = "wasm32")),
pending_frames: Default::default(),
}),
Condvar::new(),
)),
min_layer_ms: AtomicU64::new(30),
});
dc.rescan();
@@ -428,6 +464,11 @@ impl DiskCache {
self.index.lock().is_ok_and(|g| g.contains(kind, key))
}
/// A frame write is queued or in progress.
pub fn frame_pending(&self, key: u128) -> bool {
self.queue.0.lock().is_ok_and(|q| q.pending_frames.contains(&key))
}
/// Keys of one kind (unordered).
pub fn keys(&self, kind: Kind) -> Vec<u128> {
self.index.lock().map(|g| g.keys(kind)).unwrap_or_default()
@@ -467,8 +508,10 @@ impl DiskCache {
#[cfg(target_arch = "wasm32")]
{
q.jobs.clear();
q.frames.clear();
q.pending_frames.clear();
}
while !q.jobs.is_empty() || q.busy {
while !q.is_empty() || q.busy {
q = match cv.wait(q) {
Ok(q) => q,
Err(_) => return,
@@ -515,21 +558,34 @@ impl DiskCache {
}
}
fn enqueue(&self, kind: Kind, key: u128, payload: Vec<u8>) {
/// Queue a write; `false` when it was dropped (a full queue).
fn enqueue(&self, kind: Kind, key: u128, payload: Vec<u8>) -> bool {
#[cfg(target_arch = "wasm32")]
{
self.write_now(kind, key, &payload);
true
}
#[cfg(not(target_arch = "wasm32"))]
{
let (m, cv) = &*self.queue;
let Ok(mut q) = m.lock() else { return };
if q.jobs.len() >= QUEUE_LIMIT {
let Ok(mut q) = m.lock() else { return false };
let full = match kind {
Kind::Frame => q.frame_bytes.saturating_add(payload.len()) > FRAME_QUEUE_BYTES,
_ => q.jobs.len() >= QUEUE_LIMIT,
};
if full {
self.dropped.fetch_add(1, Ordering::Relaxed);
return;
return false;
}
if kind == Kind::Frame {
q.pending_frames.insert(key);
q.frame_bytes = q.frame_bytes.saturating_add(payload.len());
q.frames.push_back(Job::Write(kind, key, payload));
} else {
q.jobs.push_back(Job::Write(kind, key, payload));
}
q.jobs.push_back(Job::Write(kind, key, payload));
cv.notify_all();
true
}
}
@@ -595,12 +651,13 @@ impl DiskCache {
decode_frame(&p)
}
/// Queue a frame for writing (8-bit premultiplied RGBA, `width * height * 4` bytes).
pub fn put_frame(&self, key: u128, width: u32, height: u32, rgba: &[u8]) {
if self.contains(Kind::Frame, key) || rgba.len() != width as usize * height as usize * 4 {
return;
/// Queue a frame for writing (8-bit premultiplied RGBA, `width * height * 4` bytes). `true`
/// when it is on disk or queued; `false` when refused (wrong size, a full queue).
pub fn put_frame(&self, key: u128, width: u32, height: u32, rgba: &[u8]) -> bool {
if rgba.len() != width as usize * height as usize * 4 {
return false;
}
self.enqueue(Kind::Frame, key, encode_frame(width, height, rgba));
self.contains(Kind::Frame, key) || self.enqueue(Kind::Frame, key, encode_frame(width, height, rgba))
}
pub fn get_layer(&self, key: u128) -> Option<Buf> {
@@ -613,7 +670,7 @@ impl DiskCache {
if self.contains(Kind::Layer, key) {
return;
}
self.enqueue(Kind::Layer, key, encode_layer(buf));
let _ = self.enqueue(Kind::Layer, key, encode_layer(buf));
}
/// Write a layer buffer synchronously (tests).
@@ -634,7 +691,39 @@ impl DiskCache {
if self.contains(Kind::Matte, k) {
return;
}
self.enqueue(Kind::Matte, k, lz4_flex::compress_prepend_size(&seg.to_bytes()));
let _ = self.enqueue(Kind::Matte, k, lz4_flex::compress_prepend_size(&seg.to_bytes()));
}
/// Make the frame at key `from` the frame at key `to` too (an edit that can't change it gave
/// its comp a new content key): a hard link to the same file, or a copy where the file system
/// has no links. `false` when `from` isn't cached, `to` already is, or the file system
/// refused. (Entries don't contain their key, so the same file serves both.)
pub fn link_frame(&self, from: u128, to: u128) -> bool {
if !self.contains(Kind::Frame, from) || self.contains(Kind::Frame, to) {
return false;
}
let (src, dst) = (self.path(Kind::Frame, from), self.path(Kind::Frame, to));
let Some(parent) = dst.parent() else { return false };
let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed);
let tmp = parent.join(format!("{to:032x}.tmp.{}.{n}", std::process::id()));
let linked = std::fs::create_dir_all(parent)
.and_then(|()| std::fs::hard_link(&src, &tmp).or_else(|_| std::fs::copy(&src, &tmp).map(|_| ())))
.and_then(|()| std::fs::metadata(&tmp))
.and_then(|m| std::fs::rename(&tmp, &dst).map(|()| m.len()));
let bytes = match linked {
Ok(bytes) => bytes,
Err(_) => {
// (gone meanwhile: another process evicted it)
let _ = std::fs::remove_file(&tmp);
return false;
}
};
// (a link shares its file, but counts in full: the cache only ever errs small)
self.evict_to(self.limit_bytes().saturating_sub(bytes));
if let Ok(mut g) = self.index.lock() {
g.insert(Kind::Frame, to, bytes);
}
true
}
/// Write a frame synchronously (tests).
@@ -659,7 +748,7 @@ fn writer(dc: std::sync::Weak<DiskCache>, q: Arc<(Mutex<Queue>, Condvar)>) {
let job = {
let Ok(mut g) = m.lock() else { return };
loop {
if let Some(j) = g.jobs.pop_front() {
if let Some(j) = g.pop() {
g.busy = true;
break j;
}
@@ -673,12 +762,14 @@ fn writer(dc: std::sync::Weak<DiskCache>, q: Arc<(Mutex<Queue>, Condvar)>) {
}
};
let Some(cache) = dc.upgrade() else { return };
match job {
Job::Write(kind, key, payload) => cache.write_now(kind, key, &payload),
}
let Job::Write(kind, key, payload) = job;
cache.write_now(kind, key, &payload);
drop(cache);
if let Ok(mut g) = m.lock() {
g.busy = false;
if kind == Kind::Frame {
g.pending_frames.remove(&key);
}
}
cv.notify_all();
}
@@ -841,7 +932,7 @@ fn decode_layer(p: &[u8]) -> Option<Buf> {
// ---------------------------------------------------------------- content keys
/// Bump when rendering changes in a way that makes old disk entries wrong.
const RENDER_VERSION: &str = "effectcraft-render-5";
const RENDER_VERSION: &str = "effectcraft-render-6";
fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) {
h.write(format!("{v:?}").as_bytes());
@@ -889,8 +980,26 @@ pub fn footage_salt(project: &effectcraft_project::Project) -> u64 {
pub fn footage_salt_with(project: &effectcraft_project::Project, virtual_id: Option<VirtualFileId<'_>>) -> u64 {
let mut h = Hash128::default();
h.write(RENDER_VERSION.as_bytes());
// Footage another layer reads (Audio Spectrum's Audio Layer); a read precomp can hold any.
let mut read = std::collections::BTreeSet::new();
let mut read_comp = false;
for (_, c) in project.comps() {
for l in c.layers.iter() {
for r in l.referenced_layers().into_iter().filter(|r| *r != l.id) {
match c.layer(r).map(|x| &x.source) {
Some(effectcraft_project::LayerSource::Footage { item }) => _ = read.insert(*item),
Some(effectcraft_project::LayerSource::Comp { .. }) => read_comp = true,
_ => {}
}
}
}
}
for (id, it) in &project.items {
if let effectcraft_project::ItemKind::Footage(f) = &it.kind {
// (files without video make layer buffers only through a layer that reads them:
// importing music keeps the keys)
if let effectcraft_project::ItemKind::Footage(f) = &it.kind
&& (f.has_video || read_comp || read.contains(id))
{
h.write_u64(id.0);
hash_debug(&mut h, f);
footage_stamp(&mut h, f, virtual_id);
@@ -924,34 +1033,46 @@ fn comp_content_key_for_renderer(
let mut h = Hash128::default();
h.write(renderer.as_bytes());
hash_debug(&mut h, &project.settings);
// Expressions can read anything (every item counts); without them, layers that can't
// change the picture (music, hidden layers nothing reads) and their files don't count.
let expressions = project.reads_markers();
let heard = project.heard_layers();
let silent = |id, c: &effectcraft_project::Comp| if expressions { Default::default() } else { project.silent_layers(id, c, &heard) };
let mut seen = std::collections::BTreeSet::new();
let mut stack = vec![comp];
let mut expressions = false;
while let Some(id) = stack.pop() {
if !seen.insert(id) {
continue;
}
let Some(it) = project.item(id) else { continue };
if let ItemKind::Comp(c) = &it.kind {
for l in &c.layers {
let quiet = silent(id, c);
for l in c.layers.iter().filter(|l| !quiet.contains(&l.id)) {
if let LayerSource::Comp { item } | LayerSource::Footage { item } | LayerSource::Solid { item } = l.source {
stack.push(item);
}
if !expressions {
l.props.walk("", &mut |_, p| expressions |= p.expr.is_some());
}
}
}
}
// Markers are pixels only to the expressions that read them (#1083).
let markers = project.reads_markers();
let markers = expressions;
let ids: Vec<effectcraft_project::ItemId> = if expressions { project.items.keys().copied().collect() } else { seen.into_iter().collect() };
for id in ids {
let Some(it) = project.item(id) else { continue };
h.write_u64(id.0);
h.write(it.name.as_bytes());
match &it.kind {
ItemKind::Comp(c) => hash_debug(&mut h, &c.pixel_form_with(markers)),
ItemKind::Comp(c) => {
let quiet = silent(id, c);
let heard = |l| heard.contains(id, l);
if quiet.is_empty() {
hash_debug(&mut h, &c.pixel_form_with(markers, &heard));
} else {
let mut pic = (**c).clone();
pic.layers.retain(|l| !quiet.contains(&l.id));
hash_debug(&mut h, &pic.pixel_form_with(markers, &heard));
}
}
k => hash_debug(&mut h, k),
}
if let ItemKind::Footage(f) = &it.kind {
@@ -986,6 +1107,26 @@ mod tests {
d
}
/// A linked frame reads back as the original under its new key, survives the original's
/// eviction, and is never linked over an existing entry or from a missing one.
#[test]
fn link_frame_serves_a_frame_under_a_new_key() {
let dc = DiskCache::open(tmpdir("link"), 1 << 30).unwrap();
let rgba: Vec<u8> = (0..16).collect();
dc.put_frame_now(1, 2, 2, &rgba);
assert!(dc.link_frame(1, 2));
assert_eq!(dc.get_frame(2).map(|f| f.rgba), Some(rgba.clone()));
assert!(!dc.link_frame(1, 2), "already there");
assert!(!dc.link_frame(3, 4), "nothing to link");
assert!(!dc.contains(Kind::Frame, 4));
dc.put_frame_now(5, 2, 2, &[7; 16]);
assert!(!dc.link_frame(5, 2), "an existing entry stays");
assert_eq!(dc.get_frame(2).map(|f| f.rgba), Some(rgba.clone()));
let _ = std::fs::remove_file(dc.path(Kind::Frame, 1));
dc.forget(Kind::Frame, 1);
assert_eq!(dc.get_frame(2).map(|f| f.rgba), Some(rgba), "the link outlives the original");
}
fn buf(seed: u32, w: u32, h: u32) -> Buf {
let mut img = Image::new(w, h);
for (i, p) in img.data.iter_mut().enumerate() {
@@ -1073,7 +1214,8 @@ mod tests {
assert_eq!(back.img, b.img);
assert_eq!((back.offset, back.scale), (b.offset, b.scale));
let rgba: Vec<u8> = (0..20 * 10 * 4).map(|i| (i % 251) as u8).collect();
dc.put_frame(7, 20, 10, &rgba);
assert!(dc.put_frame(7, 20, 10, &rgba));
assert!(!dc.put_frame(8, 2, 2, &[0; 3]), "a wrong-size frame is refused");
dc.flush();
assert_eq!(dc.get_frame(7).unwrap().rgba, rgba);
let st = dc.stats();
@@ -1082,6 +1224,51 @@ mod tests {
assert!(dc.get_frame(42).is_none());
}
/// Music doesn't change a comp's disk keys (adding it to the timeline kept no blue frame);
/// hiding a clip does. Importing music keeps the layer-cache salt.
#[test]
fn music_keeps_the_disk_keys() {
use effectcraft_project::{Comp, Footage, FootageKind, ItemKind, LayerSource, Project, Solid, build};
use effectcraft_time::{FrameRate, Tick};
let mut p = Project::default();
let comp = Comp::new(64, 64, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Main", Default::default(), None, ItemKind::Comp(comp.clone().into()));
let sid = p.add_item("Solid", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 64, height: 64, pixel_aspect: 1.0 }));
let clip = build::layer(&mut p, &comp, "Clip", LayerSource::Solid { item: sid }, (64, 64), None);
let clip_id = clip.id;
p.comp_mut(cid).unwrap().layers.push(clip);
let (key, salt) = (comp_content_key(&p, cid), footage_salt(&p));
let song = p.add_item(
"song.wav",
Default::default(),
None,
ItemKind::Footage(Footage { kind: FootageKind::Audio, has_video: false, has_audio: true, ..Default::default() }),
);
assert_eq!(footage_salt(&p), salt, "importing music");
let music = build::layer(&mut p, &comp, "Music", LayerSource::Footage { item: song }, (64, 64), None);
p.comp_mut(cid).unwrap().layers.insert(0, music);
assert_eq!(comp_content_key(&p, cid), key, "music on the timeline");
p.comp_mut(cid).unwrap().layer_mut(clip_id).unwrap().switches.video = false;
assert_ne!(comp_content_key(&p, cid), key, "a hidden clip");
}
/// A burst of layer writes never crowds out frames: every frame of a fully cached comp is
/// on disk afterwards (the blue bar came back with gaps).
#[test]
fn frames_are_kept_when_layer_writes_fill_the_queue() {
let dir = tmpdir("burst");
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
for k in 0..300 {
dc.put_layer(10_000 + k, &buf(1, 64, 64));
}
let rgba = vec![7u8; 32 * 32 * 4];
for k in 0..300 {
dc.put_frame(k, 32, 32, &rgba);
}
dc.flush();
assert!((0..300).all(|k| dc.contains(Kind::Frame, k)), "every frame written");
}
#[test]
fn previous_renderer_frames_are_not_reused() {
use effectcraft_project::{Comp, ItemKind, Project};
@@ -1091,7 +1278,7 @@ mod tests {
let comp = Comp::new(2, 2, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let id = project.add_item("Cached composition", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into()));
let key = frame_key(comp_content_key(&project, id), 0, 1000, 0);
let previous_key = frame_key(comp_content_key_for_renderer(&project, id, None, "effectcraft-render-4"), 0, 1000, 0);
let previous_key = frame_key(comp_content_key_for_renderer(&project, id, None, "effectcraft-render-5"), 0, 1000, 0);
let dir = tmpdir("previous-renderer");
{
let cache = DiskCache::open(&dir, 1 << 30).unwrap();
@@ -1267,6 +1454,32 @@ mod tests {
}
}
/// An audio file nothing reads stays out of the layer salt; one a layer reads (Audio
/// Spectrum) is in it, so overwriting it doesn't reuse that layer's buffers.
#[test]
fn read_audio_counts_in_the_footage_salt() {
use effectcraft_project::build;
use effectcraft_project::{Comp, Footage, FootageKind, ItemKind, LayerSource, MatteKind, Project, TrackMatte};
use effectcraft_time::{FrameRate, Tick};
let mut p = Project::default();
let f = Footage { path: "/song.wav".into(), kind: FootageKind::Audio, has_video: false, has_audio: true, ..Default::default() };
let song = p.add_item("song.wav", effectcraft_color::Label::None, None, ItemKind::Footage(f));
let mut comp = Comp::new(16, 9, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let music = build::layer(&mut p, &comp, "music", LayerSource::Footage { item: song }, (16, 9), None);
let mut reader = build::layer(&mut p, &comp, "reader", LayerSource::Null, (16, 9), None);
let music_id = music.id;
comp.layers = vec![reader.clone(), music].into();
let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into()));
let id = |mark: u64| move |path: &str| (path == "/song.wav").then_some((8, mark));
let (one, two) = (id(1), id(2));
assert_eq!(footage_salt_with(&p, Some(&one)), footage_salt_with(&p, Some(&two)), "unread music stays out");
reader.track_matte = Some(TrackMatte { layer: music_id, kind: MatteKind::Alpha });
*p.comp_mut(cid).unwrap().layers.first_mut().unwrap() = reader;
assert_ne!(footage_salt_with(&p, Some(&one)), footage_salt_with(&p, Some(&two)), "read audio counts");
}
/// A path with no native metadata takes its key from the caller's size and content hash (#1253).
#[test]
fn virtual_file_bytes_change_content_keys() {
+1
View File
@@ -48,6 +48,7 @@ egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
pollster = "1"
effectcraft-segment = { workspace = true }
effectcraft-host = { workspace = true }
effectcraft-media = { workspace = true }
exr = { workspace = true }
[build-dependencies]
+177
View File
@@ -0,0 +1,177 @@
//! Background caching benchmark: how fast the RAM preview fills a comp, and where the time goes.
//!
//! `cargo run --release -p effectcraft-ui-egui --example cache_bench -- [options]`
//!
//! Without `--project` it builds a comp like a typical edit: eight 1080p30 H.264 clips one after
//! another (encoded once into `target/cache-bench/media` with the Render Queue, no committed
//! media) under an adjustment layer with Gaussian Blur. It then fills the comp's RAM preview the
//! way idle caching does, through [`Frames`], and prints frames per second with the decoder,
//! footage-cache and disk-cache counters.
//!
//! Options: `--project P --comp C` (a saved project's comp instead), `--scale S` (render scale,
//! default 0.5: a viewer at half size), `--frames N` (frames to cache, default the whole comp),
//! `--slots N` (frames in flight, at least 1, default one per frame thread as the app does when
//! idle),
//! `--disk` (with a fresh disk cache in `target/cache-bench/disk`), `--no-adjustment`.
use std::sync::Arc;
use std::time::{Duration, Instant};
use effectcraft_engine::Session;
use effectcraft_engine::project::ItemId;
use effectcraft_engine::render::RenderOpts;
use effectcraft_ui_egui::frames::{FrameKey, Frames, RenderSource, opts_hash};
use serde_json::{Value, json};
fn arg(name: &str) -> Option<String> {
let a: Vec<String> = std::env::args().collect();
a.iter().position(|x| x == name).and_then(|i| a.get(i + 1).cloned())
}
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// Eight copies of one generated clip (separate files: separate decoders, as with real clips).
fn clips(dir: &std::path::Path) -> Vec<String> {
let media = dir.join("media");
std::fs::create_dir_all(&media).expect("media folder");
let first = media.join("clip0.mp4");
if !first.exists() {
eprintln!("encoding the source clip (once)…");
let mut s = effectcraft_host::session();
run(&mut s, "comp.new", json!({"name": "Src", "width": 1920, "height": 1080, "frameRate": 30, "duration": 4.25}));
let l = run(&mut s, "layer.newSolid", json!({"color": "#808080"}))["layer"].clone();
run(&mut s, "effect.apply", json!({"layers": [l], "effect": "Fractal Noise"}));
run(&mut s, "prop.addKey", json!({"layer": l, "path": "effects/#1/evolution", "time": 0.0, "value": 0.0}));
run(&mut s, "prop.addKey", json!({"layer": l, "path": "effects/#1/evolution", "time": 4.25, "value": 720.0}));
run(&mut s, "renderQueue.add", json!({"comp": "Src", "format": "h264", "output": first.to_string_lossy()}));
run(&mut s, "renderQueue.render", json!({"wait": true}));
}
(0..8)
.map(|i| {
let p = media.join(format!("clip{i}.mp4"));
if !p.exists() {
std::fs::copy(&first, &p).expect("copy clip");
}
p.to_string_lossy().to_string()
})
.collect()
}
fn main() {
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/cache-bench");
let mut s = effectcraft_host::session();
let pool = Arc::new(effectcraft_media::MediaPool::new());
s.footage = pool.clone();
if std::env::args().any(|a| a == "--disk") {
let disk = dir.join("disk");
if let Err(e) = std::fs::remove_dir_all(&disk)
&& e.kind() != std::io::ErrorKind::NotFound
{
eprintln!("can't clear the disk cache at {}: {e}", disk.display());
std::process::exit(2);
}
s.prefs.disk.disk_cache_enabled = true;
s.prefs.disk.disk_cache_folder = disk.to_string_lossy().to_string();
s.configure_disk_cache();
}
let cid = match arg("--project") {
Some(p) => {
run(&mut s, "file.open", json!({"path": p}));
let comp = match arg("--comp") {
Some(c) => s.project.comps().find(|(id, _)| s.project.item(**id).is_some_and(|it| it.name == c) || id.0.to_string() == c).map(|(id, _)| *id),
None => s.active_comp_id(),
};
comp.unwrap_or_else(|| {
eprintln!("no such composition: {}", arg("--comp").unwrap_or_else(|| "(no active comp; pass --comp)".into()));
std::process::exit(2)
})
}
None => {
let paths = clips(&dir);
let cid = ItemId(
run(&mut s, "comp.new", json!({"name": "Edit", "width": 1920, "height": 1080, "frameRate": 30, "duration": 34}))["comp"]
.as_u64()
.expect("comp"),
);
let items = run(&mut s, "file.import", json!({"paths": paths}))["items"].clone();
for (i, item) in items.as_array().expect("items").iter().enumerate() {
let l = run(&mut s, "layer.addItem", json!({"item": item}))["layer"].as_u64().expect("layer");
let c = Arc::make_mut(&mut s.project).comp_mut(cid).expect("comp");
let fr = c.frame_rate;
let l = c.layer_mut(effectcraft_engine::project::LayerId(l)).expect("layer");
let at = fr.tick_of(i as i64 * 1275 / 10);
(l.start_time, l.in_point, l.out_point) = (at, at, fr.tick_of((i as i64 + 1) * 1275 / 10));
}
if !std::env::args().any(|a| a == "--no-adjustment") {
let adj = run(&mut s, "layer.newAdjustment", json!({}))["layer"].clone();
run(&mut s, "effect.apply", json!({"layers": [adj], "effect": "Gaussian Blur"}));
run(&mut s, "prop.set", json!({"layer": adj, "path": "effects/#1/blurriness", "value": 4.0}));
}
cid
}
};
let comp = s.project.comp(cid).expect("comp").clone();
let total = comp.frame_rate.frame_at(comp.duration);
let n = arg("--frames").and_then(|v| v.parse().ok()).unwrap_or(total).min(total);
let scale: f64 = arg("--scale").and_then(|v| v.parse().ok()).unwrap_or(0.5);
let frames = Frames::default();
// (as Cache Frames When Idle: one frame per frame thread, see `app/prefetch.rs`)
let slots: usize = arg("--slots").and_then(|v| v.parse().ok()).map(|n: usize| n.max(1)).unwrap_or(frames.parallelism().max(1));
// Room for every frame: this measures speed, not eviction.
frames.set_budget(64 << 30);
let opts = RenderOpts { scale, motion_blur: true, ..Default::default() };
let src = RenderSource {
project: s.project.clone(),
footage: s.footage.clone(),
expr: s.expr.clone(),
layer_cache: s.layer_cache.clone(),
gpu: None,
gpu_display: false,
disk: s.disk_cache.clone(),
};
let series = FrameKey {
revision: 1,
content: frames.content_of(&src.project, 1, cid),
comp: cid.0,
frame: 0,
scale: (scale * 1000.0).round() as u32,
view: 0,
opts: opts_hash(&opts),
};
eprintln!("caching {n} frames of {}×{} at scale {scale} with {slots} in flight…", comp.width, comp.height);
let (gop0, t0) = (effectcraft_media::gop_stats(), Instant::now());
let mut next = 0;
loop {
while next < n && frames.inflight() < slots {
frames.request_background(&src, FrameKey { frame: next, ..series }, cid, comp.frame_rate.tick_of(next), opts);
next += 1;
}
if next >= n && frames.inflight() == 0 {
break;
}
std::thread::sleep(Duration::from_millis(1));
}
let secs = t0.elapsed().as_secs_f64();
let gop = effectcraft_media::gop_stats();
let cached = frames.cached_frames(&series).len();
println!("frames cached {cached}/{n} in {secs:.2} s: {:.1} frames/s, {:.1} ms each", cached as f64 / secs, secs * 1000.0 / cached.max(1) as f64);
println!(
"video decoding {:.2} s in decoders, {} samples decoded, {} seeks, {} cache hits",
(gop.decode_ns - gop0.decode_ns) as f64 / 1e9,
gop.decoded - gop0.decoded,
gop.seeks - gop0.seeks,
gop.hits - gop0.hits
);
let p = pool.stats();
println!("footage cache {} hits, {} misses, {} frames ({} MB) kept", p.hits, p.misses, p.frames, p.bytes >> 20);
if let Some(dc) = &s.disk_cache {
// (writes finish on the disk cache's own thread)
dc.flush();
let d = dc.stats();
println!("disk cache {} written, {} dropped, {} entries ({} MB)", d.writes, d.dropped, d.entries, d.bytes >> 20);
}
}
+6
View File
@@ -399,6 +399,12 @@ impl EffectcraftApp {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(1.0 - quiet));
return;
}
// Frames an edit kept in RAM are on disk under the comp's previous content only.
// Again soon while frames wait to be written (a refused write is retried then).
#[cfg(not(target_arch = "wasm32"))]
if self.frames.persist_missing(&self.render_source(), series, &self.frame_opts(ItemId(series.comp), series.scale as f64 / 1000.0)) {
ctx.request_repaint_after(std::time::Duration::from_millis(500));
}
// An empty work area has nothing to cache.
let n = wb - wa + 1;
if n < 1 {
+9
View File
@@ -105,6 +105,15 @@ impl EffectcraftApp {
self.frame_series(comp, scale as f64 / 1000.0)
}
/// The frame series a preview with options `o` plays: at its Resolution, or the one the
/// viewer shows with Auto (it follows the zoom). The Preview panel's cache bar counts it.
pub fn preview_series(&self, comp: ItemId, o: &effectcraft_engine::preview::PreviewPreset) -> FrameKey {
match o.resolution.factor(o.custom_resolution) {
Some(n) => self.frame_series(comp, 1.0 / f64::from(n)),
None => self.shown_series(comp),
}
}
/// Hash of the comp viewer's 3D view camera (0 for the active camera view).
pub fn view_hash(&self, comp: ItemId) -> u64 {
use std::hash::{Hash, Hasher};
+243 -20
View File
@@ -28,7 +28,7 @@ use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_engine::project::{Comp, ItemId, ItemKind, LayerSource, Node, Project, PropGroup};
use effectcraft_engine::project::{Comp, Heard, ItemId, ItemKind, LayerSource, Node, Project, PropGroup};
use effectcraft_engine::render::disk_cache::{self, DiskCache};
use effectcraft_engine::render::{CancellableFootage, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
use effectcraft_engine::time::Tick;
@@ -337,12 +337,26 @@ fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64,
/// hands out), and by value the project settings and the footage, solids and proxies it uses.
/// With expressions in its comps (which can read anything) every item counts.
pub fn comp_content(project: &Project, comp: ItemId) -> u64 {
comp_content_with(project, comp, project, |_, c| c)
comp_content_with(project, comp, project, |_, c| c, true)
}
/// Frame ranges (inclusive) of `comp` in `new` that may draw differently than in `old`
/// ([`Project::dirty_spans`]), a frame wider either side for rounding.
fn dirty_frames(old: &Project, new: &Project, comp: ItemId, heard: &Heard, outside: bool) -> Option<Vec<(i64, i64)>> {
let fr = new.comp(comp)?.frame_rate;
Some(new.dirty_spans(old, comp, heard, outside)?.into_iter().map(|(s, e)| (fr.frame_at(s).saturating_sub(1), fr.frame_at(e).saturating_add(1))).collect())
}
/// [`comp_content`] with each comp's version selected for both its sources and its address.
/// Sources removed by undo are read from `base`.
fn comp_content_with<'a>(project: &'a Project, comp: ItemId, base: &'a Project, version: impl Fn(ItemId, &'a Arc<Comp>) -> &'a Arc<Comp>) -> u64 {
/// Sources removed by undo are read from `base`. Only the comps and items it draws count unless
/// `everything` and an expression (which can read any item) is among them.
fn comp_content_with<'a>(
project: &'a Project,
comp: ItemId,
base: &'a Project,
version: impl Fn(ItemId, &'a Arc<Comp>) -> &'a Arc<Comp>,
everything: bool,
) -> u64 {
use std::hash::{Hash, Hasher};
fn has_expression(g: &PropGroup) -> bool {
g.children.iter().any(|c| match c {
@@ -366,7 +380,7 @@ fn comp_content_with<'a>(project: &'a Project, comp: ItemId, base: &'a Project,
if let LayerSource::Comp { item } | LayerSource::Footage { item } | LayerSource::Solid { item } = l.source {
stack.push(item);
}
expressions = expressions || has_expression(&l.props);
expressions = expressions || (everything && has_expression(&l.props));
}
}
let ids: Vec<ItemId> = if expressions { project.items.keys().copied().collect() } else { seen.into_iter().collect() };
@@ -505,8 +519,41 @@ pub struct Frames {
/// Where frames render instead of the UI thread (wasm32), and how many it is rendering.
remote: Option<Arc<dyn RemoteFrames>>,
remote_busy: Arc<Mutex<usize>>,
/// Disk keys [`Frames::persist_missing`] is converting (the disk cache tracks them once
/// queued, so an evicted or refused frame is written again).
#[cfg(not(target_arch = "wasm32"))]
persisted: Arc<Mutex<HashSet<u128>>>,
/// The disk limit and how often each disk key was written under it: a frame is rewritten
/// once after an eviction, not again (a disk cache smaller than the RAM preview, or a frame
/// that can't fit, would otherwise be rewritten on every idle pass). A new limit resets it.
#[cfg(not(target_arch = "wasm32"))]
written: Arc<Mutex<(u64, HashMap<u128, u8>)>>,
/// Edits whose kept frames are still to be found on disk under the new content key
/// ([`Frames::carry_disk`]), oldest first.
#[cfg(not(target_arch = "wasm32"))]
disk_carries: Arc<Mutex<VecDeque<DiskCarry>>>,
/// A carry job is scheduled or running: at most one at a time, draining the edits in order.
#[cfg(not(target_arch = "wasm32"))]
carry_scheduled: Arc<AtomicBool>,
/// The disk series (scale, view and options hash) each comp's frames are cached in.
#[cfg(not(target_arch = "wasm32"))]
disk_series: Arc<Mutex<HashMap<u64, Vec<(u32, u64)>>>>,
}
/// An edit's kept frames, to find on disk under the comp's new content key: those of `comp` in
/// `from` outside `dirty` (inclusive frame ranges) also draw `to`'s.
#[cfg(not(target_arch = "wasm32"))]
struct DiskCarry {
comp: u64,
from: Arc<Project>,
to: Arc<Project>,
dirty: Vec<(i64, i64)>,
}
/// Disk series remembered per comp (see [`Frames::disk_series`]).
#[cfg(not(target_arch = "wasm32"))]
const MAX_DISK_SERIES: usize = 8;
impl Default for Frames {
fn default() -> Self {
#[cfg(not(target_arch = "wasm32"))]
@@ -554,6 +601,16 @@ impl Default for Frames {
keepers: Mutex::default(),
remote: None,
remote_busy: Arc::default(),
#[cfg(not(target_arch = "wasm32"))]
persisted: Arc::default(),
#[cfg(not(target_arch = "wasm32"))]
written: Arc::default(),
#[cfg(not(target_arch = "wasm32"))]
disk_carries: Arc::default(),
#[cfg(not(target_arch = "wasm32"))]
carry_scheduled: Arc::default(),
#[cfg(not(target_arch = "wasm32"))]
disk_series: Arc::default(),
}
}
}
@@ -789,29 +846,51 @@ impl Frames {
let Ok(mut bases) = self.bases.lock() else { return (plain, None) };
// Markers are pixels only to the expressions that read them (#1083).
let markers = project.reads_markers();
// Whose audio is drawn, before or after the edit (a waveform added or removed counts).
let heard = bases.get(&comp.0).map_or_else(|| project.heard_layers(), |(_, base)| project.heard_layers().union(&base.heard_layers()));
if let Some((base_id, base)) = bases.get(&comp.0)
&& *base_id != plain
&& comp_content_with(project, comp, project, |id, c| match base.item(id).map(|i| &i.kind) {
Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || b.same_pixels_with(c, markers) => b,
_ => c,
}) == *base_id
&& comp_content_with(
project,
comp,
project,
|id, c| match base.item(id).map(|i| &i.kind) {
Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || b.same_pixels_with(c, markers, &|l| heard.contains(id, l)) => b,
_ => c,
},
true,
) == *base_id
{
return (*base_id, Some(base.clone()));
}
// Only layers of this comp changed: its frames outside their span (and a frame either
// side for rounding) keep drawing the same pixels, so a cut or trim recaches only the
// layers it touched (#1641).
// Only comps changed (not the footage, solids or settings they draw): its frames outside
// the spans the edit reaches (and a frame either side for rounding) keep drawing the same
// pixels, so a nudge, cut or trim, here or in a precomp, recaches only the frames it
// touched (#1641). Expressions can read anything at any time, so with them a change
// elsewhere in the project (another comp, an item's name) reaches the frames of the
// layers that have one.
let base_comp = |id: ItemId| bases.get(&comp.0).and_then(|(_, base)| base.item(id)).map(|i| &i.kind);
let base_tree = |id, c| match base_comp(id) {
Some(ItemKind::Comp(b)) if id == comp || Arc::ptr_eq(b, c) || b.same_pixels_with(c, markers, &|l| heard.contains(id, l)) => b,
_ => c,
};
let base_comps = |id, c| match base_comp(id) {
Some(ItemKind::Comp(b)) => b,
_ => c,
};
if let Some((base_id, base)) = bases.get(&comp.0)
&& let (Some(old), Some(new)) = (base.comp(comp), project.comp(comp))
&& comp_content_with(project, comp, base, |id, c| match base.item(id).map(|i| &i.kind) {
Some(ItemKind::Comp(b)) if id == comp || Arc::ptr_eq(b, c) || b.same_pixels_with(c, markers) => b,
_ => c,
}) == *base_id
&& let Some((s, e)) = new.changed_span(old)
&& comp_content_with(project, comp, base, base_comps, false) == comp_content_with(base, comp, base, |_, c| c, false)
&& let Some(dirty) = dirty_frames(base, project, comp, &heard, comp_content_with(project, comp, base, base_tree, true) != *base_id)
&& let Ok(mut cache) = self.cache.lock()
{
let (first, last) = (new.frame_rate.frame_at(s).saturating_sub(1), new.frame_rate.frame_at(e).saturating_add(1));
cache.carry(*base_id, plain, |f| f < first || f > last);
cache.carry(*base_id, plain, |f| !dirty.iter().any(|(lo, hi)| (*lo..=*hi).contains(&f)));
#[cfg(not(target_arch = "wasm32"))]
if let Ok(mut q) = self.disk_carries.lock() {
if q.len() >= MAX_KEEPERS {
q.pop_front();
}
q.push_back(DiskCarry { comp: comp.0, from: base.clone(), to: project.clone(), dirty });
}
}
if bases.len() > MAX_KEEPERS {
bases.clear();
@@ -857,6 +936,11 @@ impl Frames {
}
let epoch = self.virtual_epoch.load(Ordering::Relaxed);
#[cfg(not(target_arch = "wasm32"))]
{
self.note_disk_series(base.comp, base.scale, base.view ^ opts_hash(opts));
self.carry_disk(src);
}
#[cfg(not(target_arch = "wasm32"))]
let content = {
let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp, epoch)).copied();
let Some(content) = known else {
@@ -872,6 +956,145 @@ impl Frames {
(start..end).filter(|f| !ram.contains(f)).filter(|f| contains(disk_cache::frame_key(content, *f, base.scale, base.view ^ oh))).collect()
}
/// Write the RAM frames of `base`'s series that the disk cache lacks: frames an edit kept
/// (written under the comp's previous content key) and writes the disk cache dropped. They
/// are converted on the frame pool, each queued once. `true` while there may be more to
/// write (a content key being computed, frames being converted): call again soon, since a
/// refused write is only retried by a later call. Disk keys hash `opts` the way the worker
/// writes them (`base.opts` also carries the Roto Brush model stamp).
#[cfg(not(target_arch = "wasm32"))]
pub fn persist_missing(&self, src: &RenderSource, base: &FrameKey, opts: &RenderOpts) -> bool {
let Some(dc) = src.disk.clone() else { return false };
let oh = opts_hash(opts);
self.note_disk_series(base.comp, base.scale, base.view ^ oh);
self.carry_disk(src);
let epoch = self.virtual_epoch.load(Ordering::Relaxed);
let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp, epoch)).copied();
let Some(content) = known else {
self.request_content_key(src.project.clone(), base.revision, base.comp);
return true;
};
let (missing, converting): (Vec<(u128, Arc<egui::ColorImage>)>, bool) = {
let (Ok(c), Ok(mut queued), Ok(mut written)) = (self.cache.lock(), self.persisted.lock(), self.written.lock()) else { return false };
if queued.len() > 100_000 {
queued.clear();
}
let limit = dc.limit_bytes();
if written.0 != limit || written.1.len() > 100_000 {
*written = (limit, HashMap::new());
}
let missing = c
.map
.iter()
.filter(|(k, _)| k.same_series(base))
.filter_map(|(k, img)| {
let FrameImage::Cpu(img) = img else { return None };
let dk = disk_cache::frame_key(content, k.frame, k.scale, k.view ^ oh);
let tries = written.1.get(&dk).copied().unwrap_or(0);
(tries < 2 && !dc.contains(disk_cache::Kind::Frame, dk) && !dc.frame_pending(dk) && queued.insert(dk)).then(|| (dk, img.clone()))
})
.collect();
(missing, !queued.is_empty())
};
if missing.is_empty() {
return converting;
}
let (persisted, written) = (self.persisted.clone(), self.written.clone());
let work = move || {
for (dk, img) in missing {
let rgba: Vec<u8> = img.pixels.iter().flat_map(|c| c.to_array()).collect();
// (accepted: the disk cache tracks it until written, and it counts as a try;
// refused by a full queue: retried, without counting)
if dc.put_frame(dk, img.size[0] as u32, img.size[1] as u32, &rgba)
&& let Ok(mut w) = written.lock()
{
let n = w.1.entry(dk).or_default();
*n = n.saturating_add(1);
}
if let Ok(mut q) = persisted.lock() {
q.remove(&dk);
}
}
};
match &self.pool {
Some(pool) => pool.spawn(work),
None => rayon::spawn(work),
}
true
}
/// Remember that `comp`'s frames are cached on disk at `scale` with view and options hash
/// `view` (the most recent [`MAX_DISK_SERIES`]).
#[cfg(not(target_arch = "wasm32"))]
fn note_disk_series(&self, comp: u64, scale: u32, view: u64) {
if let Ok(mut m) = self.disk_series.lock() {
let v = m.entry(comp).or_default();
if v.first() != Some(&(scale, view)) {
v.retain(|s| *s != (scale, view));
v.insert(0, (scale, view));
v.truncate(MAX_DISK_SERIES);
}
}
}
/// Find the frames edits kept ([`Frames::identity`]) on disk under their comps' new content
/// keys: link each kept frame of every disk series of the comp to its new key, on the frame
/// pool, oldest edit first.
#[cfg(not(target_arch = "wasm32"))]
fn carry_disk(&self, src: &RenderSource) {
let Some(dc) = src.disk.clone() else { return };
let pending = |q: &Mutex<VecDeque<DiskCarry>>| q.lock().is_ok_and(|q| !q.is_empty());
// (called on every paint: one job drains every carry queued meanwhile)
if !pending(&self.disk_carries) || self.carry_scheduled.swap(true, Ordering::AcqRel) {
return;
}
let (carries, scheduled, series, ctx) = (self.disk_carries.clone(), self.carry_scheduled.clone(), self.disk_series.clone(), self.ctx.clone());
let virtual_id = self.virtual_id.clone();
let work = move || {
let mut linked = false;
loop {
let todo: Vec<DiskCarry> = carries.lock().map(|mut q| q.drain(..).collect()).unwrap_or_default();
if todo.is_empty() {
scheduled.store(false, Ordering::Release);
// (a carry queued after the drain and before the store: keep it, unless a new job took it)
if pending(&carries) && !scheduled.swap(true, Ordering::AcqRel) {
continue;
}
break;
}
for c in todo {
// (a panicking carry is skipped: the others still carry, and the flag is
// still cleared below)
let carry = || {
let Some(series) = series.lock().ok().and_then(|m| m.get(&c.comp).cloned()) else { return false };
let comp = ItemId(c.comp);
let n = c.to.comp(comp).map_or(0, |k| k.frame_rate.frame_at(k.duration)).clamp(0, 100_000);
let key = |p: &Project| disk_cache::comp_content_key_with(p, comp, virtual_id.as_deref());
let (a, b) = (key(&c.from), key(&c.to));
if a == b {
return false;
}
let mut linked = false;
for f in (0..n).filter(|f| !c.dirty.iter().any(|(lo, hi)| (*lo..=*hi).contains(f))) {
for (scale, view) in &series {
linked |= dc.link_frame(disk_cache::frame_key(a, f, *scale, *view), disk_cache::frame_key(b, f, *scale, *view));
}
}
linked
};
linked |= std::panic::catch_unwind(std::panic::AssertUnwindSafe(carry)).unwrap_or(false);
}
}
if linked && let Some(ctx) = &ctx {
ctx.request_repaint();
}
};
match &self.pool {
Some(pool) => pool.spawn(work),
None => rayon::spawn(work),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn request_content_key(&self, project: Arc<Project>, revision: u64, comp: u64) {
let mut jobs = self.content_key_jobs.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
File diff suppressed because it is too large. Load diff
+11 -11
View File
@@ -240,7 +240,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Browser Storage", "ブラウザーストレージ"),
("Browse…", "参照…"),
("Brush Dynamics", "ブラシのダイナミクス"),
("Caching frames…", "フレームをキャッシュ中…"),
("Caching frames", "フレームをキャッシュ中"),
("Cancel", "キャンセル"),
("Import After Effects Project", "After Effectsプロジェクトを読み込み"),
("Some parts of this project cannot be imported exactly (left out or changed):", "このプロジェクトの一部は正確に読み込めません(省略または変更されます):"),
@@ -1371,7 +1371,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Browser Storage", "浏览器存储"),
("Browse…", "浏览…"),
("Brush Dynamics", "画笔动态"),
("Caching frames…", "正在缓存帧…"),
("Caching frames", "正在缓存帧"),
("Cancel", "取消"),
("Import After Effects Project", "导入 After Effects 项目"),
("Some parts of this project cannot be imported exactly (left out or changed):", "此项目的某些部分无法准确导入(将被省略或更改):"),
@@ -2481,7 +2481,7 @@ const SWEDISH: &[(&str, &str)] = &[
("Browser Storage", "Webbläsarlagring"),
("Browse…", "Bläddra…"),
("Brush Dynamics", "Penseldynamik"),
("Caching frames…", "Cachelagrar bildrutor…"),
("Caching frames", "Cachelagrar bildrutor"),
("Cancel", "Avbryt"),
("Import After Effects Project", "Importera After Effects-projekt"),
(
@@ -3745,7 +3745,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Browser Storage", "瀏覽器儲存"),
("Browse…", "瀏覽…"),
("Brush Dynamics", "筆刷動態"),
("Caching frames…", "正在快取幀…"),
("Caching frames", "正在快取幀"),
("Cancel", "取消"),
("Import After Effects Project", "匯入 After Effects 專案"),
("Some parts of this project cannot be imported exactly (left out or changed):", "此專案的部分內容無法準確匯入(將被省略或變更):"),
@@ -4803,7 +4803,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
("Browser Storage", "Сховище браузера"),
("Browse…", "Огляд…"),
("Brush Dynamics", "Динаміка пензля"),
("Caching frames…", "Кешування кадрів…"),
("Caching frames", "Кешування кадрів"),
("Cancel", "Скасувати"),
("Import After Effects Project", "Імпортувати проєкт After Effects"),
(
@@ -5378,7 +5378,7 @@ const PORTUGUESE: &[(&str, &str)] = &[
("Browser Storage", "Armazenamento do navegador"),
("Browse…", "Procurar…"),
("Brush Dynamics", "Dinâmica do pincel"),
("Caching frames…", "Armazenando quadros em cache…"),
("Caching frames", "Armazenando quadros em cache"),
("Cancel", "Cancelar"),
("Import After Effects Project", "Importar projeto do After Effects"),
(
@@ -6144,7 +6144,7 @@ const POLISH: &[(&str, &str)] = &[
("Browser Storage", "Pamięć przeglądarki"),
("Browse…", "Przeglądaj…"),
("Brush Dynamics", "Dynamika pędzla"),
("Caching frames…", "Buforowanie klatek…"),
("Caching frames", "Buforowanie klatek"),
("Cancel", "Anuluj"),
("Import After Effects Project", "Importuj projekt After Effects"),
(
@@ -6894,7 +6894,7 @@ const CZECH: &[(&str, &str)] = &[
("Browser Storage", "Úložiště prohlížeče"),
("Browse…", "Procházet…"),
("Brush Dynamics", "Dynamika štětce"),
("Caching frames…", "Ukládání snímků do mezipaměti…"),
("Caching frames", "Ukládání snímků do mezipaměti"),
("Cancel", "Zrušit"),
("Import After Effects Project", "Importovat projekt After Effects"),
(
@@ -7463,7 +7463,7 @@ const SPANISH: &[(&str, &str)] = &[
("Browser Storage", "Almacenamiento del navegador"),
("Browse…", "Examinar…"),
("Brush Dynamics", "Dinámica del pincel"),
("Caching frames…", "Almacenando fotogramas en caché…"),
("Caching frames", "Almacenando fotogramas en caché"),
("Cancel", "Cancelar"),
("Import After Effects Project", "Importar proyecto de After Effects"),
(
@@ -8023,7 +8023,7 @@ const GEORGIAN: &[(&str, &str)] = &[
("Browser Storage", "ბრაუზერის მეხსიერება"),
("Browse…", "არჩევა…"),
("Brush Dynamics", "ფუნჯის დინამიკა"),
("Caching frames…", "კადრების ქეშირება…"),
("Caching frames", "კადრების ქეშირება"),
("Cancel", "გაუქმება"),
("Import After Effects Project", "After Effects-ის პროექტის იმპორტი"),
(
@@ -8991,7 +8991,7 @@ const FRENCH: &[(&str, &str)] = &[
("Browser Storage", "Stockage du navigateur"),
("Browse…", "Parcourir…"),
("Brush Dynamics", "Dynamique du pinceau"),
("Caching frames…", "Mise en cache des images…"),
("Caching frames", "Mise en cache des images"),
("Cancel", "Annuler"),
("Import After Effects Project", "Importer un projet After Effects"),
(
+1 -1
View File
@@ -240,7 +240,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Some(pl) => pl,
None => effectcraft_engine::preview::plan(&o, &c, app.session.time()),
};
let cached_set = app.frames.cached_frames(&app.shown_series(cid));
let cached_set = app.frames.cached_frames(&app.preview_series(cid, &o));
let total = pl.frames().count();
let cached = pl.frames().filter(|f| cached_set.binary_search(f).is_ok()).count();
let bar = Rect::from_min_size(pos2(rect.min.x + 12.0, yy), vec2(rect.width() - 24.0, 6.0));
+17 -3
View File
@@ -1896,10 +1896,24 @@ fn phase_tail(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &m
crate::panels::text_range_handles::viewer_hook(app, ui, &painter, &map, ectx, &|c, l| l2c(c, l).0);
// Status chips (render time / caching).
// The caching chip stays up a moment after the last wait, so gaps between uncached frames
// don't make it flicker.
// ponytail: fixed 0.6 s hold; tie it to the prefetch queue being empty if gaps outlast it.
let chip_id = egui::Id::unique("viewer-caching-chip");
let now = ctx.input(|i| i.time);
if app.playback.playing && app.playback.waiting {
let r = Rect::from_min_size(area.min + vec2(10.0, 10.0), vec2(150.0, 22.0));
painter.rect_filled(r, 11.0, Color32::from_black_alpha(170));
painter.text(r.center(), Align2::CENTER_CENTER, crate::i18n::tr("Caching frames…"), Tokens::ui(11.5), t.cache_green);
ctx.data_mut(|d| d.insert_temp(chip_id, now + 0.6));
}
let until = ctx.data(|d| d.get_temp::<f64>(chip_id)).unwrap_or(0.0);
if app.playback.playing && now < until {
if !app.playback.waiting {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(until - now));
}
let text = painter.layout_no_wrap(crate::i18n::tr("Caching frames").to_string(), Tokens::ui(10.5), t.text_dim);
let r = Rect::from_min_size(area.min + vec2(8.0, 8.0), vec2(text.size().x + 30.0, 18.0));
painter.rect_filled(r, 9.0, Color32::from_black_alpha(140));
ui.put(Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(10.0, 10.0)), egui::Spinner::new().size(10.0).color(t.cache_green));
painter.galley(pos2(r.min.x + 22.0, r.center().y - text.size().y / 2.0), text, t.text_dim);
}
if app.video_preview_off().is_some() {
let r = Rect::from_min_size(area.min + vec2(10.0, 10.0), vec2(330.0, 22.0));