G5: still footage layers are kept in the layer cache across times (#1812)

Footage layers had no layer cache key, so a still made a new layer buffer every frame on every
preview worker, and the GPU compositor uploaded it again each time (its upload cache is keyed
by buffer). Stills (without a proxy) are now keyed like text and shape layers, without the
source time; movies and sequences are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ded
2026-10-20 03:43:33 +03:00
co-authored by Claude Opus 5.5
parent 57326dc6a7
commit fd1250d224
2 changed files with 39 additions and 4 deletions
+14 -4
View File
@@ -22,7 +22,7 @@ use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex};
use effectcraft_effects::Buf;
use effectcraft_project::{ItemKind, Layer, LayerSource, Node, PropGroup};
use effectcraft_project::{FootageKind, ItemKind, Layer, LayerSource, Node, PropGroup};
use crate::eval::EvalCtx;
@@ -481,7 +481,7 @@ fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) {
}
/// Cache key for the processed (source → masks → effects) buffer of `layer` at the context
/// time, or `None` when the layer must not be cached (footage, precomps, cameras, adjustment
/// time, or `None` when the layer must not be cached (movie footage, precomps, cameras, adjustment
/// layers…).
pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option<u64> {
// Time effects see property values at other times. Keyframes are part of the hashed
@@ -514,6 +514,13 @@ fn reads_other_times(layer: &Layer) -> bool {
})
}
/// A still image (no proxy): its layer is cached like a text or shape layer, so the GPU
/// compositor uploads it once, not every frame (#1812: four 21120 px plates re-uploaded by every
/// preview worker ran the GPU out of memory). Movies and sequences stay in the media pool only.
fn still_footage(ctx: &EvalCtx, item: effectcraft_project::ItemId) -> bool {
ctx.project.item(item).is_some_and(|it| it.proxy.is_none() && matches!(&it.kind, ItemKind::Footage(f) if f.kind == FootageKind::Still))
}
fn has_expression(g: &PropGroup) -> bool {
g.children.iter().any(|c| match c {
Node::Prop(p) => p.has_expression(),
@@ -552,14 +559,17 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo
hash_debug(&mut h, s);
}
LayerSource::Text | LayerSource::Shape => {}
LayerSource::Footage { item } if footage => {
LayerSource::Footage { item } if footage || still_footage(ctx, *item) => {
let it = ctx.project.item(*item)?;
let ItemKind::Footage(f) = &it.kind else { return None };
hash_debug(&mut h, f);
// A proxy (and its Use Proxy switch) changes the pixels.
hash_debug(&mut h, &it.proxy);
item.hash(&mut h);
ctx.source_time(layer).0.hash(&mut h);
// A still is the same at every time (unless a proxy, which may be a movie, stands in).
if f.kind != FootageKind::Still || it.proxy.is_some() {
ctx.source_time(layer).0.hash(&mut h);
}
// Frame blending mixes neighbouring source frames.
ctx.comp.enable_frame_blending.hash(&mut h);
}
+25
View File
@@ -81,3 +81,28 @@ fn reduced_renders_take_footage_resampled_by_the_source() {
Renderer::new(&p, &sized, RenderOpts { scale: 0.5, ..Default::default() }).comp_frame(cid, Tick::ZERO);
assert!(sized.sizes.unwrap().into_inner().unwrap().is_empty());
}
/// #1812: a still's layer is kept in the layer cache across times (its texture then uploads to
/// the GPU once, not every frame); a movie's is fetched again at every time.
#[test]
fn still_layers_are_cached_across_times_and_movies_are_not() {
for (kind, fetches) in [(FootageKind::Still, 1), (FootageKind::Video, 3)] {
let (mut p, cid) = project(BitDepth::Bpc8, false);
let Some(LayerSource::Footage { item }) = p.comp(cid).map(|c| c.layers[0].source.clone()) else { panic!("footage layer") };
if let Some(ItemKind::Footage(f)) = p.item_mut(item).map(|it| &mut it.kind) {
f.kind = kind;
}
let cache = crate::LayerCache::default();
let src = Clip { sizes: Some(Mutex::default()) };
let opts = RenderOpts { scale: 0.5, ..Default::default() };
for t in [0.0, 0.25, 0.5] {
let t = Tick::from_seconds_f64(t);
let mut r = Renderer::new(&p, &src, opts);
r.cache = Some(&cache);
let frame = r.comp_frame(cid, t);
assert_eq!(frame, Renderer::new(&p, &src, opts).comp_frame(cid, t), "{kind:?} at {t:?}");
}
// (each uncached render above fetches once more)
assert_eq!(src.sizes.unwrap().into_inner().unwrap().len(), fetches + 3, "{kind:?}");
}
}