Merge pull request #3533 from arnav-hiwarkar/fix/3493-lut-cache

Apply Color LUT and Lumetri follow a LUT file that changes on disk (#3493)
This commit is contained in:
ash authored and GitHub committed 2026-11-12 12:00:00 +05:30
commit 103760584a
14 files changed
+653 -17

No files matched your search

+1 -1
View File
@@ -129,7 +129,7 @@ pub use sim2::{
CausticsSetup, FoamDisc, FoamEnv, FoamPlan, Piece, PiecePlan, PieceTex, PixelPlan, WavePlan, Waves, caustics_setup, foam_full_plan, piece_plan, pixel_plan,
wave_world_plan,
};
pub use utility::{Lut, load_lut};
pub use utility::{Lut, check_lut_files, load_lut, lut_file, lut_generation, lut_params};
pub use vr::{equi_dir as vr_equi_dir, rotation as vr_rotation};
/// Effect categories in Effects & Presets order.
+260 -15
View File
@@ -2,7 +2,8 @@
//! lookup tables (.cube).
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use effectcraft_color::luminance;
use effectcraft_keyframe::Value;
@@ -330,28 +331,165 @@ impl Lut {
}
}
type LutCache = Mutex<HashMap<String, Option<Arc<Lut>>>>;
/// A LUT file's size and modification time when it was read (`None`: inline text, or a file
/// that could not be described). A file whose stamp moved is read again (#3493).
type FileStamp = Option<(u64, Option<std::time::SystemTime>)>;
/// Load (and cache) a LUT from inline `.cube` text or a file path.
/// At most this many parsed LUTs (files and inline texts) are kept, and at most
/// [`MAX_CACHED_BYTES`] of them; the least recently used go first.
const MAX_CACHED_LUTS: usize = 64;
const MAX_CACHED_BYTES: usize = 512 << 20;
/// At most this many LUT files are followed by [`check_lut_files`] beyond the ones whose LUT is
/// kept: the frames made with a LUT that left the cache are still dropped when its file changes.
const MAX_FOLLOWED_FILES: usize = 1024;
/// A parsed LUT (or the failure to parse one) and what it was read from.
struct CachedLut {
stamp: FileStamp,
lut: Option<Arc<Lut>>,
used: u64,
/// The parsed tables and the parameter text, counted against [`MAX_CACHED_BYTES`].
bytes: usize,
}
/// [`load_lut`]'s cache, by parameter text, and the stamp each LUT file was last read with.
#[derive(Default)]
struct LutCache {
map: HashMap<String, CachedLut>,
files: HashMap<String, FileStamp>,
clock: u64,
bytes: usize,
}
impl LutCache {
/// Forget the LUT kept for `src`.
fn remove(&mut self, src: &str) {
if let Some(e) = self.map.remove(src) {
self.bytes = self.bytes.saturating_sub(e.bytes);
}
}
/// Note that the file at `path` was read with `stamp`: a different stamp than the last read's
/// means it changed on disk, and frames made before may show the old LUT.
fn read_file(&mut self, path: &str, stamp: FileStamp) {
if self.files.insert(path.to_string(), stamp).is_some_and(|old| old != stamp) {
GENERATION.fetch_add(1, Ordering::AcqRel);
}
if self.files.len() > MAX_FOLLOWED_FILES {
let map = &self.map;
self.files.retain(|p, _| p.as_str() == path || map.keys().any(|src| lut_file(src) == Some(p.as_str())));
}
}
/// Keep at most [`MAX_CACHED_LUTS`] LUTs and [`MAX_CACHED_BYTES`], always the one for `keep`.
fn evict(&mut self, keep: &str) {
while self.map.len() > MAX_CACHED_LUTS || (self.bytes > MAX_CACHED_BYTES && self.map.len() > 1) {
let Some(oldest) = self.map.iter().filter(|(k, _)| k.as_str() != keep).min_by_key(|(_, e)| e.used).map(|(k, _)| k.clone()) else { break };
self.remove(&oldest);
}
}
}
/// How often a LUT file was found changed on disk ([`lut_generation`]).
static GENERATION: AtomicU64 = AtomicU64::new(0);
fn lut_cache() -> std::sync::MutexGuard<'static, LutCache> {
static CACHE: OnceLock<Mutex<LutCache>> = OnceLock::new();
CACHE.get_or_init(Mutex::default).lock().unwrap_or_else(PoisonError::into_inner)
}
/// The stamp of the file at `path` now: one `metadata` call.
fn file_stamp(path: &str) -> FileStamp {
std::fs::metadata(path).ok().map(|md| (md.len(), md.modified().ok()))
}
/// The parameters (paths in the effect's group) of `effect` that [`load_lut`] reads.
pub fn lut_params(effect: &str) -> &'static [&'static str] {
match effect {
"ec.utility.applylut" => &["lut"],
"ec.color.lumetri" => &["basicCorrection/inputLutFile", "creative/lookFile"],
_ => &[],
}
}
/// The file a LUT parameter names; `None` for inline `.cube` text or an empty parameter.
pub fn lut_file(src: &str) -> Option<&str> {
// `.cube` text always spans lines (a size header, then data rows); a path never does, even one
// whose file name contains `LUT_` (#3454). Trimmed, so a trailing newline is still a path.
let path = src.trim();
(!path.is_empty() && !path.contains('\n')).then_some(path)
}
/// Changes each time a LUT file [`load_lut`] read is found changed on disk (by [`load_lut`]
/// itself or [`check_lut_files`]): frames made before then may show the old LUT.
pub fn lut_generation() -> u64 {
GENERATION.load(Ordering::Acquire)
}
/// Look at every LUT file read (one `metadata` call each) and forget the LUTs of the ones that
/// changed on disk since, so the next [`load_lut`] reads them again. Returns [`lut_generation`].
pub fn check_lut_files() -> u64 {
let files: Vec<(String, FileStamp)> = lut_cache().files.iter().map(|(p, s)| (p.clone(), *s)).collect();
// (stamped without the lock: a file on a slow share must not hold up renders)
let changed: Vec<(String, FileStamp, FileStamp)> =
files.into_iter().filter_map(|(path, old)| Some(file_stamp(&path)).filter(|now| *now != old).map(|now| (path, old, now))).collect();
if !changed.is_empty() {
let mut c = lut_cache();
for (path, old, now) in changed {
// (a render that read the file meanwhile has noted its new stamp already)
if c.files.get(&path) == Some(&old) {
let stale: Vec<String> = c.map.keys().filter(|src| lut_file(src) == Some(path.as_str())).cloned().collect();
for src in stale {
c.remove(&src);
}
c.read_file(&path, now);
}
}
}
lut_generation()
}
/// Load (and cache) a LUT from inline `.cube` text or a file path. A file is stamped (one
/// `metadata` call) on every load and read again once its size or modification time moved, so a
/// file edited, created, deleted or repaired on disk is followed; inline text is its own content.
/// An edit that keeps both the size and the modification time can't be told apart.
pub fn load_lut(src: &str) -> Option<Arc<Lut>> {
if src.trim().is_empty() {
return None;
}
static CACHE: OnceLock<LutCache> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Ok(m) = cache.lock()
&& let Some(v) = m.get(src)
let file = lut_file(src);
let stamp = file.and_then(file_stamp);
{
return v.clone();
let mut c = lut_cache();
c.clock += 1;
let now = c.clock;
if let Some(e) = c.map.get_mut(src)
&& e.stamp == stamp
{
e.used = now;
return e.lut.clone();
}
}
// `.cube` text always spans lines (a size header, then data rows); a path never does, even one
// whose file name contains `LUT_` (#3454). Trimmed, as the path is, so a trailing newline is a path.
let inline = src.trim().contains('\n');
let parsed = if inline { parse_cube(src) } else { std::fs::read_to_string(src.trim()).ok().and_then(|t| parse_cube(&t)) }.map(Arc::new);
if let Ok(mut m) = cache.lock() {
m.insert(src.to_string(), parsed.clone());
// (read and parsed without the lock; a file written after it was stamped has a newer stamp,
// and is read again next time)
let lut = match file {
Some(path) => std::fs::read_to_string(path).ok().and_then(|t| parse_cube(&t)),
None => parse_cube(src),
}
parsed
.map(Arc::new);
let tables = lut.as_ref().map_or(0, |l| l.d1.len().saturating_add(l.d3.len()).saturating_mul(std::mem::size_of::<[f32; 3]>()));
let bytes = tables.saturating_add(src.len());
let mut c = lut_cache();
if let Some(path) = file {
c.read_file(path, stamp);
}
c.remove(src);
let used = c.clock;
c.map.insert(src.to_string(), CachedLut { stamp, lut: lut.clone(), used, bytes });
c.bytes = c.bytes.saturating_add(bytes);
c.evict(src);
lut
}
fn apply_lut(ctx: &EffectCtx, mut b: Buf) -> Buf {
@@ -500,6 +638,113 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
/// #3493: a LUT file was read once per process; an edited or newly created file never loaded.
#[test]
fn a_lut_file_is_read_again_once_it_changes() {
const BLACK: &str = "LUT_1D_SIZE 2\n0 0 0\n0 0 0\n";
const WHITE: &str = "LUT_1D_SIZE 2\n1 1 1\n1 1 1\n";
let dir = std::env::temp_dir().join(format!("ec-fx-3493-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let write = |name: &str, text: &str, secs: u64| {
let path = dir.join(name);
std::fs::write(&path, text).unwrap();
let f = std::fs::File::options().write(true).open(&path).unwrap();
f.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs)).unwrap();
path.to_string_lossy().into_owned()
};
let at = |src: &str| load_lut(src).map(|l| l.apply([0.5; 3]));
let edited = write("edited.cube", BLACK, 1_000);
assert_eq!(at(&edited), Some([0.0; 3]));
write("edited.cube", WHITE, 2_000);
assert_eq!(at(&edited), Some([1.0; 3]), "an edited file, same size");
let created = dir.join("created.cube").to_string_lossy().into_owned();
assert_eq!(at(&created), None);
write("created.cube", WHITE, 1_000);
assert_eq!(at(&created), Some([1.0; 3]), "a file created after a failed load");
let repaired = write("repaired.cube", "not a lut", 1_000);
assert_eq!(at(&repaired), None);
write("repaired.cube", WHITE, 2_000);
assert_eq!(at(&repaired), Some([1.0; 3]), "a repaired file");
std::fs::remove_file(&repaired).unwrap();
assert_eq!(at(&repaired), None, "a deleted file");
// Inline text is its own content: the same text is the same LUT.
let inline = load_lut(BLACK).unwrap();
assert!(Arc::ptr_eq(&inline, &load_lut(BLACK).unwrap()));
std::fs::remove_dir_all(&dir).unwrap();
}
/// A file that changed is noticed without a render loading it, and the generation that tells
/// frames made before it apart moves.
#[test]
fn check_lut_files_forgets_a_changed_file() {
let dir = std::env::temp_dir().join(format!("ec-fx-3493-check-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("grade.cube");
std::fs::write(&path, identity_cube(2)).unwrap();
let src = path.to_string_lossy().into_owned();
assert!(load_lut(&src).is_some());
let before = check_lut_files();
assert!(lut_cache().map.contains_key(&src), "an unchanged file stays");
std::fs::write(&path, identity_cube(3)).unwrap();
assert!(check_lut_files() > before);
assert!(!lut_cache().map.contains_key(&src), "a changed file is read again");
assert_eq!(load_lut(&src).map(|l| l.n3), Some(3));
std::fs::remove_dir_all(&dir).unwrap();
}
/// The cache keeps at most [`MAX_CACHED_LUTS`] LUTs, the least recently used going first.
#[test]
fn the_lut_cache_is_bounded() {
// (text no other test loads: a load elsewhere would make it recently used)
let oldest = format!("TITLE \"bounded first\"\n{}", identity_cube(2));
let first = load_lut(&oldest).unwrap();
for i in 0..MAX_CACHED_LUTS + 8 {
let text = format!("TITLE \"bounded {i}\"\n{}", identity_cube(2));
assert!(load_lut(&text).is_some());
assert!(lut_cache().map.len() <= MAX_CACHED_LUTS);
}
assert!(!Arc::ptr_eq(&first, &load_lut(&oldest).unwrap()), "the oldest was let go");
let c = lut_cache();
assert_eq!(c.bytes, c.map.values().map(|e| e.bytes).sum::<usize>(), "the bytes kept add up");
assert!(c.bytes <= MAX_CACHED_BYTES);
}
/// A LUT file whose LUT left the cache is still followed: its frames are dropped when it
/// changes, though no render loads it again first.
#[test]
fn a_file_whose_lut_left_the_cache_is_still_followed() {
let dir = std::env::temp_dir().join(format!("ec-fx-3493-evicted-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("old.cube");
std::fs::write(&path, identity_cube(2)).unwrap();
let src = path.to_string_lossy().into_owned();
assert!(load_lut(&src).is_some());
for i in 0..MAX_CACHED_LUTS + 8 {
assert!(load_lut(&format!("TITLE \"evict {i}\"\n{}", identity_cube(2))).is_some());
}
assert!(!lut_cache().map.contains_key(&src), "its LUT left the cache");
std::fs::write(&path, identity_cube(3)).unwrap();
check_lut_files();
assert_eq!(lut_cache().files.get(&src), Some(&file_stamp(&src)), "the change was noticed");
std::fs::remove_dir_all(&dir).unwrap();
}
/// Past [`MAX_FOLLOWED_FILES`], the file just read is still followed (a cache of its own:
/// the shared one's followed files belong to the other tests too).
#[test]
fn the_file_just_read_stays_followed_past_the_limit() {
let mut c = LutCache::default();
for i in 0..MAX_FOLLOWED_FILES + 8 {
let path = format!("/followed/{i}.cube");
c.read_file(&path, Some((1, None)));
assert!(c.files.contains_key(&path), "{i}: the file just read");
}
assert!(c.files.len() <= MAX_FOLLOWED_FILES, "the others no longer cached are let go");
}
#[test]
fn one_d_lut_with_domain() {
let lut = parse_cube("LUT_1D_SIZE 2\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 2 2 2\n1 1 1\n0 0 0\n").unwrap();
+1
View File
@@ -1,6 +1,7 @@
pub mod doc_path;
pub mod footage_check;
pub mod footage_reload;
pub mod lut_reload;
pub mod media_browser;
pub mod media_cache;
pub mod sequence;
+90
View File
@@ -0,0 +1,90 @@
//! LUT files that change on disk (#3493).
//!
//! A render follows a LUT file by itself: [`effectcraft_effects::load_lut`] stamps the file and
//! reads it again once it changed, and a layer's cache key holds the stamp. What the desktop app
//! keeps of earlier renders, the frames of its viewer and RAM preview, is dropped when
//! [`effectcraft_effects::lut_generation`] moves. The [`Watcher`] looks at the LUT files in use
//! every two seconds, so an edit shows without anything rendering first, and wakes the interface
//! only when one changed.
use std::sync::mpsc::{RecvTimeoutError, SyncSender};
use std::time::Duration;
/// Moves each time a LUT file in use was found changed on disk: frames rendered before may show
/// the old LUT ([`effectcraft_effects::lut_generation`]).
pub fn generation() -> u64 {
effectcraft_effects::lut_generation()
}
/// Stamp the LUT files in use now ([`effectcraft_effects::check_lut_files`]): the ones that
/// changed are read again by the next render. Returns [`generation`].
pub fn check() -> u64 {
effectcraft_effects::check_lut_files()
}
/// How often the LUT files in use are stamped.
pub const INTERVAL: Duration = Duration::from_secs(2);
/// A worker thread that stamps the LUT files in use ([`effectcraft_effects::check_lut_files`])
/// and runs `wake` when one changed. Dropping the watcher ends the thread.
pub struct Watcher {
_stop: SyncSender<()>,
}
impl Watcher {
/// Start the worker. `None` where there are no threads (the web app, whose LUTs don't come
/// from files) or one couldn't be started.
pub fn start(wake: impl Fn() + Send + 'static) -> Option<Watcher> {
Self::start_with(wake, INTERVAL)
}
/// [`Watcher::start`] stamping every `every` (tests).
pub fn start_with(wake: impl Fn() + Send + 'static, every: Duration) -> Option<Watcher> {
if cfg!(target_arch = "wasm32") {
return None;
}
let (tx, rx) = std::sync::mpsc::sync_channel::<()>(1);
std::thread::Builder::new()
.name("lut-watch".into())
.spawn(move || {
let mut seen = effectcraft_effects::lut_generation();
// (ends once the watcher, the only sender, is dropped)
while let Err(RecvTimeoutError::Timeout) = rx.recv_timeout(every) {
let now = check();
if now != seen {
seen = now;
wake();
}
}
})
.ok()?;
Some(Watcher { _stop: tx })
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
/// A LUT file edited on disk wakes the interface without a render loading it.
#[test]
fn an_edited_lut_file_wakes_the_interface() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("watched.cube");
std::fs::write(&path, "LUT_1D_SIZE 2\n0 0 0\n1 1 1\n").unwrap();
let src = path.to_string_lossy().into_owned();
assert!(effectcraft_effects::load_lut(&src).is_some());
let before = effectcraft_effects::lut_generation();
let (tx, rx) = std::sync::mpsc::channel();
let _watcher = Watcher::start_with(
move || {
let _ = tx.send(());
},
Duration::from_millis(10),
)
.unwrap();
std::fs::write(&path, "LUT_1D_SIZE 3\n0 0 0\n0.5 0.5 0.5\n1 1 1\n").unwrap();
rx.recv_timeout(Duration::from_secs(10)).unwrap();
assert!(effectcraft_effects::lut_generation() > before);
}
}
+1
View File
@@ -61,6 +61,7 @@ pub use effectcraft_track as track;
pub use footage::doc_path;
pub use footage::footage_check;
pub use footage::footage_reload;
pub use footage::lut_reload;
pub use footage::media_browser;
pub use footage::media_cache;
pub use footage::sequence;
+1
View File
@@ -30,6 +30,7 @@ mod keylight;
mod legacy_analysis_keys;
mod limits;
mod lottie;
mod lut_files;
mod m137;
mod m145;
mod markers;
+188
View File
@@ -0,0 +1,188 @@
//! A LUT file that changes on disk renders again (#3493): Apply Color LUT and Lumetri's Input
//! LUT name a `.cube` file; an edit to it, or a file created, deleted or repaired, shows on the
//! next render of the same frame, from warm layer caches in memory and on disk, through a precomp
//! and a layer reading that precomp, and a frame kept on disk made with the old file is refused.
use std::path::Path;
use std::time::{Duration, UNIX_EPOCH};
use serde_json::{Value, json};
use crate::Session;
use crate::render::RenderOpts;
use effectcraft_project::ItemId;
use effectcraft_render::disk_cache::{DiskCache, comp_content_key, frame_key};
use effectcraft_render::reads::ReadLog;
use effectcraft_time::Tick;
/// Every input to black, or to white (1D, two entries: the same size, so only the modification
/// time tells the two apart).
const BLACK: &str = "LUT_1D_SIZE 2\n0 0 0\n0 0 0\n";
const WHITE: &str = "LUT_1D_SIZE 2\n1 1 1\n1 1 1\n";
const GREY: f32 = 128.0 / 255.0;
/// Write `text` to `path` with its modification time `secs` after the epoch.
fn write(path: &Path, text: &str, secs: u64) {
std::fs::write(path, text).unwrap();
let f = std::fs::File::options().write(true).open(path).unwrap();
f.set_modified(UNIX_EPOCH + Duration::from_secs(secs)).unwrap();
}
/// A session, its layer buffers written to a disk cache in `disk` when given.
fn session(disk: Option<&Path>) -> Session {
let mut s = Session::default();
if let Some(dir) = disk {
s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": dir.to_string_lossy(), "disk.diskCacheEnabled": true}})).unwrap();
s.disk_cache.as_ref().unwrap().set_min_layer_ms(0);
}
s
}
/// Comp "Grade": a grey solid graded by `effect` (`params` set on it). Comp "Shot" shows it as a
/// precomp layer, under a solid that copies the precomp's channels (Set Channels): that layer is
/// cached with what the precomp shows. Returns (Grade, Shot).
fn shot(s: &mut Session, effect: &str, params: &[(&str, Value)]) -> (ItemId, ItemId) {
s.execute_checked("comp.new", json!({"name": "Shot", "width": 8, "height": 8, "frameRate": 24, "duration": 1})).unwrap();
let shot = s.active_comp_id().unwrap();
let plate = s.execute_checked("layer.newSolid", json!({"name": "Plate", "color": "#808080", "width": 8, "height": 8})).unwrap()["layer"].clone();
s.execute_checked("effect.apply", json!({"layer": plate, "effect": effect})).unwrap();
for (path, value) in params {
s.execute_checked("prop.set", json!({"layer": plate, "path": format!("effects/#1/{path}"), "value": value})).unwrap();
}
let pre = s.execute_checked("layer.precompose", json!({"layers": [plate], "name": "Grade"})).unwrap();
let grade = ItemId(pre["comp"].as_u64().unwrap());
let copy = s.execute_checked("layer.newSolid", json!({"name": "Copy", "color": "#00ff00", "width": 8, "height": 8})).unwrap()["layer"].clone();
s.execute_checked("effect.apply", json!({"layer": copy, "effect": "Set Channels"})).unwrap();
for i in 1..=3 {
s.execute_checked("prop.set", json!({"layer": copy, "path": format!("effects/#1/sourceLayer{i}"), "value": pre["layer"]})).unwrap();
}
(grade, shot)
}
/// The red and green of the centre of both comps' first frame.
fn colours(s: &Session, (grade, shot): (ItemId, ItemId)) -> [f32; 4] {
let at = |comp| {
let img = s.render(comp, Tick::ZERO, RenderOpts::default());
img.get(i64::from(img.width / 2), i64::from(img.height / 2))
};
let (g, c) = (at(grade), at(shot));
[g[0], g[1], c[0], c[1]]
}
#[track_caller]
fn assert_all(got: [f32; 4], want: f32) {
assert!(got.iter().all(|v| (v - want).abs() < 0.01), "{got:?}, want {want}");
}
#[test]
fn an_edited_lut_file_renders_again_from_warm_caches() {
let dir = tempfile::tempdir().unwrap();
let lut = dir.path().join("grade.cube");
write(&lut, BLACK, 1_000);
let mut s = session(None);
let comps = shot(&mut s, "Apply Color LUT", &[("lut", json!(lut.to_string_lossy()))]);
assert_all(colours(&s, comps), 0.0);
let misses = s.layer_cache.stats().misses;
assert_all(colours(&s, comps), 0.0);
assert_eq!(s.layer_cache.stats().misses, misses, "an unchanged file keeps the cached layers");
// A frame on disk made with the file names it.
let frames = DiskCache::open(dir.path().join("frames"), 1 << 30).unwrap();
let key = frame_key(comp_content_key(&s.project, comps.1), 0, 1000, 0);
let log = ReadLog::new();
let mut r = s.renderer(RenderOpts::default());
r.reads = Some(&log);
let _ = r.comp_frame(comps.1, Tick::ZERO);
let files = log.into_files();
assert!(files.get(&lut.to_string_lossy()).is_some(), "the render notes the LUT file: {files:?}");
frames.put_frame_now(key, 1, 1, &[0; 4], &files);
assert!(frames.get_frame(key, None).is_some());
let generation = effectcraft_effects::lut_generation();
write(&lut, WHITE, 2_000);
assert_all(colours(&s, comps), 1.0);
assert!(effectcraft_effects::lut_generation() > generation, "the viewer's frames made before are dropped");
assert!(frames.get_frame(key, None).is_none(), "the frame on disk made with the old file");
}
#[test]
fn an_edited_lut_file_is_not_served_from_the_disk_cache() {
let dir = tempfile::tempdir().unwrap();
let (lut, disk) = (dir.path().join("grade.cube"), dir.path().join("cache"));
write(&lut, BLACK, 1_000);
let mut s = session(Some(&disk));
let comps = shot(&mut s, "Apply Color LUT", &[("lut", json!(lut.to_string_lossy()))]);
assert_all(colours(&s, comps), 0.0);
let dc = s.disk_cache.clone().unwrap();
dc.flush();
assert!(dc.stats().entries > 0, "layer buffers written");
let project = s.project.clone();
drop((s, dc));
// A restart on the same cache, after the file changed.
write(&lut, WHITE, 2_000);
let mut s = session(Some(&disk));
s.project = project;
s.bump();
assert_all(colours(&s, comps), 1.0);
}
#[test]
fn a_lut_file_created_deleted_or_repaired_renders_again() {
let dir = tempfile::tempdir().unwrap();
let lut = dir.path().join("later.cube");
let mut s = session(None);
let comps = shot(&mut s, "Apply Color LUT", &[("lut", json!(lut.to_string_lossy()))]);
assert_all(colours(&s, comps), GREY);
write(&lut, WHITE, 1_000);
assert_all(colours(&s, comps), 1.0);
std::fs::remove_file(&lut).unwrap();
assert_all(colours(&s, comps), GREY);
write(&lut, "not a LUT", 2_000);
assert_all(colours(&s, comps), GREY);
write(&lut, BLACK, 3_000);
assert_all(colours(&s, comps), 0.0);
}
#[test]
fn lumetri_follows_its_input_lut_file() {
let dir = tempfile::tempdir().unwrap();
let lut = dir.path().join("input.cube");
write(&lut, BLACK, 1_000);
let mut s = session(None);
let comps = shot(&mut s, "Lumetri Color", &[("basicCorrection/inputLut", json!(1)), ("basicCorrection/inputLutFile", json!(lut.to_string_lossy()))]);
assert_all(colours(&s, comps), 0.0);
write(&lut, WHITE, 2_000);
assert_all(colours(&s, comps), 1.0);
}
/// A precomp instance whose Essential Property names another LUT file follows that file, also
/// for a layer that reads the instance through a layer parameter.
#[test]
fn an_overridden_lut_file_renders_again() {
let dir = tempfile::tempdir().unwrap();
let (base, overridden) = (dir.path().join("base.cube"), dir.path().join("override.cube"));
write(&base, WHITE, 1_000);
write(&overridden, BLACK, 1_000);
let mut s = session(None);
s.execute_checked("comp.new", json!({"name": "Grade", "width": 8, "height": 8, "frameRate": 24, "duration": 1})).unwrap();
let grade = s.active_comp_id().unwrap();
let plate = s.execute_checked("layer.newSolid", json!({"name": "Plate", "color": "#808080", "width": 8, "height": 8})).unwrap()["layer"].clone();
s.execute_checked("effect.apply", json!({"layer": plate, "effect": "Apply Color LUT"})).unwrap();
s.execute_checked("prop.set", json!({"layer": plate, "path": "effects/#1/lut", "value": base.to_string_lossy()})).unwrap();
let control = s.execute_checked("essential.addProperty", json!({"layer": plate, "path": "effects/#1/lut", "name": "LUT"})).unwrap()["controls"][0].clone();
s.execute_checked("comp.new", json!({"name": "Shot", "width": 8, "height": 8, "frameRate": 24, "duration": 1})).unwrap();
let shot = s.active_comp_id().unwrap();
let instance = s.execute_checked("layer.addItem", json!({"item": grade.0})).unwrap()["layer"].clone();
s.execute_checked("essential.set", json!({"layer": instance, "control": control, "value": overridden.to_string_lossy()})).unwrap();
let copy = s.execute_checked("layer.newSolid", json!({"name": "Copy", "color": "#00ff00", "width": 8, "height": 8})).unwrap()["layer"].clone();
s.execute_checked("effect.apply", json!({"layer": copy, "effect": "Set Channels"})).unwrap();
for i in 1..=3 {
s.execute_checked("prop.set", json!({"layer": copy, "path": format!("effects/#1/sourceLayer{i}"), "value": instance})).unwrap();
}
let centre = |s: &Session| {
let img = s.render(shot, Tick::ZERO, RenderOpts::default());
let p = img.get(i64::from(img.width / 2), i64::from(img.height / 2));
[p[0], p[1], p[0], p[1]]
};
assert_all(centre(&s), 0.0);
write(&overridden, WHITE, 2_000);
assert_all(centre(&s), 1.0);
}
+13
View File
@@ -33,6 +33,8 @@ impl Dependencies<'_> {
{
for g in fx.groups().take(limit.unwrap_or(usize::MAX)).filter(|g| g.enabled) {
self.group(ctx, layer, g, 0)?;
// A LUT file edited on disk changes the pixels without an edit (#3493).
crate::reads::lut_files(ctx, layer, g).hash(self.h);
}
}
self.other_times = previous;
@@ -169,6 +171,17 @@ impl Dependencies<'_> {
ItemKind::Comp(comp) => {
let time = ctx.source_time(layer);
time.0.hash(self.h);
// The nested layers are walked without this instance's Essential
// Properties overrides, which the render applies: a LUT file one of
// them names is read too (#3493).
for o in crate::fx_host::essential_overrides(ctx, layer) {
if let effectcraft_keyframe::Value::Str(src) = &o.value
&& let Some(path) = effectcraft_effects::lut_file(src)
{
crate::reads::ReadFile::new(path, crate::reads::FileStamp::native(path).unwrap_or(crate::reads::FileStamp::Missing))
.hash(self.h);
}
}
let nested = EvalCtx::new(ctx.project, item, comp, time);
for child in &comp.layers {
self.layer(&nested, child)?;
+9
View File
@@ -757,6 +757,15 @@ impl<'a> Renderer<'a> {
return;
}
let Some(fx) = layer.effects() else { return };
// The LUT files the stack reads go to this render's files: a frame on disk made with one
// that changed since is not used (#3493).
if let Some(log) = self.reads {
for g in fx.groups().take(limit).filter(|g| g.enabled) {
for f in super::reads::lut_files(ctx, layer, g) {
log.note(f);
}
}
}
let lt = layer.layer_time(ctx.time);
let layer_size = ctx.effect_bounds(layer).0;
// #659 / #1205: an authored layer's buffer spans only its painted content. Effects that
+17
View File
@@ -65,6 +65,23 @@ impl ReadFile {
}
}
/// The LUT files effect `g` of `layer` reads at the context time (Apply Color LUT, Lumetri's
/// Input LUT and Look), each with its identity now. A layer's cache key holds them and a render
/// notes them, so what was made with a file that changed since is not used (#3493).
pub(crate) fn lut_files(ctx: &crate::EvalCtx, layer: &effectcraft_project::Layer, g: &effectcraft_project::PropGroup) -> Vec<ReadFile> {
let effectcraft_project::GroupKind::Effect { effect } = &g.kind else { return Vec::new() };
effectcraft_effects::lut_params(effect)
.iter()
.filter_map(|param| match ctx.value(layer, g.prop(param)?) {
effectcraft_keyframe::Value::Str(src) => {
let path = effectcraft_effects::lut_file(&src)?;
Some(ReadFile::new(path, FileStamp::native(path).unwrap_or(FileStamp::Missing)))
}
_ => None,
})
.collect()
}
/// At most this many files are listed per render or cached buffer; more mark the list
/// incomplete (a comp reading more files than this for one frame is not checked file by file).
pub const MAX_READ_FILES: usize = 4096;
+2
View File
@@ -97,6 +97,8 @@ impl EffectcraftApp {
gpu_failure_applied: None,
footage_watcher: None,
footage_watch_key: None,
lut_watcher: None,
lut_generation: effectcraft_engine::lut_reload::generation(),
device_lost_close: None,
dialog_answers: std::sync::mpsc::channel(),
file_dialogs: FileDialogsUp::default(),
+17
View File
@@ -654,6 +654,23 @@ impl EffectcraftApp {
ctx.request_repaint_after(std::time::Duration::from_secs(10));
}
self.tick_footage_reload(ctx);
self.tick_lut_files(ctx);
}
/// A LUT file in use changed on disk (#3493): the frames kept were rendered with the old one.
/// Renders read it again by themselves; the watcher wakes the interface when no render did.
/// The frame keys carry the generation ([`Self::frame_series`]), so the viewers show the
/// frames rendered again, and a frame still rendering with the old LUT is never asked for.
fn tick_lut_files(&mut self, ctx: &egui::Context) {
if self.lut_watcher.is_none() {
let wake = ctx.clone();
self.lut_watcher = effectcraft_engine::lut_reload::Watcher::start(move || wake.request_repaint());
}
let generation = effectcraft_engine::lut_reload::generation();
if generation != self.lut_generation {
self.lut_generation = generation;
self.frames.clear();
}
}
/// One background worker tracks source availability and optional content reloads. It wakes
+49 -1
View File
@@ -128,7 +128,7 @@ impl EffectcraftApp {
frame: 0,
scale: (scale * 1000.0).round() as u32,
view: self.view_hash(comp),
opts: frames::opts_hash(&self.frame_opts(comp, scale)) ^ self.roto_model_stamp(),
opts: frames::opts_hash(&self.frame_opts(comp, scale)) ^ self.roto_model_stamp() ^ self.lut_stamp(),
}
}
@@ -142,6 +142,13 @@ impl EffectcraftApp {
h.finish() | 1
}
/// Changes when a LUT file in use changed on disk (#3493): the frame on screen and the ones
/// still rendering were made with the old LUT, so the new ones must not share their keys
/// (the viewers show a frame again only when its key changes). 0 until a LUT file changes.
fn lut_stamp(&self) -> u64 {
self.lut_generation.wrapping_mul(0x9e37_79b9_7f4a_7c15)
}
/// The frame series the viewer of `comp` shows now (its scale, view and options, for the
/// comp's current content): what the cache bars count.
pub fn shown_series(&self, comp: ItemId) -> FrameKey {
@@ -238,3 +245,44 @@ impl EffectcraftApp {
request(&self.frames, &self.render_source(), key, comp, t, opts);
}
}
#[cfg(test)]
mod tests {
/// #3493: the viewer shows the frame again once the LUT file it was graded with changes,
/// though its key was otherwise the same (same project revision, frame and options).
#[test]
fn the_viewer_shows_a_lut_file_changed_on_disk() {
let dir = tempfile::tempdir().unwrap();
let lut = dir.path().join("viewer.cube");
let write = |text: &str, secs: u64| {
std::fs::write(&lut, text).unwrap();
let f = std::fs::File::options().write(true).open(&lut).unwrap();
f.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs)).unwrap();
};
write("LUT_1D_SIZE 2\n0 0 0\n0 0 0\n", 1_000);
let mut session = effectcraft_engine::Session::default();
session.execute("comp.new", serde_json::json!({"width": 64, "height": 36, "duration": 2})).unwrap();
session.execute("layer.newSolid", serde_json::json!({"color": "#808080"})).unwrap();
session.execute("effect.apply", serde_json::json!({"layer": 1, "effect": "Apply Color LUT"})).unwrap();
session.execute("prop.set", serde_json::json!({"layer": 1, "path": "effects/#1/lut", "value": lut.to_string_lossy()})).unwrap();
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| crate::EffectcraftApp::new(session));
let centre = |h: &egui_kittest::Harness<'_, crate::EffectcraftApp>| {
h.state().viewer_image.as_ref().map(|img| img.pixels[img.pixels.len() / 2 + img.size[0] / 2].r())
};
let settle = |h: &mut egui_kittest::Harness<'_, crate::EffectcraftApp>, want: u8| {
for _ in 0..400 {
h.step();
if centre(h) == Some(want) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
panic!("the viewer shows {:?}, not {want}", centre(h));
};
settle(&mut harness, 0);
write("LUT_1D_SIZE 2\n1 1 1\n1 1 1\n", 2_000);
// (what the app's watcher does every two seconds)
effectcraft_engine::lut_reload::check();
settle(&mut harness, 255);
}
}
+4
View File
@@ -373,6 +373,10 @@ pub struct EffectcraftApp {
footage_watcher: Option<effectcraft_engine::footage_reload::Watcher>,
/// The project revision and preference the watched set was last computed for.
footage_watch_key: Option<(u64, String, bool)>,
/// The worker that stamps the LUT files in use ([`Self::tick_lut_files`], #3493).
lut_watcher: Option<effectcraft_engine::lut_reload::Watcher>,
/// The LUT generation the frames kept were rendered at ([`effectcraft_engine::lut_reload::generation`]).
lut_generation: u64,
/// The graphics device was lost and the window is closing (see `close_after_device_loss`);
/// `true` keeps crash recovery for the next launch.
device_lost_close: Option<bool>,