diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e08c7c42..354fbd268 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,14 @@ concurrency: cancel-in-progress: false jobs: + # Regenerates contributors/contributors.json for the release commit; every build bundles it. + credits: + permissions: + contents: read + pull-requests: read + uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main release: + needs: credits permissions: contents: write packages: write @@ -25,6 +32,7 @@ jobs: app-name: effectcraft app-title: EffectCraft version: ${{ inputs.version }} + contributors-artifact: ${{ needs.credits.outputs.artifact-name }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} diff --git a/Cargo.lock b/Cargo.lock index 5b426c8f8..f897737db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1468,6 +1468,7 @@ dependencies = [ "effectcraft-script", "image", "serde_json", + "tempfile", "thiserror 2.0.21", ] @@ -1741,6 +1742,7 @@ version = "0.21.0" dependencies = [ "craft-docpath", "effectcraft-color", + "effectcraft-effects", "effectcraft-model", "effectcraft-pdf", "effectcraft-project", @@ -1877,6 +1879,7 @@ dependencies = [ "lz4_flex", "rayon", "serde", + "tempfile", "web-time", ] diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 2882b16cf..99e71117f 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -329,7 +329,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { if n > 0 { app.frames.set_remote(Some(Arc::new(frames::WebFrames::start(n, !query_flag("nogpuworkers"))))); } - app.frames.set_virtual_media(files::STORE.media_epoch(), Arc::new(|path| files::STORE.file_identity(path))); + app.frames.set_virtual_id(Arc::new(|path| files::STORE.file_identity(path))); let (tx, rx) = std::sync::mpsc::channel(); api::set_sender(tx); app = app.with_control(rx); diff --git a/apps/effectcraft-web/src/diskcache.rs b/apps/effectcraft-web/src/diskcache.rs index ef9d46f8c..72e9a9e11 100644 --- a/apps/effectcraft-web/src/diskcache.rs +++ b/apps/effectcraft-web/src/diskcache.rs @@ -4,23 +4,28 @@ //! //! - **Writes** happen in the frame workers: a render request carries the frame's disk-cache key //! (`effectcraft_engine::remote::FrameMsg::Render`), the worker posts the frame, then writes -//! the sealed entry (`disk_cache::frame_entry`: LZ4, checksum) with a synchronous access -//! handle into a temporary file moved into place (`js/host.js` `cacheWrite`), and reports -//! `FrameReply::Stored`. +//! the sealed entry (`disk_cache::frame_entry`: the source files the frame read, LZ4, +//! checksum) with a synchronous access handle into a temporary file moved into place +//! (`js/host.js` `cacheWrite`), and reports `FrameReply::Stored`. A frame whose files can't +//! all be named is not stored. //! - **The index** lives here, on the page ([`DiskIndex`], shared with the desktop's cache): //! rebuilt at startup from the folder listing (oldest files first), updated by the workers' //! `Stored` replies and by reads; eviction deletes the least recently used files. //! - **Reads** are asynchronous: a frame whose key is indexed is read and decoded here instead -//! of rendering it (`crate::frames`); a damaged or missing file is a miss and is forgotten. +//! of rendering it (`crate::frames`), if the files its entry lists still have the size and +//! content hash it was made of (the file table's identities); a damaged or missing entry, or +//! one whose files changed, is a miss and is deleted. //! //! - **Layer buffers** too (`layers/<32 hex>.ecc`): a layer lookup happens in the middle of a //! synchronous render, where the browser's file system can't be read, so the frame workers //! back their layer caches with a `PrefetchStore`. They report each frame's layer-cache //! misses; before the next request the page names the recent misses that are on disk //! ([`layer_prefetch`], `disk_cache::LayerPrefetch`), and the worker reads those entries -//! into memory before rendering, so the lookups find them. Slow buffers are written by the -//! worker after its frame and indexed here like frames (one least-recently-used order for -//! both kinds). +//! into memory before rendering, so the lookups find them; an entry is used only while the +//! files it lists (the file table's size and content hash) are unchanged, and a buffer whose +//! files can't all be named is not written (#3184). Slow buffers are written by the worker +//! after its frame and indexed here like frames (one least-recently-used order for both +//! kinds). use std::cell::RefCell; @@ -187,11 +192,12 @@ pub fn note_miss() { } /// Read and decode a cached frame; `None` (forgotten and deleted) when the file is missing or -/// damaged. +/// damaged, or the source files it was made of changed since. pub async fn read(key: u128) -> Option { let name = disk_cache::entry_name(key); let bytes = JsFuture::from(cache_read(&name)).await.ok().and_then(|b| b.dyn_into::().ok()).map(|b| b.to_vec()); - let frame = bytes.as_deref().and_then(disk_cache::read_frame_entry); + let identity = |path: &str| crate::files::STORE.file_identity(path); + let frame = bytes.as_deref().and_then(|b| disk_cache::read_frame_entry(b, Some(&identity))); ST.with(|s| { let mut s = s.borrow_mut(); if frame.is_some() { diff --git a/apps/effectcraft-web/src/frames.rs b/apps/effectcraft-web/src/frames.rs index 69089e308..0159fe23b 100644 --- a/apps/effectcraft-web/src/frames.rs +++ b/apps/effectcraft-web/src/frames.rs @@ -473,10 +473,13 @@ pub async fn worker_frame(json: String) { g.settled().await; replies = SERVER.with(|c| c.borrow_mut().as_mut().map(|s| s.resume()).unwrap_or_default()); } + // The source files the frame read, kept with its entry (checked by the page before use); a + // frame whose files can't all be named is not stored (#3184). + let (files, virtual_id) = SERVER.with(|c| c.borrow_mut().as_mut().map(|s| (s.take_files(), s.virtual_id().cloned())).unwrap_or_default()); for (r, px) in replies { post_reply(&r, px.as_deref()); - if let (FrameReply::Frame { width, height, .. }, Some(px), Some(key)) = (&r, &px, &disk) { - let entry = effectcraft_engine::render::disk_cache::frame_entry(*width, *height, px); + if let (FrameReply::Frame { width, height, .. }, Some(px), Some(key), Some(files)) = (&r, &px, &disk, &files) { + let Some(entry) = effectcraft_engine::render::disk_cache::frame_entry(*width, *height, px, files, virtual_id.as_deref()) else { continue }; let name = format!("{key}.ecc"); match JsFuture::from(crate::diskcache::cache_write(&name, js_sys::Uint8Array::from(&entry[..]))).await { Ok(n) => post_reply(&FrameReply::Stored { key: key.clone(), bytes: n.as_f64().unwrap_or(entry.len() as f64) as u64, layer: false }, None), diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs index 0cc877948..a4947600b 100644 --- a/apps/effectcraft-web/src/store.rs +++ b/apps/effectcraft-web/src/store.rs @@ -20,7 +20,6 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use effectcraft_engine::config::{ConfigStore, FileOps}; @@ -187,13 +186,12 @@ pub struct Store { pub on_change: OnceLock, /// Clock in milliseconds since the Unix epoch (tests pin it). pub clock: OnceLock f64>, - /// Bumped when a virtual file is written. Preview cache lookups key on it; they do not hash. - media_epoch: Arc, } impl Default for Store { + /// An empty store with no mirror contents, change callback or clock. fn default() -> Self { - Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new(), media_epoch: Arc::new(AtomicU64::new(0)) } + Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new() } } } @@ -218,22 +216,12 @@ impl Store { pub fn put_file(&self, path: &str, data: Arc<[u8]>, persist: bool) { let now = self.now(); let key = file_key(path); - let replaced = self.lock().get(&key).is_none_or(|e| !Arc::ptr_eq(&e.data, &data)); self.lock().put(&key, data, now, persist); - if replaced { - self.media_epoch.fetch_add(1, Ordering::Relaxed); - } if persist { self.changed(); } } - /// The generation preview cache lookups key on. It changes when [`Self::put_file`] writes - /// a different blob, and it is not a content hash. - pub fn media_epoch(&self) -> Arc { - self.media_epoch.clone() - } - /// Size and content hash of the file under `path`. The hash is stored on the entry so a /// later key does not walk the bytes again. pub fn file_identity(&self, path: &str) -> Option<(u64, u64)> { @@ -541,11 +529,9 @@ mod tests { assert_ne!(a, b); assert_eq!(&data[..], b"blue"); assert_eq!(s.file_versioned("/collision.png").map(|f| f.1), Some(b), "reading doesn't change it"); - let epoch = s.media_epoch().load(Ordering::Relaxed); let id = s.file_identity("/collision.png").expect("identity"); assert_eq!(id, (4, content_hash(b"blue"))); assert_eq!(s.file_identity("/collision.png"), Some(id), "the hash is kept on the entry"); - assert!(epoch >= 2, "each new blob bumps the generation the preview keys on"); } /// A write that failed is flushed again with the key's latest state (#215). diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 07d327a21..5350addbe 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -276,9 +276,6 @@ fn main() -> eframe::Result { // written on background threads (M13.14). session.check_footage_on_open = true; session.autosave.background = true; - // The disk cache's footage stamps too: an imported or opened project with thousands of - // sequence frames on a network share took half a minute to stamp. - session.disk_salt_background = true; // Settings, shortcut presets and the crash-recovery sentinel live in the platform // config directory. Agent-driven runs (`--control`) skip crash recovery. if let Some(dir) = effectcraft_host::config_dir() { diff --git a/crates/aep/src/container/effects.rs b/crates/aep/src/container/effects.rs index e336d84e5..2fa77d017 100644 --- a/crates/aep/src/container/effects.rs +++ b/crates/aep/src/container/effects.rs @@ -12,6 +12,11 @@ //! project-level `LIST EfdG` (one `EfDf` per effect type: a `tdmn` with the match name, then a copy //! of one instance's `sspc`), see [`Project::effect_params`]. That copy also carries the values of //! the instance it was copied from, so values are only ever read from the instance's own `tdgp`. +//! +//! Some files have no `EfdG` at all (seen in 17 projects, all from inside After Effects-made +//! Motion Graphics templates). In those, one instance per effect type kept its full `parT` (which +//! one is not known) and it is the only place the definitions are written, so the other instances +//! of that type borrow them from it (#3170). use std::collections::{HashMap, HashSet}; @@ -138,7 +143,11 @@ pub enum DefinitionSource { /// The project-level `EfdG/EfDf` copy for the effect's match name (the instance's own `parT` /// is empty, 383). Project, - /// Neither has any: the effect has no readable parameter definitions. + /// The own `parT` of another instance of the same match name in the file: the instance's + /// own `parT` is empty and the file has no project-level copy of the type (a project without + /// `EfdG`, #3170). + OtherInstance, + /// None of these has any: the effect has no readable parameter definitions. Missing, } @@ -155,7 +164,8 @@ pub struct EffectParam<'f, 'a> { pub name: String, /// The popup items (`pdnm`, split at `|`; for a button its label, e.g. `Swap Colors`). pub items: Option>, - /// True when the definition is the project-level copy (see [`DefinitionSource::Project`]). + /// True when the definition is borrowed: the project-level copy ([`DefinitionSource::Project`]) + /// or another instance's ([`DefinitionSource::OtherInstance`]). pub borrowed_definition: bool, pard: &'f [u8], stored: Option>, @@ -214,6 +224,22 @@ impl<'f, 'a> Effect<'f, 'a> { self.chunk.child_list(b"parT").is_some_and(has_pard) } + /// The match names of the value records in the instance's own `tdgp` (properties only, not + /// groups), in file order, each once. + pub fn value_record_names(&self) -> Vec { + let mut seen: HashSet = HashSet::new(); + self.chunk + .child_list(b"tdgp") + .and_then(PropGroup::from_chunk) + .map(|g| g.entries()) + .unwrap_or_default() + .into_iter() + .filter(|e| matches!(e.node, PropNode::Property(_))) + .map(|e| e.match_name) + .filter(|n| seen.insert(n.clone())) + .collect() + } + /// The parameters in the order of the instance's own `parT`. Empty for an instance whose /// `parT` is empty; [`Project::effect_params`] falls back to the project's definitions. pub fn params(&self) -> Vec> { @@ -299,8 +325,25 @@ impl<'f, 'a> Project<'f, 'a> { /// [`Self::effect_definitions`] indexed by match name, for looking up many effects: build it /// once per file, not once per effect (a file can hold many definitions and many instances). + /// For a type without a project-level copy it also keeps the own `parT` of the first instance + /// in the file (items, layers and effects in file order) that has one. pub fn effect_definition_index(&self) -> EffectDefinitions<'f, 'a> { - EffectDefinitions { by_name: self.effect_definitions().into_iter().collect() } + let by_name: HashMap> = self.effect_definitions().into_iter().collect(); + let mut instances: HashMap> = HashMap::new(); + for item in self.all_items() { + let Some(comp) = item.comp() else { continue }; + for layer in comp.layers() { + for fx in layer.effects() { + if by_name.contains_key(&fx.match_name) || instances.contains_key(&fx.match_name) { + continue; + } + if let Some(part) = fx.chunk.child_list(b"parT").filter(|p| has_pard(p)) { + instances.insert(fx.match_name, part); + } + } + } + } + EffectDefinitions { by_name, instances } } /// Where the parameter definitions of `effect` come from. Builds the index each call: use @@ -316,10 +359,13 @@ impl<'f, 'a> Project<'f, 'a> { } } -/// The project-level effect definitions by match name ([`Project::effect_definition_index`]). +/// The borrowable effect definitions by match name ([`Project::effect_definition_index`]). #[derive(Debug, Clone, Default)] pub struct EffectDefinitions<'f, 'a> { + /// The project-level copies (`EfdG`). by_name: HashMap>, + /// For a type without a project-level copy: the own `parT` of the first instance that has one. + instances: HashMap>, } impl<'f, 'a> EffectDefinitions<'f, 'a> { @@ -329,20 +375,23 @@ impl<'f, 'a> EffectDefinitions<'f, 'a> { DefinitionSource::Instance } else if self.by_name.contains_key(effect.match_name()) { DefinitionSource::Project + } else if self.instances.contains_key(effect.match_name()) { + DefinitionSource::OtherInstance } else { DefinitionSource::Missing } } /// The parameters of `effect`: from its own `parT`, or, when that is empty, from the - /// project-level definition of its match name ([`EffectParam::borrowed_definition`]). Values - /// always come from the instance's own records (absent = the `pard` default); the project - /// copy's values belong to another instance and are never read. + /// project-level definition of its match name, else from another instance of it that has + /// its own ([`EffectParam::borrowed_definition`]). Values always come from the instance's own + /// records (absent = the `pard` default); the values next to borrowed definitions belong to + /// another instance and are never read. pub fn params(&self, effect: &Effect<'f, 'a>) -> Vec> { if effect.has_own_definitions() { return effect.params(); } - match self.by_name.get(effect.match_name()) { + match self.by_name.get(effect.match_name()).or_else(|| self.instances.get(effect.match_name())) { Some(part) => effect.params_from(part, true), None => Vec::new(), } diff --git a/crates/aep/src/container/footage.rs b/crates/aep/src/container/footage.rs index 3c9148155..da0369d64 100644 --- a/crates/aep/src/container/footage.rs +++ b/crates/aep/src/container/footage.rs @@ -2,6 +2,7 @@ //! of a layer's source. use crate::container::bytes::{be_f32, be_f64, be_u32, cstr, le_u32, text}; +use crate::container::chunk::Chunk; use crate::container::header::Rational; use crate::container::typed::{Item, ItemKind, Layer, Project}; @@ -159,19 +160,25 @@ fn json_string_at(json: &[u8], mut i: usize) -> Option { } } -/// The numbering of an image sequence item (353, 603). +/// The numbering of an image sequence item (353, 603), or its file list when it was imported with +/// Force alphabetical order. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SequenceInfo { /// The file name before the frame number (`seq_`, `shot_`): the first `Utf8` after the alias. + /// An empty prefix is not stored (`0001.png`): the one `Utf8` is then the suffix. pub prefix: String, /// The file name after the frame number (`.png`, `.exr`): the second `Utf8` after the alias. pub suffix: String, /// The first and last frame number (`sspc` `u32` at 172 and 176: 1 and 3 in 353, 101 and 103 - /// in 603). + /// in 603; both `0xFFFFFFFF` for a sequence with a file list). pub start: u32, pub end: u32, /// The digits of the frame number (`sspc` `u32` at 180: 3 for `seq_001`, 4 for `shot_0101`). pub digits: u32, + /// The file names in order for a sequence imported with Force alphabetical order (`LIST StVc` + /// after the alias: `StVS` with the count as a little-endian `u32`, then one `Utf8` per file); + /// empty for a numbered sequence. When set, it replaces the numbering. + pub files: Vec, } impl SequenceInfo { @@ -180,6 +187,62 @@ impl SequenceInfo { let width = usize::try_from(self.digits.min(16)).unwrap_or(0); format!("{}{n:0width$}{}", self.prefix, self.suffix) } + + /// The number of files: the listed files, or the frame numbers from start to end (0 when the + /// end lies before the start). + pub fn frame_count(&self) -> u32 { + if self.files.is_empty() { + self.end.checked_sub(self.start).map_or(0, |d| d.saturating_add(1)) + } else { + u32::try_from(self.files.len()).unwrap_or(u32::MAX) + } + } + + /// The name of the `i`-th file (from 0): from the file list, or numbered from `start`; `None` + /// past the last file. + pub fn nth_file_name(&self, i: u32) -> Option { + if self.files.is_empty() { + let n = self.start.checked_add(i).filter(|n| *n <= self.end)?; + Some(self.file_name(n)) + } else { + self.files.get(usize::try_from(i).ok()?).cloned() + } + } +} + +/// The file names of a `LIST StVc` (Force alphabetical order): the `Utf8` children in order, at most +/// as many as the count in `StVS` (little-endian `u32`) says. Empty names count toward that number +/// and are then skipped. Without a readable count (no `StVS`, or one shorter than 4 bytes) the list +/// is unreadable (empty). A name that is not a plain file name (holding `/`, `\`, `:` or a control +/// character such as NUL, or being `.` or `..`) makes the whole list unreadable (empty): the names +/// are joined to the folder. +fn sequence_file_list(list: &Chunk<'_>) -> Vec { + // After Effects writes the count first (the one sample); a list without a readable count is + // not read rather than guessed. + let Some(count) = list.child(b"StVS").and_then(|c| c.bytes()).and_then(|b| le_u32(b, 0)).and_then(|n| usize::try_from(n).ok()) else { + return Vec::new(); + }; + let names: Vec = + list.children().iter().filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).take(count).map(text).filter(|n| !n.is_empty()).collect(); + if names.iter().any(|n| n.contains(|c: char| matches!(c, '/' | '\\' | ':') || c.is_control()) || n == "." || n == "..") { + return Vec::new(); + } + names +} + +/// The prefix and suffix of a sequence whose naming holds one `Utf8` (`part`): After Effects leaves +/// out an empty prefix (`0001.png` stores only `.png`). The stored first file name (`opti`) decides: +/// a name starting with `part` has it as the prefix, one ending with it as the suffix. Without that +/// evidence, a `part` starting with `.` is the suffix (an extension); anything else is not read. +fn single_name_part(part: &str, first_file: Option<&str>) -> Option<(String, String)> { + if let Some(first) = first_file.filter(|_| !part.is_empty()) { + match (first.starts_with(part), first.ends_with(part)) { + (true, false) => return Some((part.to_string(), String::new())), + (false, true) => return Some((String::new(), part.to_string())), + _ => {} + } + } + part.starts_with('.').then(|| (String::new(), part.to_string())) } impl<'f, 'a> Item<'f, 'a> { @@ -194,10 +257,24 @@ impl<'f, 'a> Item<'f, 'a> { let sspc = pin.child(b"sspc")?.bytes()?; let kids = pin.children(); let after = kids.iter().position(|c| c.is_list(b"Als2"))?; - let mut names = kids.iter().skip(after + 1).take_while(|c| !c.is(b"opti")).filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).map(text); - let prefix = names.next()?; - let suffix = names.next()?; - Some(SequenceInfo { prefix, suffix, start: be_u32(sspc, 172)?, end: be_u32(sspc, 176)?, digits: be_u32(sspc, 180)? }) + let naming = || kids.iter().skip(after.saturating_add(1)).take_while(|c| !c.is(b"opti")); + let (start, end, digits) = (be_u32(sspc, 172)?, be_u32(sspc, 176)?, be_u32(sspc, 180)?); + // Force alphabetical order: the file list replaces the numbering. + if let Some(list) = naming().find(|c| c.is_list(b"StVc")) { + let files = sequence_file_list(list); + if files.is_empty() { + return None; + } + return Some(SequenceInfo { prefix: String::new(), suffix: String::new(), start, end, digits, files }); + } + let names: Vec = naming().filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).map(text).take(2).collect(); + let (prefix, suffix) = match names.as_slice() { + [prefix, suffix] => (prefix.clone(), suffix.clone()), + // An empty prefix is not stored (`0001.png` has only `.png`). + [one] => single_name_part(one, f.name.as_deref())?, + _ => return None, + }; + Some(SequenceInfo { prefix, suffix, start, end, digits, files: Vec::new() }) } /// The rate a clip is conformed to (Interpret Footage ▸ Conform to frame rate; `sspc` `u32` at diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs index fa66ee411..da86da93e 100644 --- a/crates/aep/src/import/build.rs +++ b/crates/aep/src/import/build.rs @@ -206,6 +206,8 @@ fn new_comp(h: &CompHeader, renderer: Option, map: &TimeMap, name: comp } +/// Adds one footage item to the project under the given parent. +/// Unreadable footage is recorded as a loss, solids and null placeholders are handled separately, and file and sequence footage is linked to its files. fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_>, name: String, label: Label, parent: ItemId, null_only: &HashSet) { let Some(f) = item.footage() else { cx.col.lose(LossCategory::DamagedData, format!("footage '{name}': data unreadable, not imported")); @@ -240,31 +242,42 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_ } let mut path = f.path.clone().unwrap_or_default(); // An image sequence's alias is its folder; its files are named by the stored numbering - // (353, 603). The first file stands for the sequence, as in EffectCraft. + // (353, 603), or listed one by one when imported with Force alphabetical order. The first + // file stands for the sequence, as in EffectCraft. let mut sequence = Vec::new(); - if let Some(seq) = item.sequence_info().filter(|s| !path.is_empty() && s.end >= s.start) { - let frames = (seq.end - seq.start).saturating_add(1); + if let Some(seq) = item.sequence_info().filter(|s| !path.is_empty() && s.frame_count() > 0) { + let frames = seq.frame_count(); let sep = if path.contains('\\') { '\\' } else { '/' }; let folder = path.trim_end_matches(['\\', '/']).to_string(); - // One name is the folder, a separator and the file name; the number is as wide as the stored - // digits or as the largest number, whichever is longer (an upper bound for every frame). - let name_bytes = (folder.len() + 1 + seq.prefix.len() + seq.suffix.len() + usize::try_from(seq.digits.min(16)).unwrap_or(16).max(10)) as u64; - let bytes = name_bytes.saturating_mul(u64::from(frames)); + // One name is the folder, a separator and the file name. A numbered name is as wide as the + // stored digits or as the largest number, whichever is longer (an upper bound for every + // frame); listed names are counted as they are. + let len = |n: usize| u64::try_from(n).unwrap_or(u64::MAX); + let file_bytes = if seq.files.is_empty() { + len(seq.prefix.len().saturating_add(seq.suffix.len()).saturating_add(usize::try_from(seq.digits.min(16)).unwrap_or(16).max(10))) + .saturating_mul(u64::from(frames)) + } else { + seq.files.iter().fold(0u64, |sum, n| sum.saturating_add(len(n.len()))) + }; + let bytes = len(folder.len().saturating_add(1)).saturating_mul(u64::from(frames)).saturating_add(file_bytes); if frames > MAX_SEQUENCE_FRAMES || frames > cx.sequence_budget || bytes > cx.sequence_bytes { // Not expanded: the item keeps its first file (a still standing for the sequence). cx.col.lose( LossCategory::DamagedData, format!("footage '{name}': a sequence of {frames} frames is longer than the import expands; only its first file is linked"), ); - path = format!("{folder}{sep}{}", seq.file_name(seq.start)); + path = format!("{folder}{sep}{}", seq.nth_file_name(0).unwrap_or_default()); } else { cx.sequence_budget -= frames; cx.sequence_bytes -= bytes; - sequence = (seq.start..=seq.end).map(|n| format!("{folder}{sep}{}", seq.file_name(n))).collect(); + sequence = (0..frames).filter_map(|i| seq.nth_file_name(i)).map(|n| format!("{folder}{sep}{n}")).collect(); } if let Some(first) = sequence.first() { path = first.clone(); } + } else if f.is_sequence && !path.is_empty() { + // The alias is a folder, but the file names could not be read: the item links the folder. + cx.col.lose(LossCategory::DamagedData, format!("footage '{name}': image sequence naming not read, linked to its folder")); } let present = !path.is_empty() && (cx.options.exists)(&path); if !present { @@ -318,6 +331,11 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_ /// sequence the name with the frame range (`shot_[0101-0103].exr`). fn default_footage_name(item: &AepItem<'_, '_>, f: &crate::Footage) -> Option { if let Some(s) = item.sequence_info() { + // A listed sequence (Force alphabetical order) has no numbering: its first file names it. + // Assumption: what After Effects shows for such an item without a stored name was not sampled. + if let Some(first) = s.files.first() { + return Some(first.clone()); + } let w = usize::try_from(s.digits.min(16)).unwrap_or(0); return Some(format!("{}[{:0w$}-{:0w$}]{}", s.prefix, s.start, s.end, s.suffix)); } diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 8409a6f7e..b097c3600 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -99,6 +99,22 @@ pub(crate) fn import_effects<'f, 'a>( None => col.lose(LossCategory::DamagedData, format!("{}: '{}': the fx switch of '{shown}' is unreadable, imported switched on", cx.comp, cx.layer)), } let params = defs.params(&fx); + // Borrowed definitions (another instance's or the project copy) may not name every value + // record this instance has: such a record has nothing to import into, so it is reported. + if params.first().is_some_and(|p| p.borrowed_definition) { + let defined: std::collections::HashSet<&str> = params.iter().map(|p| p.match_name.as_str()).collect(); + for name in fx.value_record_names().iter().filter(|n| !defined.contains(n.as_str())) { + col.lose( + LossCategory::Effects, + format!( + "{}: '{}' effect '{shown}' ({}): the value record {name} has no parameter definition, not imported", + cx.comp, + cx.layer, + fx.match_name() + ), + ); + } + } if map.extra == Extra::Cryptomatte { import_cryptomatte(&fx, ¶ms, cx, &shown, &mut group, col); } else { diff --git a/crates/aep/tests/it/container/robustness.rs b/crates/aep/tests/it/container/robustness.rs index 27622d887..5c3422a10 100644 --- a/crates/aep/tests/it/container/robustness.rs +++ b/crates/aep/tests/it/container/robustness.rs @@ -237,9 +237,11 @@ fn exercise_layer(project: &Project<'_, '_>, comp_size: (u32, u32), layer: &Laye if let Some(root) = layer.properties() { exercise_group(&root, 0); } + // One index for the layer's effects: building it walks every layer of the file. + let defs = project.effect_definition_index(); for e in layer.effects() { - let _ = (e.match_name().len(), e.name(), e.declared_param_count(), e.has_own_definitions(), project.definition_source(&e)); - for p in e.params().into_iter().chain(project.effect_params(&e)) { + let _ = (e.match_name().len(), e.name(), e.declared_param_count(), e.has_own_definitions(), defs.source(&e)); + for p in e.params().into_iter().chain(defs.params(&e)) { let _ = (p.index, p.kind, p.name.len(), p.items.as_ref().map(Vec::len), p.pard().len(), p.value(), p.stored_value(), p.pard_default()); let _ = (p.is_default(), p.popup_item_count(), p.popup_selected_index(), p.popup_selected_item(), p.valid_max(), p.is_animated()); let _ = (p.valid_min(), p.slider_range()); diff --git a/crates/aep/tests/it/import/robustness.rs b/crates/aep/tests/it/import/robustness.rs index 1a42665bc..902c9cc4b 100644 --- a/crates/aep/tests/it/import/robustness.rs +++ b/crates/aep/tests/it/import/robustness.rs @@ -626,9 +626,10 @@ fn bogus_effect_structure_is_reported_not_imported_blindly() { "{r:?}" ); - // No project-level definitions: a Ramp with an empty parT cannot be read and is reported; one - // with its own parT still arrives. (In the single-case file one of the two had its own; in - // the combined file an earlier case holds the definitions, so both take the project copy.) + // No project-level definitions: a Ramp with an empty parT borrows the definitions of a Ramp + // in the file that has its own parT (#3170). In the single-case file one of the two has its + // own, so both arrive; in the case cut from the combined file an earlier case (not kept) + // held them, so neither has readable definitions and both are reported. let own = { let bytes = skip!(crate::common::try_fixture("383-fx-ramp-on-100x100-start-10-20.aep")); let file = parse(&bytes).unwrap(); @@ -646,8 +647,9 @@ fn bogus_effect_structure_is_reported_not_imported_blindly() { } })); let r = report_of(&bytes); - assert_eq!(r.imported.effects, own, "{r:?}"); - assert_eq!(r.count(effectcraft_aep::import::LossCategory::Effects), 2 - own, "{r:?}"); + let arrive = if own > 0 { 2 } else { 0 }; + assert_eq!(r.imported.effects, arrive, "{r:?}"); + assert_eq!(r.count(effectcraft_aep::import::LossCategory::Effects), 2 - arrive, "{r:?}"); assert!(try_all(&bytes)); // 368, the first Gradient Ramp of its file, has its own parT: it still arrives. let bytes = skip!(mutate("368-fx-ramp-default.aep", &mut |c| { if let Body::List { list_type, .. } = &mut c.body diff --git a/crates/aep/tests/it/import/synthetic.rs b/crates/aep/tests/it/import/synthetic.rs index 18709f9e5..a023bb162 100644 --- a/crates/aep/tests/it/import/synthetic.rs +++ b/crates/aep/tests/it/import/synthetic.rs @@ -77,6 +77,167 @@ fn a_sequence_with_long_names_is_not_expanded_past_the_byte_budget() { assert_eq!(longest, 0, "the sequence was not expanded"); } +/// The project with the sequence's naming (the chunks between `Als2` and `opti` in its `Pin `) +/// replaced by `naming`, and the `sspc` numbering (start, end, digits at 172, 176, 180) set to +/// `numbering` when given. +fn with_sequence_naming(naming: &[Chunk<'static>], numbering: Option<[u32; 3]>) -> Vec { + let mut file: AepFile<'_> = parse(SYNTHETIC).unwrap(); + let mut patched = 0; + visit(&mut file.chunks, &mut |c| { + if !c.is_list(b"Pin ") { + return; + } + let Body::List { children, .. } = &mut c.body else { return }; + let Some(als) = children.iter().position(|k| k.is_list(b"Als2")) else { return }; + let Some(opti) = children.iter().position(|k| k.is(b"opti")) else { return }; + assert!(als < opti); + let tail = children.split_off(opti); + children.truncate(als + 1); + children.extend(naming.iter().map(shorten)); + children.extend(tail); + if let Some(numbering) = numbering { + let sspc = children.iter_mut().find(|k| k.is(b"sspc")).unwrap(); + for (at, v) in [172, 176, 180].into_iter().zip(numbering) { + set_data(sspc, at, &v.to_be_bytes()); + } + } + patched += 1; + }); + assert_eq!(patched, 1, "the synthetic project has one sequence"); + write(&file).unwrap() +} + +/// `bytes` with the first file name the sequence stores in its png `opti` (byte 58, NUL-terminated) +/// set to `name`. +fn with_first_file(bytes: &[u8], name: &str) -> Vec { + let mut file: AepFile<'_> = parse(bytes).unwrap(); + let mut patched = 0; + visit(&mut file.chunks, &mut |c| { + if c.is(b"opti") && c.bytes().is_some_and(|d| d.get(..4) == Some(&b"png!"[..])) { + let mut z = name.as_bytes().to_vec(); + z.push(0); + set_data(c, 58, &z); + patched += 1; + } + }); + assert_eq!(patched, 1, "the synthetic project has one png item"); + write(&file).unwrap() +} + +/// The imported sequence item's footage. +fn the_footage(p: &Project) -> effectcraft_project::Footage { + p.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).expect("the sequence") +} + +/// The file names (without the folder) of a footage's sequence. +fn file_names(f: &effectcraft_project::Footage) -> Vec { + f.sequence.iter().map(|s| s.rsplit(['\\', '/']).next().unwrap_or_default().to_string()).collect() +} + +/// A copy of `c` that borrows for any lifetime (a chunk is covariant in its lifetime). +fn shorten<'a>(c: &Chunk<'static>) -> Chunk<'a> { + c.clone() +} + +/// A `Utf8` string chunk holding `s`. +fn utf8(s: &str) -> Chunk<'static> { + Chunk::data(*b"Utf8", s.as_bytes().to_vec()) +} + +/// A sequence whose naming has only a suffix (no filename prefix) imports as a sequence of its files. +#[test] +fn a_sequence_without_a_filename_prefix_imports_its_files() { + // `0001.png` .. `0003.png`: After Effects omits the empty prefix, so only `.png` follows the + // alias. Before, `.png` was read as the prefix, the suffix was missing and the item became a + // video linked to its folder. + let (p, r) = run(&with_sequence_naming(&[utf8(".png")], None), true); + r.unwrap(); + let f = the_footage(&p); + assert_eq!(f.kind, FootageKind::Sequence); + assert_eq!(file_names(&f), ["0001.png", "0002.png", "0003.png"]); + assert!(f.path.ends_with("0001.png"), "{}", f.path); +} + +/// A single `Utf8` naming part is read as the suffix or the prefix depending on how the stored first file name matches it. +#[test] +fn a_single_naming_part_is_placed_by_the_stored_first_file_name() { + // `0001_v2.png` ..: the one `Utf8` is the suffix, as the first file name (`opti`) ends with it. + let bytes = with_first_file(&with_sequence_naming(&[utf8("_v2.png")], None), "0001_v2.png"); + let (p, r) = run(&bytes, true); + r.unwrap(); + let f = the_footage(&p); + assert_eq!(f.kind, FootageKind::Sequence); + assert_eq!(file_names(&f), ["0001_v2.png", "0002_v2.png", "0003_v2.png"]); + // `shot_0001` ..: a name without an extension; the one `Utf8` is the prefix. + let bytes = with_first_file(&with_sequence_naming(&[utf8("shot_")], None), "shot_0001"); + let (p, r) = run(&bytes, true); + r.unwrap(); + assert_eq!(file_names(&the_footage(&p)), ["shot_0001", "shot_0002", "shot_0003"]); + // Without that evidence (the stored first file is `shot_0001.png`), a part that is no extension + // is not guessed: the item is reported. + let bytes = with_sequence_naming(&[utf8("_v2.png")], None); + reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder"); +} + +/// A file list containing a name with a separator, drive colon, dot directory or control character is refused and reported. +#[test] +fn a_file_list_with_a_name_that_is_not_a_plain_file_name_is_reported() { + // The listed names are joined to the folder: a separator, a drive colon or a dot directory + // would point outside it, and a NUL or another control character is no file name, so such a + // list is not read. + for hostile in ["..\\..\\x.png", "../x.png", "C:x.png", "..", ".", "x\0.png", "x\n.png"] { + let list = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 2u32.to_le_bytes().to_vec()), utf8("a.png"), utf8(hostile)]); + let bytes = with_sequence_naming(&[list], Some([u32::MAX, u32::MAX, 0])); + reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder"); + } +} + +/// A file list with a missing or too short count chunk is refused and reported. +#[test] +fn a_file_list_without_a_readable_count_is_reported() { + // No `StVS`, or one too short for its `u32`: the list is not read rather than taken whole. + for count in [None, Some(vec![2u8, 0])] { + let mut kids: Vec> = count.map(|c| Chunk::data(*b"StVS", c)).into_iter().collect(); + kids.extend([utf8("a.png"), utf8("b.png")]); + let bytes = with_sequence_naming(&[Chunk::list(*b"StVc", kids)], Some([u32::MAX, u32::MAX, 0])); + reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder"); + } +} + +/// A force-alphabetical sequence imports its stored file list in the stored order. +#[test] +fn a_sequence_imported_with_force_alphabetical_order_imports_its_file_list() { + // Force alphabetical order: `LIST StVc` with `StVS` (the count, little-endian) and one `Utf8` + // per file instead of the prefix and suffix; the numbering in `sspc` is not set. Before, the + // item became a video linked to its folder. + let list = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 3u32.to_le_bytes().to_vec()), utf8("b_take.png"), utf8("a_take.png"), utf8("c_take.png")]); + let (p, r) = run(&with_sequence_naming(&[list], Some([u32::MAX, u32::MAX, 0])), true); + let r = r.unwrap(); + let f = the_footage(&p); + assert_eq!(f.kind, FootageKind::Sequence); + // The stored order is kept. + assert_eq!(file_names(&f), ["b_take.png", "a_take.png", "c_take.png"]); + assert!(f.path.ends_with("b_take.png"), "{}", f.path); + assert!(losses(&r, LossCategory::DamagedData).is_empty(), "{:?}", r.report.losses); + // The count bounds the list. + let list = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 2u32.to_le_bytes().to_vec()), utf8("x.png"), utf8("y.png"), utf8("z.png")]); + let (p, r) = run(&with_sequence_naming(&[list], Some([u32::MAX, u32::MAX, 0])), true); + r.unwrap(); + assert_eq!(file_names(&the_footage(&p)), ["x.png", "y.png"]); +} + +/// A sequence with no naming or an empty file list is reported and linked to its folder. +#[test] +fn a_sequence_whose_naming_cannot_be_read_is_reported() { + // No naming at all after the alias. Before, the item silently linked its folder. + let bytes = with_sequence_naming(&[], None); + reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder"); + // An empty file list is no naming either. + let empty = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 0u32.to_le_bytes().to_vec())]); + let bytes = with_sequence_naming(&[empty], Some([u32::MAX, u32::MAX, 0])); + reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder"); +} + #[test] fn a_solid_with_a_nan_channel_is_repaired_and_reported() { let nan = f32::NAN.to_be_bytes(); @@ -392,6 +553,186 @@ fn default_state_effect_records_import_like_written_ones() { assert_eq!(clip, vec![("clipToOutputBlack".to_string(), effectcraft_keyframe::Value::Enum(1))]); } +/// Sets the `cdat` of the value record `param` in the value group `tdgp` (the first `f64`), and +/// its state to `tdsb` when given. True when the group has that record. +fn patch_record(tdgp: &mut Chunk<'_>, param: &str, tdsb: Option, value: f64) -> bool { + let kids = tdgp.children_mut(); + let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(param.as_bytes()))) else { + return false; + }; + let Some(record) = kids.get_mut(at + 1).filter(|k| k.is_list(b"tdbs")) else { return false }; + for k in record.children_mut() { + if k.is(b"tdsb") + && let Some(state) = tdsb + { + set_data(k, 0, &state.to_be_bytes()); + } else if k.is(b"cdat") { + set_data(k, 0, &value.to_be_bytes()); + } + } + true +} + +/// The project without its project-level effect definitions (`EfdG`; some files have none, seen +/// in the projects inside After Effects-made Motion Graphics templates) and with a second Glow +/// just above the first on the layer: a copy of it whose `parT` is empty, as such files write +/// every instance of a type but one. `edit_copy` is applied to every chunk of the copy; with +/// `empty_original` the original's `parT` is emptied too. +fn second_glow_without_definitions(edit_copy: &mut dyn FnMut(&mut Chunk<'_>), empty_original: bool) -> Vec { + fn empty_part(fx: &mut Chunk<'_>) { + for k in fx.children_mut() { + if let Body::List { list_type, children, .. } = &mut k.body + && list_type == b"parT" + { + children.clear(); + } + } + } + let mut file: AepFile<'_> = parse(SYNTHETIC).unwrap(); + let mut added = 0; + visit(&mut file.chunks, &mut |c| { + if let Body::List { list_type, children, .. } = &mut c.body { + if list_type == b"EfdG" { + *list_type = *b"XfdG"; + return; + } + if list_type != b"tdgp" { + return; + } + let Some(at) = children.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(b"ADBE Glo2"))) else { + return; + }; + let (Some(name), Some(mut fx)) = (children.get(at).cloned(), children.get(at + 1).filter(|k| k.is_list(b"sspc")).cloned()) else { return }; + empty_part(&mut fx); + edit_copy(&mut fx); + visit(fx.children_mut(), edit_copy); + if empty_original && let Some(original) = children.get_mut(at + 1) { + empty_part(original); + } + children.insert(at, fx); + children.insert(at, name); + added += 1; + } + }); + assert_eq!(added, 1, "one Glow on the layer"); + write(&file).unwrap() +} + +/// The imported Glows in stack order, each as its property values by property id. +fn glow_values(p: &Project) -> Vec> { + let glow = effectcraft_project::GroupKind::Effect { effect: "ec.stylize.glow".to_string() }; + let mut glows = Vec::new(); + for item in p.items.values() { + if let ItemKind::Comp(c) = &item.kind { + for layer in &c.layers { + for node in layer.effects().map(|fx| fx.children.as_slice()).unwrap_or_default() { + if let effectcraft_project::Node::Group(g) = node + && g.kind == glow + { + let mut values = Vec::new(); + g.walk("", &mut |_, prop| values.push((prop.match_id.clone(), prop.value.clone()))); + glows.push(values); + } + } + } + } + } + glows +} + +/// The value of property `id` in `values`. +fn value_of<'v>(values: Option<&'v Vec<(String, effectcraft_keyframe::Value)>>, id: &str) -> Option<&'v effectcraft_keyframe::Value> { + values?.iter().find(|(i, _)| i == id).map(|(_, v)| v) +} + +/// Every instance of an effect type but one has an empty `parT`; without a project-level copy the +/// others borrow the definitions of that one (#3170: 129 Color Controls of a template were +/// reported "without readable parameter definitions" and dropped). +#[test] +fn an_instance_without_definitions_imports_with_those_of_another_instance() { + // Both Glows import; before, the copy was reported and not imported. + let (p, r) = run(&second_glow_without_definitions(&mut |_| {}, false), true); + let r = r.unwrap(); + let found = losses(&r, LossCategory::Effects); + assert!(!found.iter().any(|d| d.contains("without readable parameter definitions") || d.contains("no parameter definition")), "{found:?}"); + assert_eq!(r.report.imported.effects, 3, "Levels and both Glows: {:?}", r.report.losses); + let glows = glow_values(&p); + assert_eq!(glows.len(), 2); + assert_eq!(glows.first(), glows.get(1), "the copy has the same records, so the same values"); + + // The values come from each instance's own records: the copy's A & B Midpoint (`-0011`) set + // to 0.75 arrives as 75 % in the copy only; the Glow it borrows from keeps its own. + let mut patched = 0; + let bytes = second_glow_without_definitions( + &mut |c| { + if c.is_list(b"tdgp") && patch_record(c, "ADBE Glo2-0011", None, 0.75) { + patched += 1; + } + }, + false, + ); + assert_eq!(patched, 1, "the copy's record"); + let (p, r) = run(&bytes, true); + r.unwrap(); + let glows = glow_values(&p); + let seventy_five = effectcraft_keyframe::Value::Scalar(75.0); + assert_eq!(value_of(glows.first(), "abMidpoint"), Some(&seventy_five)); + let own = value_of(glows.get(1), "abMidpoint"); + assert!(own.is_some() && own != Some(&seventy_five), "{own:?}"); +} + +/// A value record that the borrowed definitions do not name is reported, not dropped silently. +#[test] +fn a_value_record_the_borrowed_definitions_do_not_name_is_reported() { + let bytes = second_glow_without_definitions( + &mut |c| { + if c.is(b"tdmn") && c.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(b"ADBE Glo2-0011")) { + set_data(c, 0, b"ADBE Glo2-0099\0"); + } + }, + false, + ); + let (_, r) = run(&bytes, true); + let r = r.unwrap(); + let found = losses(&r, LossCategory::Effects); + assert!(found.iter().any(|d| d.contains("ADBE Glo2-0099") && d.contains("no parameter definition")), "{found:?}"); + assert_eq!(r.report.imported.effects, 3, "{:?}", r.report.losses); +} + +/// When no instance of the type keeps its definitions, every instance is reported. +#[test] +fn instances_without_any_definitions_are_reported() { + let (p, r) = run(&second_glow_without_definitions(&mut |_| {}, true), true); + let r = r.unwrap(); + let found = losses(&r, LossCategory::Effects); + assert_eq!(found.iter().filter(|d| d.contains("ADBE Glo2") && d.contains("without readable parameter definitions, not imported")).count(), 2, "{found:?}"); + assert_eq!(r.report.imported.effects, 1, "Levels only: {:?}", r.report.losses); + assert!(glow_values(&p).is_empty()); +} + +/// The reader's side: the copy's definitions come from the other instance. +#[test] +fn an_instance_without_definitions_reads_those_of_another_instance() { + let bytes = second_glow_without_definitions(&mut |_| {}, false); + let file = parse(&bytes).unwrap(); + let project = effectcraft_aep::Project::new(&file).unwrap(); + let defs = project.effect_definition_index(); + let mut sources = Vec::new(); + for item in project.all_items() { + let Some(comp) = item.comp() else { continue }; + for layer in comp.layers() { + for fx in layer.effects().iter().filter(|fx| fx.match_name() == "ADBE Glo2") { + sources.push(defs.source(fx)); + let params = defs.params(fx); + assert_eq!(params.len(), 16, "Glow's parameters"); + assert_eq!(params.iter().all(|p| p.borrowed_definition), defs.source(fx) == effectcraft_aep::DefinitionSource::OtherInstance); + } + } + } + // The copy is above the instance it borrows from (earlier in the stack). + assert_eq!(sources, [effectcraft_aep::DefinitionSource::OtherInstance, effectcraft_aep::DefinitionSource::Instance]); +} + /// A record in a state other than 1 or 3 is not read and is reported. #[test] fn an_effect_record_in_another_state_is_reported_as_damaged() { @@ -403,3 +744,60 @@ fn an_effect_record_in_another_state_is_reported_as_damaged() { assert!(damaged.iter().any(|l| l.contains("unreadable value")), "tdsb {tdsb}: {:?}", r.report.losses); } } + +/// The project with a value record for Glow's Color A (`ADBE Glo2-0012`, which the project does +/// not store) in state `tdsb` holding the ARGB doubles `argb` (0..255): a copy of the A & B +/// Midpoint record (`-0011`) made a 4-component colour without its expression. +fn with_color_a(tdsb: u32, argb: [f64; 4]) -> Vec { + let mut file: AepFile<'_> = parse(SYNTHETIC).unwrap(); + let mut added = 0; + visit(&mut file.chunks, &mut |c| { + let Body::List { list_type, children, .. } = &mut c.body else { return }; + if list_type != b"tdgp" { + return; + } + let Some(at) = children.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(b"ADBE Glo2-0011"))) else { + return; + }; + let (Some(mut name), Some(mut record)) = (children.get(at).cloned(), children.get(at + 1).filter(|k| k.is_list(b"tdbs")).cloned()) else { return }; + set_data(&mut name, 0, b"ADBE Glo2-0012\0"); + if let Body::List { children: kids, .. } = &mut record.body { + kids.retain(|k| !k.is(b"Utf8")); + for k in kids.iter_mut() { + if k.is(b"tdsb") { + set_data(k, 0, &tdsb.to_be_bytes()); + } else if k.is(b"tdb4") { + // 4 components; no expression (bytes 76, 77 and 120). + set_data(k, 3, &[4]); + for byte in [76, 77, 120] { + set_data(k, byte, &[0]); + } + } else if k.is(b"cdat") { + *k = Chunk::data(*b"cdat", argb.iter().flat_map(|v| v.to_be_bytes()).collect::>()); + } + } + } + children.insert(at + 2, record); + children.insert(at + 2, name); + added += 1; + }); + assert_eq!(added, 2, "the instance and the EfdG copy"); + write(&file).unwrap() +} + +/// A colour recorded in the default state (`tdsb` 3) reads like a written one (#3170's "parameter +/// Color ... holds an unreadable value" on 0.21.1, which read only written records). +#[test] +fn a_default_state_colour_record_holds_the_value() { + let argb = [255.0, 51.0, 153.0, 255.0]; + let (written, r1) = run(&with_color_a(1, argb), true); + let (default_state, r3) = run(&with_color_a(3, argb), true); + let (r1, r3) = (r1.unwrap(), r3.unwrap()); + assert!(losses(&r3, LossCategory::DamagedData).is_empty(), "{:?}", r3.report.losses); + assert_eq!(r3.report.losses, r1.report.losses); + let colour = |p: &Project| value_of(glow_values(p).first(), "colorA").cloned(); + let (unchanged, _) = run(SYNTHETIC, true); + assert!(colour(&written).is_some()); + assert_eq!(colour(&default_state), colour(&written)); + assert_ne!(colour(&default_state), colour(&unchanged), "the recorded colour, not the default"); +} diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 916cc3501..e0caf3218 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -19,3 +19,4 @@ effectcraft-model = { workspace = true } [dev-dependencies] effectcraft-script = { workspace = true } effectcraft-host = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index 6d8ce2819..570726932 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -346,32 +346,60 @@ impl ConfinedFootage { fn inside(&self, f: &Footage) -> bool { std::iter::once(&f.path).chain(&f.sequence).filter(|p| !p.is_empty()).all(|p| self.confinement.check(p, Access::Read).is_ok()) } + + /// [`Self::inside`] for a read: a refused one is counted on this thread, so the render + /// [`recording`](FootageSource::recording) it can't list what it made without the footage. + fn readable(&self, f: &Footage) -> bool { + let inside = self.inside(f); + if !inside { + let _ = REFUSED_READS.try_with(|n| n.set(n.get().wrapping_add(1))); + } + inside + } +} + +thread_local! { + /// Footage reads refused on this thread (see [`ConfinedFootage::readable`]). + static REFUSED_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The number of reads this thread has refused for lying outside the allowed roots. +fn refused_reads() -> u64 { + REFUSED_READS.try_with(std::cell::Cell::get).unwrap_or(0) } impl FootageSource for ConfinedFootage { + /// The frame from the inner source, or `None` when the footage lies outside the allowed roots. fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option> { - self.inside(footage).then(|| self.inner.frame(item, footage, t)).flatten() + self.readable(footage).then(|| self.inner.frame(item, footage, t)).flatten() } + /// The frame scaled to `width` by `height`, or `None` when the footage lies outside the allowed roots. fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, width: u32, height: u32) -> Option> { - self.inside(footage).then(|| self.inner.frame_at_size(item, footage, t, width, height)).flatten() + self.readable(footage).then(|| self.inner.frame_at_size(item, footage, t, width, height)).flatten() } + /// The frame with an explicit size and float curve, or `None` when the footage lies outside the allowed roots. fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { - self.inside(footage).then(|| self.inner.frame_with(item, footage, t, size, float)).flatten() + self.readable(footage).then(|| self.inner.frame_with(item, footage, t, size, float)).flatten() } + /// The footage audio from the inner source, or `None` when the footage lies outside the allowed roots. fn audio(&self, item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option> { - self.inside(footage).then(|| self.inner.audio(item, footage, t, frames, rate)).flatten() + self.readable(footage).then(|| self.inner.audio(item, footage, t, frames, rate)).flatten() } + /// The auxiliary channels of the footage frame, or `None` when the footage lies outside the allowed roots. fn aux(&self, item: ItemId, footage: &Footage, t: Tick) -> Option> { - self.inside(footage).then(|| self.inner.aux(item, footage, t)).flatten() + self.readable(footage).then(|| self.inner.aux(item, footage, t)).flatten() } + /// Forwards the auxiliary footage paths to the inner source. fn set_aux_footage(&self, paths: &[String]) { self.inner.set_aux_footage(paths) } + /// The 3D model for the footage, or `None` when the footage lies outside the allowed roots. fn model(&self, item: ItemId, footage: &Footage) -> Option> { - self.inside(footage).then(|| self.inner.model(item, footage)).flatten() + self.readable(footage).then(|| self.inner.model(item, footage)).flatten() } + /// The rasterised vector footage at `scale`, or `None` when the footage lies outside the allowed roots. fn vector_frame(&self, item: ItemId, footage: &Footage, scale: f64) -> Option> { - self.inside(footage).then(|| self.inner.vector_frame(item, footage, scale)).flatten() + self.readable(footage).then(|| self.inner.vector_frame(item, footage, scale)).flatten() } /// Says why footage outside the roots shows nothing, so the agent can act on it. fn problem(&self, footage: &Footage) -> Option { @@ -392,15 +420,29 @@ impl FootageSource for ConfinedFootage { fn set_conform_folder(&self, folder: Option) { self.inner.set_conform_folder(folder) } + /// The inner source's cache budget. fn cache_budget(&self) -> Option { self.inner.cache_budget() } + /// Runs `f` under the inner source's cancel flag. fn cancellable(&self, cancel: &Arc, f: &mut dyn FnMut()) { self.inner.cancellable(cancel, f) } + /// Whether the inner source has been called off. fn called_off(&self) -> bool { self.inner.called_off() } + /// The files read inside the roots, as the inner source notes them. Footage outside reads + /// nothing, and what is made without it can't be listed by its files: a refused read makes + /// the list incomplete, so it never outlives the session in a disk cache (another session + /// with other roots would read the footage). + fn recording(&self, reads: &effectcraft_engine::render::reads::ReadLog, f: &mut dyn FnMut()) { + let refused = refused_reads(); + self.inner.recording(reads, f); + if refused_reads() != refused { + reads.mark_incomplete(); + } + } } /// The session's file reads, only inside the read roots: what a project or an imported file @@ -593,6 +635,55 @@ mod tests { } } + /// Footage outside the roots shows nothing: a render that asked for it can't be listed by its + /// files (#3184), or a disk cache would keep the empty picture for a session with other + /// roots. Reads inside the roots record as the inner source tells. + #[test] + fn a_refused_footage_read_makes_the_render_unlisted() { + use effectcraft_engine::render::reads::{FileStamp, ReadFile, ReadLog}; + /// Reads every file, noting it. + struct Noting; + impl FootageSource for Noting { + /// Returns a 1x1 frame and notes the footage path it was asked for. + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + NOTED.with(|n| n.borrow_mut().push(f.path.clone())); + Some(Arc::new(Image::new(1, 1))) + } + /// Runs `f`, then reports every path noted meanwhile to `reads` as a file with a missing stamp. + fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) { + f(); + for p in NOTED.with(|n| std::mem::take(&mut *n.borrow_mut())) { + reads.note(ReadFile::new(p, FileStamp::Missing)); + } + } + } + thread_local! { + static NOTED: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + } + // (a fresh directory, removed when the test ends, panicking or not) + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path(); + let (inside, outside) = (base.join("in"), base.join("elsewhere")); + for d in [&inside, &outside] { + std::fs::create_dir_all(d).unwrap(); + } + let c = Confinement::new(std::slice::from_ref(&inside), &[]).unwrap(); + let src = ConfinedFootage::new(Arc::new(Noting), c); + let footage = |d: &std::path::Path| Footage { path: d.join("a.png").to_string_lossy().into_owned(), ..Default::default() }; + let read = |f: &Footage| { + let log = ReadLog::new(); + let mut got = None; + src.recording(&log, &mut || got = src.frame(ItemId(1), f, Tick::ZERO)); + (got.is_some(), log.into_files()) + }; + let (shown, files) = read(&footage(&inside)); + assert!(shown && files.is_complete() && files.len() == 1, "inside the roots: {files:?}"); + let (shown, files) = read(&footage(&outside)); + assert!(!shown && !files.is_complete(), "outside the roots: {files:?}"); + // The next render on the thread is not affected. + assert!(read(&footage(&inside)).1.is_complete()); + } + #[test] fn relative_and_outside_paths_of_newly_listed_commands_are_refused() { let base = std::env::temp_dir().join(format!("ec-confine-1243-{}", std::process::id())); diff --git a/crates/engine/src/analysis/tracking.rs b/crates/engine/src/analysis/tracking.rs index 221b28602..912a8adfe 100644 --- a/crates/engine/src/analysis/tracking.rs +++ b/crates/engine/src/analysis/tracking.rs @@ -208,7 +208,7 @@ pub(crate) fn source_frame( t: Tick, expr: Option<&dyn ExprHost>, ) -> Option<(Arc, [f64; 2])> { - let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None, property_overrides: &[], media_overrides: &[] }; + let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None, property_overrides: &[], media_overrides: &[], reads: None }; if let effectcraft_project::LayerSource::Footage { item } = &layer.source && let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind) && f.has_video diff --git a/crates/engine/src/background/remote.rs b/crates/engine/src/background/remote.rs index 27f9396e1..aea210ec5 100644 --- a/crates/engine/src/background/remote.rs +++ b/crates/engine/src/background/remote.rs @@ -20,13 +20,16 @@ //! calls [`FrameServer::resume`] for the next. Backend Auto picks CPU or GPU per comp from //! the frames' total times ([`effectcraft_render::AutoPick`]). //! - Disk cache: a render request may carry the frame's disk-cache key ([`FrameMsg::Render`] -//! `disk`); the transport stores the finished frame under it (the browser: in the Origin -//! Private File System) and reports [`FrameReply::Stored`]. +//! `disk`); the transport stores the finished frame under it with the source files it read +//! ([`FrameServer::take_files`], `disk_cache::frame_entry`; the browser: in the Origin Private +//! File System) and reports [`FrameReply::Stored`]. A frame whose files can't all be named is +//! not stored. The page checks an entry's files before it uses it (#3184). //! - Layer buffers on disk: with `layers`, the server's layer cache is backed by a //! [`PrefetchStore`] ([`FrameServer::set_layer_store`]): before the request the transport //! fetches the entries named in `prefetch` (recent misses that are on disk, chosen by the -//! page, `disk_cache::LayerPrefetch`), so the lookups in the middle of the render find them; -//! the reply lists the layer keys that missed and those served from the store +//! page, `disk_cache::LayerPrefetch`), so the lookups in the middle of the render find them +//! (each entry keeps the source files its buffer was made of and is used only while they are +//! unchanged, #3184); the reply lists the layer keys that missed and those served from the store //! ([`FrameReply::Frame`] `layer_misses` / `layer_hits`), and slow buffers queue up for the //! transport to write after the frame ([`PrefetchStore::take_writes`], reported as //! [`FrameReply::Stored`] with `layer`). @@ -37,6 +40,7 @@ use std::sync::Arc; use effectcraft_project::{ItemId, Project}; +use effectcraft_render::reads::{ReadFiles, ReadLog}; use effectcraft_render::{Accelerator, AutoKey, Backend, ExprHost, FootageSource, Image, LayerCache, LayerStore, PrefetchStore, RenderOpts, Renderer}; use effectcraft_time::Tick; use serde::{Deserialize, Serialize}; @@ -177,7 +181,7 @@ pub enum FrameReply { passes: u32, #[serde(default)] gpu: bool, - /// Layer-cache lookups that missed the store (salted keys, 32 hex digits). + /// Layer-cache lookups that missed the store (disk keys, 32 hex digits). #[serde(default, rename = "layerMisses", skip_serializing_if = "Vec::is_empty")] layer_misses: Vec, /// Layer buffers served from the store (fetched from disk). @@ -260,11 +264,12 @@ pub struct FrameServer { revision: Option, /// The frame being rendered in passes. pending: Option, - /// The disk cache's layer buffers (see the module docs) and the project's salt. + /// The disk cache's layer buffers (see the module docs). layer_store: Option>, - salt: u64, /// Size and content hash for footage paths with no native metadata (the browser file table). virtual_id: Option Option<(u64, u64)> + Send + Sync>>, + /// The source files the last finished frame read ([`FrameServer::take_files`]). + files: Option, } /// A frame whose readbacks are in flight. @@ -284,6 +289,7 @@ struct Pending { pub const MAX_PASSES: u32 = 24; impl FrameServer { + /// A frame server over `footage` and the optional expression host, with an empty layer cache. pub fn new(footage: Arc, expr: Option>) -> FrameServer { FrameServer { footage, @@ -295,17 +301,30 @@ impl FrameServer { revision: None, pending: None, layer_store: None, - salt: 0, virtual_id: None, + files: None, } } - /// Identity of virtual footage files, used when native metadata cannot describe the path. - /// Called from `FrameServer::load`, not from the UI thread's cache lookup. + /// Identity of virtual footage files, used when native metadata cannot describe the path: + /// what the disk entries of frames and layer buffers keep for them and are checked against. pub fn set_virtual_id(&mut self, id: Option Option<(u64, u64)> + Send + Sync>>) { + self.cache.set_virtual_id(id.clone()); self.virtual_id = id; } + /// The source files the frame of the last [`FrameReply::Frame`] read, with their identities + /// as read: what its disk entry keeps (`disk_cache::frame_entry`). Taken once. + pub fn take_files(&mut self) -> Option { + self.files.take() + } + + /// The identity of footage paths with no native metadata (the browser file table), to store + /// with a frame's disk entry. + pub fn virtual_id(&self) -> Option<&Arc Option<(u64, u64)> + Send + Sync>> { + self.virtual_id.as_ref() + } + /// The store behind the layer cache when a request asks for `layers` (the browser: the /// disk cache's layer buffers, fetched before the request). pub fn set_layer_store(&mut self, store: Option>) { @@ -316,11 +335,6 @@ impl FrameServer { self.layer_store.as_ref() } - /// The salt of the replica's layer keys on disk (keys are `(salt, key)`). - pub fn salt(&self) -> u64 { - self.salt - } - /// Render with `accel` when the request's backend asks for it (the worker's GPU). Its miss /// gate keeps placeholder buffers of unfinished passes out of the layer cache. pub fn set_accel(&mut self, accel: Option>) { @@ -347,8 +361,6 @@ impl FrameServer { fn load(&mut self, revision: u64) -> Result<(), String> { let p = Project::from_json(&self.doc.to_string()).map_err(|e| e.to_string())?; - self.salt = effectcraft_render::disk_cache::footage_salt_with(&p, self.virtual_id.as_deref()); - self.cache.set_store_salt(self.salt); self.project = Some(Arc::new(p)); self.revision = Some(revision); Ok(()) @@ -392,7 +404,7 @@ impl FrameServer { } FrameMsg::Render { id, revision, comp, time, opts, layers, .. } => { let store = self.layer_store.clone().filter(|_| layers); - self.cache.set_store(store.map(|s| (s as Arc, self.salt))); + self.cache.set_store(store.map(|s| s as Arc)); let fail = |error: String, stale: bool| vec![(FrameReply::Failed { id, error, stale }, None)]; if self.revision != Some(revision) { return fail(format!("the replica is at {:?}, not {revision}", self.revision), true); @@ -432,12 +444,16 @@ impl FrameServer { /// Run the next pass of the frame in progress; its reply once it is done. pub fn resume(&mut self) -> Vec<(FrameReply, Option>)> { let Some(mut pr) = self.pending.take() else { return vec![] }; + self.files = None; pr.passes += 1; let accel = self.accel.clone().filter(|_| pr.opts.backend != Backend::Cpu); let give_up = pr.passes > MAX_PASSES; + // (each pass renders the whole frame: the last one's files are the frame's) + let reads = ReadLog::new(); let mut r = Renderer::new(&pr.project, self.footage.as_ref(), pr.opts); r.expr = self.expr.as_deref(); r.cache = Some(&self.cache); + r.reads = Some(&reads); if !give_up { r.accel = accel.as_deref(); } @@ -451,6 +467,7 @@ impl FrameServer { } let ms = pr.t0.elapsed().as_secs_f64() * 1000.0; let gpu = r.accel.is_some() && r.active_accel().is_some(); + self.files = Some(reads.files()); let hex = |v: Vec| v.into_iter().map(|k| format!("{k:032x}")).collect::>(); let (layer_misses, layer_hits) = match &self.layer_store { Some(st) => (hex(st.take_misses()), hex(st.take_hits())), diff --git a/crates/engine/src/background/remote/tests.rs b/crates/engine/src/background/remote/tests.rs index e2db10b8e..e6d797f9d 100644 --- a/crates/engine/src/background/remote/tests.rs +++ b/crates/engine/src/background/remote/tests.rs @@ -69,7 +69,8 @@ fn layer_buffers_go_through_the_disk_store() { assert!(!layer_misses.is_empty() && layer_hits.is_empty()); let writes = store_a.take_writes(); assert!(!writes.is_empty()); - assert!(writes.iter().all(|(k, _)| (*k >> 64) as u64 == a.salt()), "keys are salted per project"); + // Sealed with the files each buffer was made of (none here: the demo reads no footage). + assert!(writes.iter().all(|(_, e)| effectcraft_render::disk_cache::read_layer_entry(e).is_some_and(|(_, f)| f.is_empty()))); let mut index = DiskIndex::default(); let mut disk = std::collections::HashMap::new(); for (k, e) in writes { @@ -375,3 +376,138 @@ fn every_frame_begin_has_a_frame_end() { assert!(matches!(w.handle(req(6, s.revision, comp, cpu))[0].0, FrameReply::Frame { id: 6, .. })); assert_eq!(load(), (3, 3)); } + +/// Footage that reads one file from memory (the browser's file table) for every frame. +struct OneVirtualFile { + /// Tell which files it reads (else every render's list is incomplete). + records: bool, +} + +impl FootageSource for OneVirtualFile { + fn frame(&self, _: ItemId, _: &effectcraft_project::Footage, _: Tick) -> Option> { + Some(Arc::new(Image::filled(4, 4, [1.0, 0.5, 0.0, 1.0]))) + } + fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) { + if self.records { + reads.note(effectcraft_render::reads::ReadFile::new("/clip.png", effectcraft_render::reads::FileStamp::Virtual)); + } else { + reads.mark_incomplete(); + } + f() + } +} + +/// The worker hands the transport the files each frame read, so its disk entry keeps them; the +/// page reads the entry back only while those files are unchanged. A frame whose files can't +/// all be named is not stored (#3184). +#[test] +fn the_worker_reports_the_files_of_each_frame_for_its_disk_entry() { + use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project, build}; + use effectcraft_render::disk_cache::{frame_entry, read_frame_entry}; + use effectcraft_time::FrameRate; + let mut p = Project::default(); + let f = Footage { path: "/clip.png".into(), width: 4, height: 4, duration: Tick::from_seconds_f64(1.0), has_video: true, ..Default::default() }; + let fid = p.add_item("clip.png", effectcraft_color::Label::None, None, ItemKind::Footage(f)); + let mut comp = Comp::new(4, 4, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (4, 4), None)].into(); + let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); + let id = |mark: u64| move |path: &str| (path == "/clip.png").then_some((10, mark)); + let (one, two) = (id(1), id(2)); + for records in [true, false] { + let mut w = FrameServer::new(Arc::new(OneVirtualFile { records }), None); + w.set_virtual_id(Some(Arc::new(one))); + let mut m = Mirror::default(); + assert!(w.handle(round(&m.sync(1, &p).unwrap())).is_empty()); + assert!(w.take_files().is_none(), "no frame yet"); + let out = w.handle(round(&FrameMsg::Render { + id: 1, + revision: 1, + comp: cid, + time: Tick::ZERO, + opts: Box::default(), + disk: Some(format!("{:032x}", 5u128)), + layers: false, + prefetch: vec![], + })); + let (FrameReply::Frame { width, height, .. }, Some(px)) = &out[0] else { panic!("{:?}", out[0].0) }; + let files = w.take_files().expect("the frame's files"); + assert!(w.take_files().is_none(), "taken once"); + let vid = w.virtual_id().cloned(); + let entry = frame_entry(*width, *height, px, &files, vid.as_deref()); + if !records { + assert!(!files.is_complete() && entry.is_none(), "an incomplete list is not stored"); + continue; + } + assert_eq!(files.files().iter().map(|f| &*f.path).collect::>(), ["/clip.png"]); + let entry = entry.expect("stored"); + assert_eq!(read_frame_entry(&entry, Some(&one)).map(|f| f.rgba), Some(px.clone())); + assert!(read_frame_entry(&entry, Some(&two)).is_none(), "the file was replaced: a miss"); + } +} + +/// No salt work at all (#3184, replacing the background salt of #3106): syncing a project with a +/// large image sequence nobody shows, patching it and rendering with the layer store never asks +/// the file hook about the sequence (the salt asked about every file at every sync). Only the +/// file the rendered buffer reads is asked about: to store its entry and, in another worker, to +/// check it before the buffer from the store is used. +#[test] +fn the_worker_asks_only_about_the_files_its_buffers_read() { + use effectcraft_project::{Comp, Footage, FootageKind, ItemKind, LayerSource, Project, build}; + use effectcraft_time::FrameRate; + use std::sync::Mutex; + let mut p = Project::default(); + let f = Footage { path: "/clip.png".into(), width: 4, height: 4, has_video: true, ..Default::default() }; + let fid = p.add_item("clip.png", effectcraft_color::Label::None, None, ItemKind::Footage(f)); + let seq = Footage { + kind: FootageKind::Sequence, + path: "/seq/f_00000.png".into(), + sequence: (0..5000).map(|i| format!("/seq/f_{i:05}.png")).collect(), + width: 4, + height: 4, + has_video: true, + ..Default::default() + }; + p.add_item("seq", effectcraft_color::Label::None, None, ItemKind::Footage(seq)); + let mut comp = Comp::new(4, 4, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (4, 4), None)].into(); + let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); + let asked: Arc>> = Arc::default(); + let worker = || { + let mut w = FrameServer::new(Arc::new(OneVirtualFile { records: true }), None); + let seen = asked.clone(); + w.set_virtual_id(Some(Arc::new(move |path: &str| { + seen.lock().unwrap().push(path.to_string()); + (path == "/clip.png").then_some((10, 1)) + }))); + let store = Arc::new(PrefetchStore::new(1 << 20).with_min_ms(0)); + w.set_layer_store(Some(store.clone())); + (w, store) + }; + let render = |w: &mut FrameServer, revision: u64, prefetch: Vec| { + let out = w.handle(round(&FrameMsg::Render { id: 1, revision, comp: cid, time: Tick::ZERO, opts: Box::default(), disk: None, layers: true, prefetch })); + let FrameReply::Frame { layer_hits, .. } = &out[0].0 else { panic!("{:?}", out[0].0) }; + layer_hits.len() + }; + // Worker A: synced, patched, rendered; its buffer is queued with its file. + let (mut a, store_a) = worker(); + let mut m = Mirror::default(); + assert!(a.handle(round(&m.sync(1, &p).unwrap())).is_empty()); + let mut edited = p.clone(); + edited.item_mut(cid).unwrap().name = "Renamed".into(); + assert!(a.handle(round(&m.sync(2, &edited).unwrap())).is_empty()); + assert!(asked.lock().unwrap().is_empty(), "syncing looks at no file"); + assert_eq!(render(&mut a, 2, vec![]), 0); + let writes = store_a.take_writes(); + assert!(!writes.is_empty(), "the clip's buffer is stored"); + // Worker B: fetched before its frame, checked, used. + let (mut b, store_b) = worker(); + let mut m = Mirror::default(); + assert!(b.handle(round(&m.sync(2, &edited).unwrap())).is_empty()); + for (k, e) in &writes { + assert!(store_b.provide(*k, e)); + } + assert!(render(&mut b, 2, writes.iter().map(|(k, _)| format!("{k:032x}")).collect()) > 0, "served from the store"); + let asked = asked.lock().unwrap(); + assert!(!asked.is_empty(), "the hook names the buffer's file"); + assert!(asked.iter().all(|p| p == "/clip.png"), "only the file the buffer read: {:?}", &asked[..asked.len().min(5)]); +} diff --git a/crates/engine/src/cmd/effect.rs b/crates/engine/src/cmd/effect.rs index 69bdb5a4a..1d352d192 100644 --- a/crates/engine/src/cmd/effect.rs +++ b/crates/engine/src/cmd/effect.rs @@ -345,6 +345,7 @@ fn warning(s: &mut Session, p: &Value) -> Result { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; Ok(json!({"effect": uid, "warning": effectcraft_effects::warning(g, &mut |pr| ctx.value(layer, pr))})) } @@ -378,6 +379,7 @@ fn pick_color(s: &mut Session, p: &Value) -> Result { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let mut r = effectcraft_render::Renderer::new(&s.project, &*s.footage, effectcraft_render::RenderOpts::default()); r.expr = s.expr.as_deref(); @@ -515,6 +517,7 @@ impl CryptoProbe { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)); let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?; @@ -586,6 +589,7 @@ fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let frame = probe_frame(&ctx, layer, pos); let o = s.render_opts(Default::default()); @@ -780,6 +784,7 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string(); let driven = prop.expr.as_ref().is_some_and(|e| e.enabled); diff --git a/crates/engine/src/cmd/layer/parent.rs b/crates/engine/src/cmd/layer/parent.rs index 3611ef047..cd923c950 100644 --- a/crates/engine/src/cmd/layer/parent.rs +++ b/crates/engine/src/cmd/layer/parent.rs @@ -249,6 +249,7 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op footage: Some(s.footage.as_ref()), property_overrides: &[], media_overrides: &[], + reads: None, }; let space = |id: Option| -> effectcraft_geom::Mat3 { id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY) diff --git a/crates/engine/src/cmd/layer_time.rs b/crates/engine/src/cmd/layer_time.rs index 4ecf725ec..2f8661885 100644 --- a/crates/engine/src/cmd/layer_time.rs +++ b/crates/engine/src/cmd/layer_time.rs @@ -194,6 +194,7 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result { footage: Some(s.footage.as_ref()), property_overrides: &[], media_overrides: &[], + reads: None, }; let now: Vec<(effectcraft_project::LayerId, f64)> = comp .layers diff --git a/crates/engine/src/cmd/path_nulls.rs b/crates/engine/src/cmd/path_nulls.rs index 93d71cd67..6da43afde 100644 --- a/crates/engine/src/cmd/path_nulls.rs +++ b/crates/engine/src/cmd/path_nulls.rs @@ -73,6 +73,8 @@ struct PathInfo { span: (f64, f64), } +/// Reads a mask or shape path at the current time and returns its comp-space vertices, value, expression reference and layer name. +/// Errors when the property is missing, is not a path or has no vertices. fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; @@ -87,6 +89,7 @@ fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result

Result Result { convert_one_expr(s, cid, lid, uid) } +/// Replaces the expression on one property with keyframes sampled from its evaluated values. +/// Returns the number of properties converted. fn convert_one_expr(s: &mut Session, cid: ItemId, lid: LayerId, uid: Uid) -> Result { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let layer = comp.layer(lid).ok_or(EngineError::NoComp)?.clone(); @@ -1397,6 +1399,7 @@ fn convert_one_expr(s: &mut Session, cid: ItemId, lid: LayerId, uid: Uid) -> Res footage: Some(s.footage.as_ref()), property_overrides: &[], media_overrides: &[], + reads: None, }; keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr))); t += fd; diff --git a/crates/engine/src/cmd/puppet.rs b/crates/engine/src/cmd/puppet.rs index d894516c2..bd20bd885 100644 --- a/crates/engine/src/cmd/puppet.rs +++ b/crates/engine/src/cmd/puppet.rs @@ -39,8 +39,17 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick) let fx = layer.effects()?; let index = fx.groups().position(|g| g.uid == fx_uid)?; let g = fx.groups().nth(index)?; - let ctx = - EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; + let ctx = EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: t, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + reads: None, + }; let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default()); r.expr = s.expr.as_deref(); r.cache = Some(&s.layer_cache); diff --git a/crates/engine/src/cmd/rig3d.rs b/crates/engine/src/cmd/rig3d.rs index dbcaa3c3c..58a4281a8 100644 --- a/crates/engine/src/cmd/rig3d.rs +++ b/crates/engine/src/cmd/rig3d.rs @@ -47,6 +47,7 @@ impl Env { fn of(s: &Session, cid: ItemId) -> Env { Env { time: s.time_of(cid), expr: s.expr.clone(), footage: s.footage.clone() } } + /// The evaluation context of `comp` in `proj` at the session's current time. fn ctx<'a>(&'a self, proj: &'a Project, cid: ItemId, comp: &'a Comp) -> EvalCtx<'a> { EvalCtx { project: proj, @@ -57,6 +58,7 @@ impl Env { footage: Some(self.footage.as_ref()), property_overrides: &[], media_overrides: &[], + reads: None, } } } diff --git a/crates/engine/src/cmd/three_d.rs b/crates/engine/src/cmd/three_d.rs index 009a33b2e..3355b68e3 100644 --- a/crates/engine/src/cmd/three_d.rs +++ b/crates/engine/src/cmd/three_d.rs @@ -418,14 +418,24 @@ fn set_view_camera(s: &mut Session, p: &Value) -> Result { Ok(json!({"view": v.id()})) } +/// Returns the active comp's 3D view state, with the comp size, for the 3D view commands. fn get_3d(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let (w, h) = (comp.width as f64, comp.height as f64); let vs = s.state.views3d.get(&cid).cloned().unwrap_or_default(); let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO); - let ctx = - EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; + let ctx = EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: t, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + reads: None, + }; let cam = vs.override_camera(w, h).unwrap_or_else(|| camera::active_camera(&ctx)); let f = cam.forward(); let lights: Vec = comp @@ -567,8 +577,17 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result { /// through each layer's world matrix; cameras and lights contribute their position). fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec { let Some(comp) = s.project.comp(cid) else { return vec![] }; - let ctx = - EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; + let ctx = EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: t, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + reads: None, + }; let mut out = vec![]; for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) { let m = ctx.world_matrix(l); diff --git a/crates/engine/src/footage/availability.rs b/crates/engine/src/footage/availability.rs index 7ad2bc03f..75e2a1e1c 100644 --- a/crates/engine/src/footage/availability.rs +++ b/crates/engine/src/footage/availability.rs @@ -215,20 +215,18 @@ impl Scanner { } } -/// A disk observation prepared entirely by the existing worker, including the potentially -/// expensive sequence stamps used for disk-cache keys. The UI only swaps a current snapshot. +/// A disk observation prepared entirely by the existing worker. The UI only swaps a current +/// snapshot (the disk cache needs nothing: its entries check their own files when read, #3184). pub(crate) struct Update { before: Arc, after: Arc, paths: BTreeSet, newly_missing: usize, - cache_enabled: bool, - salt: Option<(u64, u64)>, } -/// Prepare a replacement only when availability changed. Project cloning and optional disk -/// salt computation belong on the worker; apply later rejects an outdated project snapshot. -pub(crate) fn prepare(project: &Arc, sources: &[Source], missing: &[bool], cache_enabled: bool) -> Option { +/// Prepare a replacement only when availability changed. Project cloning belongs on the +/// worker; apply later rejects an outdated project snapshot. +pub(crate) fn prepare(project: &Arc, sources: &[Source], missing: &[bool]) -> Option { let mut after = project.clone(); let mut paths = BTreeSet::new(); let mut newly_missing = 0; @@ -245,22 +243,17 @@ pub(crate) fn prepare(project: &Arc, sources: &[Source], missing: &[boo if paths.is_empty() { return None; } - let salt = cache_enabled.then(|| (Session::footage_fingerprint(&after), crate::render::disk_cache::footage_salt(&after))); - Some(Update { before: project.clone(), after, paths, newly_missing, cache_enabled, salt }) + Some(Update { before: project.clone(), after, paths, newly_missing }) } impl Update { - /// False means a newer project or cache configuration needs a fresh observation. + /// False means a newer project needs a fresh observation. pub fn apply(self, s: &mut Session) -> bool { - if !Arc::ptr_eq(&s.project, &self.before) || s.disk_cache.is_some() != self.cache_enabled { + if !Arc::ptr_eq(&s.project, &self.before) { return false; } let dirty = s.is_dirty(); s.project = self.after; - if let Some((fingerprint, salt)) = self.salt { - s.disk_salt = Some((fingerprint, salt)); - s.layer_cache.set_disk_salt(salt); - } s.bump(); if !dirty { s.mark_saved(); @@ -371,7 +364,7 @@ mod tests { fn stale_observation_cannot_mark_a_replacement_missing() { let (mut s, id) = session(Arc::new(Disk::default())); let old = sources(&s); - let update = prepare(&s.project, &old, &[true, true], false).unwrap(); + let update = prepare(&s.project, &old, &[true, true]).unwrap(); if let ItemKind::Footage(f) = &mut Arc::make_mut(&mut s.project).item_mut(id).unwrap().kind { f.path = "/new.png".into(); } @@ -398,9 +391,12 @@ mod tests { assert_eq!(disk.looks.load(Ordering::Relaxed), 2, "one file per source, no scan of an unmounted folder"); } + /// An availability change does no disk-cache work (#3184): the cache stays attached and + /// no file is looked at for it, on the worker or when the UI applies the observation (it + /// used to stamp every footage file for a new layer salt). #[test] - fn applying_prepared_cache_salt_does_not_restat_files_on_the_ui_thread() { - let dir = std::env::temp_dir().join(format!("ec-availability-salt-{}", std::process::id())); + fn applying_an_availability_change_does_no_disk_cache_work() { + let dir = std::env::temp_dir().join(format!("ec-availability-cache-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let file = dir.join("plate.png"); std::fs::write(&file, b"plate").unwrap(); @@ -413,16 +409,13 @@ mod tests { ); let cache = crate::render::disk_cache::DiskCache::open(dir.join("cache"), 1 << 20).unwrap(); s.disk_cache = Some(cache.clone()); - s.layer_cache.set_disk(Some((cache, 0))); + s.layer_cache.set_disk(Some(cache.clone())); s.mark_saved(); - let update = prepare(&s.project, &sources(&s), &[false], true).unwrap(); - let expected = update.salt.unwrap().1; - // The filesystem can change again after the worker's observation. Applying it must - // retain that observation's salt, rather than scan the disk a second time on the UI. + let update = prepare(&s.project, &sources(&s), &[false]).unwrap(); std::fs::remove_file(file).unwrap(); - assert_ne!(crate::render::disk_cache::footage_salt(&update.after), expected); assert!(update.apply(&mut s)); - assert_eq!(s.disk_salt.map(|(_, salt)| salt), Some(expected)); + assert!(s.layer_cache.disk().is_some_and(|d| Arc::ptr_eq(&d, &cache)), "still attached"); + assert_eq!(cache.file_checks(), 0, "no file looked at for the disk cache"); assert!(!s.is_dirty()); drop(s); std::fs::remove_dir_all(dir).unwrap(); diff --git a/crates/engine/src/footage/footage_check.rs b/crates/engine/src/footage/footage_check.rs index 9e941a6ed..42401d195 100644 --- a/crates/engine/src/footage/footage_check.rs +++ b/crates/engine/src/footage/footage_check.rs @@ -272,7 +272,7 @@ mod tests { let id = source.id; let mut recovered = source.footage.clone(); recovered.path = "/new/project/plate.png".into(); - let observed = super::super::availability::prepare(&s.project, std::slice::from_ref(&source), &[true], false).unwrap(); + let observed = super::super::availability::prepare(&s.project, std::slice::from_ref(&source), &[true]).unwrap(); assert!(observed.apply(&mut s)); assert!(matches!(&s.project.item(id).unwrap().kind, ItemKind::Footage(f) if f.missing)); let result = apply(&mut s, vec![(source, Found::Moved(Box::new(recovered)))]); diff --git a/crates/engine/src/footage/footage_reload.rs b/crates/engine/src/footage/footage_reload.rs index 7065620af..e56e16a96 100644 --- a/crates/engine/src/footage/footage_reload.rs +++ b/crates/engine/src/footage/footage_reload.rs @@ -6,7 +6,8 @@ //! or PDF page an item shows and its interpretation (alpha, conformed frame rate, pixel aspect, //! fields, colour). A Photoshop layer is found again by name when layers were added or removed //! around it. The media layer then forgets the file and the layer cache is emptied, so the next -//! render reads the new pixels. +//! render reads the new pixels; File ▸ Reload Footage also deletes the disk cache entries made of +//! the file (#3184). //! //! The automatic part compares each footage file's size and modification time with what they //! were when the file was last seen. The desktop app keeps a [`Watcher`]: a worker thread that @@ -76,7 +77,6 @@ pub struct Watched { reload: BTreeSet, sources: Arc>, project: Option>, - cache_enabled: bool, reload_mode: String, stored_paths: crate::doc_path::StoredPaths, } @@ -85,8 +85,7 @@ impl PartialEq for Watched { /// Compare watched inputs, excluding unrelated project edits whose latest Arc is refreshed /// separately without waking the worker or restarting partial sequence scans. fn eq(&self, other: &Self) -> bool { - self.cache_enabled == other.cache_enabled - && self.stored_paths == other.stored_paths + self.stored_paths == other.stored_paths && self.reload_mode == other.reload_mode && self.reload == other.reload && (Arc::ptr_eq(&self.sources, &other.sources) || self.sources == other.sources) @@ -100,7 +99,6 @@ pub fn watched(s: &Session) -> Watched { reload: automatic_paths(s), sources: Arc::new(if s.check_footage_on_open { availability::sources(s) } else { Vec::new() }), project: Some(s.project.clone()), - cache_enabled: s.disk_cache.is_some(), reload_mode: s.prefs.import.auto_reload_footage.clone(), stored_paths: s.stored_paths.clone(), } @@ -139,10 +137,10 @@ pub struct Scan { } impl Scan { - /// Prepare availability and cache state before the session applies the observation. + /// Prepare the availability change before the session applies the observation. /// Automatic callers construct this result on the worker; explicit commands run inline. fn new(watched: Watched, observation: Observation, policy: ReloadPolicy) -> Self { - let update = watched.project.as_ref().and_then(|p| availability::prepare(p, &watched.sources, &observation.missing, watched.cache_enabled)); + let update = watched.project.as_ref().and_then(|p| availability::prepare(p, &watched.sources, &observation.missing)); Self { watched, observation, update, policy } } @@ -281,7 +279,17 @@ fn reload(s: &mut Session, ids: &[ItemId], undoable: bool) -> crate::Result<(usi } let n = updates.len(); apply_reloads(s, updates, undoable)?; - forget(s, &paths); + forget_media(s, &paths); + // An explicit reload also reads files again that kept their size and time (the check on hit + // can't tell those apart): their entries in the disk cache go (#3184), when the cache is + // switched on if it is off now. An automatic one only follows a changed stamp, which the + // check catches. + if undoable { + match &s.disk_cache { + Some(dc) => dc.drop_files(paths.iter().map(String::as_str)), + None => s.pending_disk_drops.extend(paths), + } + } Ok((n, failed)) } @@ -320,14 +328,9 @@ fn apply_reloads(s: &mut Session, updates: Vec<(ItemId, Footage)>, undoable: boo Ok(()) } -/// Make the media layer and the layer cache let go of `paths`, so renders read them again. -fn forget(s: &mut Session, paths: &BTreeSet) { - forget_media(s, paths); - s.refresh_disk_salt(true); -} - -/// Invalidate decoded and rendered buffers without probing the disk for a new salt. -/// Availability updates already carry their worker-computed salt; content reload refreshes it. +/// Make the media layer and the layer cache let go of `paths`, so renders read them again. Disk +/// cache entries check the files they were made of when read (#3184); File ▸ Reload Footage +/// also drops the entries of its files ([`reload`]). pub(crate) fn forget_media(s: &mut Session, paths: &BTreeSet) { for p in paths { s.footage.forget(p); @@ -374,7 +377,7 @@ pub fn apply_stamps(s: &mut Session, now: Vec<(String, Option)>) -> crate return Ok(json!({"files": 0, "reloaded": 0})); } // Changed frames of image sequences (their items keep their metadata). - forget(s, &changed); + forget_media(s, &changed); let files = changed.len(); s.toast(if reloaded > 0 { format!("Reloaded {reloaded} footage item(s) changed on disk") @@ -391,7 +394,6 @@ pub(crate) fn command(s: &mut Session, _: &Value) -> crate::Result { reload: reload_paths(s), sources: Arc::new(availability::sources(s)), project: Some(s.project.clone()), - cache_enabled: s.disk_cache.is_some(), reload_mode: s.prefs.import.auto_reload_footage.clone(), stored_paths: s.stored_paths.clone(), }; @@ -452,12 +454,7 @@ impl Watcher { pub fn update(&mut self, s: &Session) { let same_sources = availability::sources_match(s, &self.paths.sources); let same_data = self.paths.project.as_ref().is_some_and(|before| data_paths(before).eq(data_paths(&s.project))); - if same_sources - && same_data - && self.paths.stored_paths == s.stored_paths - && self.paths.reload_mode == s.prefs.import.auto_reload_footage - && self.paths.cache_enabled == s.disk_cache.is_some() - { + if same_sources && same_data && self.paths.stored_paths == s.stored_paths && self.paths.reload_mode == s.prefs.import.auto_reload_footage { let project = Some(s.project.clone()); self.paths.project = project.clone(); self.pending.lock().unwrap_or_else(PoisonError::into_inner).paths.project = project; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e50b15d04..599aba1b7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -97,7 +97,7 @@ pub use error::{EngineError, Result}; pub use event::Event; pub use fonts::{FontRow, font_features, font_menu, font_preview, font_styles, text_families}; pub use services::{FsServices, Importer, Services}; -pub use session::{DiskSaltJob, upgrade_effects}; +pub use session::upgrade_effects; pub struct Session { pub project: Arc, @@ -171,6 +171,9 @@ pub struct Session { pub crypto_probe: Arc>, /// The persistent disk cache (Settings ▸ Media & Disk Cache), when enabled. pub disk_cache: Option>, + /// Files File ▸ Reload Footage reloaded while the disk cache was off: their entries are + /// dropped when it is switched on ([`Session::configure_disk_cache`]), before it is used. + pub pending_disk_drops: std::collections::BTreeSet, /// Settings (Preferences). pub prefs: prefs::Prefs, /// Bumped whenever settings change (frontends re-apply theme, labels…). @@ -254,14 +257,6 @@ pub struct Session { /// Each footage file's size and modification time when last seen (Settings ▸ Import ▸ /// Reload Footage Changed on Disk, [`footage_reload`]). pub footage_stamps: std::collections::HashMap, - /// The disk cache's footage salt and the footage it was taken for ([`Session::refresh_disk_salt`]). - pub disk_salt: Option<(u64, u64)>, - /// Take the disk cache's footage salt on a worker thread ([`Session::refresh_disk_salt`]): - /// frontends with an event loop (the desktop app) turn this on and call - /// [`Session::poll_disk_salt`] each frame; headless sessions take it at once. - pub disk_salt_background: bool, - /// The footage salt being taken in the background. - pub disk_salt_job: Option, /// The footage files effects read the channels of, as last given to the footage source /// ([`Session::refresh_aux_footage`]); `None` = not given yet. pub aux_footage: Option>, @@ -356,6 +351,7 @@ impl Default for Session { layer_cache: Arc::new(LayerCache::default()), crypto_probe: Default::default(), disk_cache: None, + pending_disk_drops: Default::default(), prefs: prefs::Prefs::default(), prefs_revision: 0, config: None, @@ -391,9 +387,6 @@ impl Default for Session { check_footage_on_open: false, stored_paths: Default::default(), footage_stamps: Default::default(), - disk_salt: None, - disk_salt_background: false, - disk_salt_job: None, aux_footage: None, script_calls: ScriptCalls::default(), } diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 31ed40c1d..18b0ff4cf 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -10,4 +10,4 @@ mod dispatch; mod edit; mod render; -pub use edit::{DiskSaltJob, upgrade_effects}; +pub use edit::upgrade_effects; diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index 28813e2ec..67a8e4dcb 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -97,114 +97,10 @@ impl Session { if Arc::ptr_eq(&self.project, &self.saved_project) { self.saved_revision = self.revision; } - self.refresh_disk_salt(false); self.refresh_aux_footage(); self.events.push(Event::ProjectChanged { revision: self.revision }); } - /// Salt the disk cache's layer keys with the project's footage files - /// ([`effectcraft_render::disk_cache::footage_salt`]) and attach the disk cache to the layer - /// cache with it. Taking it reads the size and time of every file, every frame of an image - /// sequence included, so it is taken again only when the footage items changed, or with - /// `force` (a project opened, footage reloaded, the disk cache attached), not at every edit: - /// with a few large sequences that cost each keystroke in a text field over half a second - /// (#1926). - /// - /// With [`Session::disk_salt_background`] the files are read on a worker thread: an imported - /// or opened project with thousands of sequence frames on a network share took half a minute - /// to stamp, all of it on the UI thread. Until [`Session::poll_disk_salt`] picks the salt up, - /// renders use the memory cache only (keys under the old salt may name other files, or - /// another project's items) and this returns `None`. - pub fn refresh_disk_salt(&mut self, force: bool) -> Option { - let Some(dc) = self.disk_cache.clone() else { - self.disk_salt = None; - self.disk_salt_job = None; - return None; - }; - self.poll_disk_salt(); - let footage = Self::footage_fingerprint(&self.project); - if !force { - if let Some((seen, salt)) = self.disk_salt - && seen == footage - { - return Some(salt); - } - if self.disk_salt_job.as_ref().is_some_and(|j| j.footage == footage) { - return None; - } - } - if self.disk_salt_background && !cfg!(target_arch = "wasm32") { - let project = self.project.clone(); - let slot: Arc>> = Arc::default(); - let out = slot.clone(); - let spawned = std::thread::Builder::new().name("ec-disk-salt".into()).spawn(move || { - let salt = effectcraft_render::disk_cache::footage_salt(&project); - if let Ok(mut s) = out.lock() { - *s = Some(salt); - } - }); - // Without a thread the salt is taken here, as before. - if let Ok(thread) = spawned { - self.disk_salt = None; - self.disk_salt_job = Some(DiskSaltJob { footage, slot, thread }); - self.layer_cache.set_disk(None); - return None; - } - } - self.disk_salt_job = None; - let salt = effectcraft_render::disk_cache::footage_salt(&self.project); - self.disk_salt = Some((footage, salt)); - self.layer_cache.set_disk(Some((dc, salt))); - Some(salt) - } - - /// Pick up a footage salt taken in the background ([`Session::refresh_disk_salt`]) and attach - /// the disk cache with it. Returns whether one is still being taken (frontends call this each - /// frame while it is). - pub fn poll_disk_salt(&mut self) -> bool { - let Some(job) = &self.disk_salt_job else { return false }; - // (looked at first: a worker that has finished has left its salt in the slot) - let finished = job.thread.is_finished(); - let salt = job.slot.lock().ok().and_then(|mut s| s.take()); - let footage = job.footage; - match salt { - Some(salt) => { - self.disk_salt_job = None; - if let Some(dc) = self.disk_cache.clone() { - self.disk_salt = Some((footage, salt)); - self.layer_cache.set_disk(Some((dc, salt))); - } - false - } - None if finished => { - // The worker stopped without a salt: the disk cache stays off until the next - // edit takes it again. - log::warn!("disk cache: the footage files could not be stamped; the disk cache is off until the next edit"); - self.disk_salt_job = None; - false - } - None => true, - } - } - - /// In-memory identity of the footage settings represented by a disk salt. Workers use the - /// same fingerprint when preparing availability changes without disk access on the UI. - pub(crate) fn footage_fingerprint(project: &effectcraft_project::Project) -> u64 { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - for (id, item) in &project.items { - if let effectcraft_project::ItemKind::Footage(f) = &item.kind { - id.hash(&mut h); - format!("{f:?}").hash(&mut h); - } - if let Some(proxy) = &item.proxy { - id.hash(&mut h); - format!("{proxy:?}").hash(&mut h); - } - } - h.finish() - } - /// Set a comp's Resolution. As in After Effects it is saved with the project (which becomes /// modified) but is no undo step, and it changes no pixels: the comp's cached frames stay /// valid ([`effectcraft_project::Comp::same_pixels`]). @@ -322,11 +218,6 @@ impl Session { } self.layer_cache.clear(); self.project = Arc::new(p); - // Its files may have changed since they were last stamped. - self.disk_salt = None; - // A salt still being taken is for the old project's files: dropped, so that the next refresh - // stamps the new project anew (a matching fingerprint would otherwise keep the stale job). - self.disk_salt_job = None; // The set of footage effects read the channels of is sent again, even when it is empty. self.aux_footage = None; self.history = History::default(); @@ -389,11 +280,3 @@ pub fn upgrade_effects(p: &mut Project) { p.next_id = next; p.schema = effectcraft_project::SCHEMA_VERSION; } - -/// A footage salt being taken on a worker thread ([`Session::refresh_disk_salt`]). -pub struct DiskSaltJob { - /// The footage it is taken for ([`Session::footage_fingerprint`]). - footage: u64, - slot: Arc>>, - thread: std::thread::JoinHandle<()>, -} diff --git a/crates/engine/src/settings/session_settings.rs b/crates/engine/src/settings/session_settings.rs index 056616201..8ade9f8a3 100644 --- a/crates/engine/src/settings/session_settings.rs +++ b/crates/engine/src/settings/session_settings.rs @@ -137,10 +137,6 @@ impl Session { // over the store of a roto test running beside it. Serialise them with the roto tests. #[cfg(test)] crate::tests::roto::hold_roto_cache_test_lock(); - // Another setting changed (the disk cache stays as it is): its salt still holds, and - // stamping every footage file again would cost each settings change seconds with large - // sequences. - let kept = self.disk_cache.as_ref().is_some_and(|c| c.folder() == folder.as_path()); let dc = match &self.disk_cache { Some(c) if c.folder() == folder.as_path() => { c.set_max_bytes(max); @@ -162,11 +158,17 @@ impl Session { } }, }; + // Files reloaded while the cache was off: their entries go before anything is read from + // it (until the writer has deleted them they are misses, and no frame is linked). + if !self.pending_disk_drops.is_empty() { + dc.drop_files(std::mem::take(&mut self.pending_disk_drops)); + } self.disk_cache = Some(dc.clone()); // The reserve applies from the start (a new folder's watcher is idle). self.poll_disk_space(); - // Attaches the disk cache to the layer cache once the footage salt is known. - self.refresh_disk_salt(!kept); + // Used at once (no file is looked at first: each entry checks the source files it lists + // when it is read, #3184). + self.layer_cache.set_disk(Some(dc.clone())); // Roto Brush / Object Matte segmentations persist there too (held weakly: switching the // disk cache off drops it). let store: std::sync::Arc = dc.clone(); diff --git a/crates/engine/src/tests/crypto_pick.rs b/crates/engine/src/tests/crypto_pick.rs index e3a8cd50c..82fe21e71 100644 --- a/crates/engine/src/tests/crypto_pick.rs +++ b/crates/engine/src/tests/crypto_pick.rs @@ -214,6 +214,7 @@ fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (Strin footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let buf = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).content_buf(&ctx, layer).unwrap(); let (px, py) = buf.to_px([x, y]); diff --git a/crates/engine/src/tests/disk_cache.rs b/crates/engine/src/tests/disk_cache.rs index 4805d68fe..4c610dceb 100644 --- a/crates/engine/src/tests/disk_cache.rs +++ b/crates/engine/src/tests/disk_cache.rs @@ -76,11 +76,13 @@ fn disk_cache_settings_persistence_and_invalidation() { assert!(s2.layer_cache.disk().is_none()); } +/// The comp key comes from the project in memory: a footage file that changes on disk doesn't +/// change it (the entries check their own files, #3184); edits that change pixels do. #[test] -fn footage_changes_change_the_salt() { - let dir = folder("salt"); +fn comp_content_keys_come_from_memory() { + let dir = folder("keys"); let mut s = session(&dir); - let foot = std::env::temp_dir().join(format!("effectcraft-salt-{}.png", std::process::id())); + let foot = std::env::temp_dir().join(format!("effectcraft-keys-{}.png", std::process::id())); std::fs::write(&foot, b"one").unwrap(); s.edit("add", None, |p, _| { let f = effectcraft_project::Footage { @@ -108,12 +110,10 @@ fn footage_changes_change_the_salt() { Ok(()) }) .unwrap(); - let a = effectcraft_render::disk_cache::footage_salt(&s.project); - std::fs::write(&foot, b"two, longer").unwrap(); - let b = effectcraft_render::disk_cache::footage_salt(&s.project); - assert_ne!(a, b); let cid = project(&mut s); let k1 = effectcraft_render::disk_cache::comp_content_key(&s.project, cid); + std::fs::write(&foot, b"two, longer").unwrap(); + assert_eq!(effectcraft_render::disk_cache::comp_content_key(&s.project, cid), k1, "no file is looked at"); s.execute("prop.set", json!({"layer": "#1", "path": "transform/opacity", "value": 50})).unwrap(); let k2 = effectcraft_render::disk_cache::comp_content_key(&s.project, cid); assert_ne!(k1, k2); @@ -137,66 +137,51 @@ fn footage_changes_change_the_salt() { assert_ne!(set_proxy(&mut s, false), k3); } -/// The desktop app stamps footage files for the disk cache's salt on a worker thread: importing an -/// After Effects project with thousands of sequence frames on a network share stamped them all -/// inside the import's edit, freezing the UI for half a minute. Until the salt lands the disk -/// cache sits out (keys under the old salt may name other files); a newer footage change -/// supersedes a salt still being taken; a settings change keeps the salt. +/// No salt and no scan (#3184, replacing the background footage salt of #3106): the disk cache +/// is attached to the layer cache as soon as it is switched on, and stays attached through +/// edits that add footage (a large image sequence included), settings changes and opening a +/// project, none of which looks at a single file for it. The first render may use it at once. #[test] -fn footage_salt_is_taken_off_the_ui_thread_and_the_disk_cache_waits_for_it() { - let dir = folder("bg-salt"); +fn the_disk_cache_is_attached_at_once_and_edits_do_no_file_work() { + let dir = folder("attach"); let mut s = session(&dir); - s.disk_salt_background = true; - let at = |n: &str| std::env::temp_dir().join(format!("effectcraft-bg-salt-{}-{n}.png", std::process::id())); - let add = |s: &mut Session, path: &std::path::Path| { - let f = effectcraft_project::Footage { - path: path.to_string_lossy().to_string(), - kind: effectcraft_project::FootageKind::Still, - width: 1, - height: 1, - has_video: true, - ..Default::default() - }; - s.edit("add", None, |p, _| { - p.add_item("f.png", effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f)); - Ok(()) - }) - .unwrap(); + let dc = s.disk_cache.clone().unwrap(); + let attached = |s: &Session| s.layer_cache.disk().is_some_and(|d| std::sync::Arc::ptr_eq(&d, &dc)); + assert!(attached(&s), "attached at once"); + // A sequence of 5000 frames that don't even exist. + let seq_dir = std::env::temp_dir().join(format!("effectcraft-no-scan-{}", std::process::id())); + let f = effectcraft_project::Footage { + path: seq_dir.join("f_00000.exr").to_string_lossy().to_string(), + kind: effectcraft_project::FootageKind::Sequence, + sequence: (0..5000).map(|i| seq_dir.join(format!("f_{i:05}.exr")).to_string_lossy().to_string()).collect(), + width: 8, + height: 8, + has_video: true, + ..Default::default() }; - let wait = |s: &mut Session| { - let t0 = std::time::Instant::now(); - while s.poll_disk_salt() { - assert!(t0.elapsed().as_secs() < 20, "the salt never landed"); - std::thread::sleep(std::time::Duration::from_millis(5)); - } - }; - assert!(s.layer_cache.disk().is_some(), "attached with its salt"); - std::fs::write(at("a"), b"one").unwrap(); - add(&mut s, &at("a")); - // The edit returned before the files were stamped, and the disk cache waits for the salt. - assert!(s.disk_salt.is_none() && s.layer_cache.disk().is_none()); - // Edits that leave the footage alone don't start over (and may pick up the salt meanwhile). - s.execute("comp.new", json!({"name": "C", "width": 8, "height": 8, "frameRate": 30, "duration": 1})).unwrap(); - let r = s.refresh_disk_salt(false); - assert!(r.is_none() || r == Some(effectcraft_render::disk_cache::footage_salt(&s.project)), "{r:?}"); - // New footage while it is taken: the salt for the newest footage lands. - std::fs::write(at("b"), b"two").unwrap(); - add(&mut s, &at("b")); - wait(&mut s); - let salt = effectcraft_render::disk_cache::footage_salt(&s.project); - assert_eq!(s.disk_salt.map(|(_, salt)| salt), Some(salt)); - assert_eq!(s.refresh_disk_salt(false), Some(salt)); - assert!(s.layer_cache.disk().is_some(), "attached again"); - // Another setting keeps the salt and the disk cache attached. + s.edit("add", None, |p, _| { + p.add_item("seq", effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f)); + Ok(()) + }) + .unwrap(); + assert!(attached(&s), "an import keeps it attached"); + let cid = project(&mut s); + s.execute("prop.set", json!({"layer": "#1", "path": "transform/opacity", "value": 50})).unwrap(); s.execute_checked("prefs.set", json!({"key": "disk.diskCacheMaxGb", "value": 30})).unwrap(); - assert!(s.layer_cache.disk().is_some() && s.disk_salt.map(|(_, salt)| salt) == Some(salt)); - // Turned off and on again: stamped anew, in the background. + assert!(attached(&s), "edits and other settings keep it attached"); + let same = (*s.project).clone(); + s.replace_project(same, None); + assert!(attached(&s), "opening a project keeps it attached"); + // The first render uses it: a solid reads no file, so nothing is checked either. + s.render(cid, s.time(), RenderOpts::default()); + dc.flush(); + assert!(dc.stats().entries > 0, "written by the first render"); + assert_eq!(dc.file_checks(), 0, "no file looked at for the disk cache"); + // Switched off and on: attached again at once. s.execute_checked("prefs.set", json!({"key": "disk.diskCacheEnabled", "value": false})).unwrap(); + assert!(s.layer_cache.disk().is_none()); s.execute_checked("prefs.set", json!({"key": "disk.diskCacheEnabled", "value": true})).unwrap(); - wait(&mut s); - assert_eq!(s.refresh_disk_salt(false), Some(salt)); - let _ = std::fs::remove_file(at("a")); - let _ = std::fs::remove_file(at("b")); + assert!(s.layer_cache.disk().is_some()); } /// Marker edits keep the disk key (the sound of a playing preview must not wait for frames to @@ -258,38 +243,227 @@ fn protected_markers_with_a_duration_change_the_content_key() { assert_ne!(key(&s), k1, "its duration"); } -/// A project opened while a footage salt is still being taken must not keep that salt when it -/// names the same footage: its files may have changed since the worker read them. +/// With expressions the comp key covers what they can read, not the whole project (#3184, +/// #1492): an expression reading only its own comp keeps the key through edits of another comp +/// and of unused footage; `comp("Other")` makes "Other" count (and which comps have that name), +/// not a third comp; a computed name counts every item. #[test] -fn opening_a_project_drops_the_footage_salt_being_taken() { - let dir = folder("bg-salt-open"); - let mut s = session(&dir); - s.disk_salt_background = true; - let path = std::env::temp_dir().join(format!("effectcraft-bg-salt-open-{}.png", std::process::id())); - std::fs::write(&path, b"one").unwrap(); - let f = effectcraft_project::Footage { - path: path.to_string_lossy().to_string(), - kind: effectcraft_project::FootageKind::Still, - width: 1, - height: 1, - has_video: true, - ..Default::default() +fn expressions_reach_only_what_they_name() { + let mut s = Session::default(); + let comp = |s: &mut Session, name: &str| { + let c = s.execute("comp.new", json!({"name": name, "width": 8, "height": 8, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + let l = s.execute("layer.newSolid", json!({"comp": c, "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + (effectcraft_project::ItemId(c), (c, l)) }; - s.edit("add", None, |p, _| { - p.add_item("f.png", effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f)); + let (main, ml) = comp(&mut s, "Main"); + let (_, ol) = comp(&mut s, "Other"); + let (third, tl) = comp(&mut s, "Third"); + let unused = s.edit("add", None, |p, _| { + let f = effectcraft_project::Footage { path: "/nowhere/a.png".into(), has_video: true, ..Default::default() }; + Ok(p.add_item("a.png", effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f))) + }); + let unused = unused.unwrap(); + let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, main); + let expr = |s: &mut Session, (comp, layer): (u64, u64), text: &str| { + s.execute("prop.setExpression", json!({"comp": comp, "layer": layer, "path": "transform/opacity", "expression": text})).unwrap(); + }; + let opacity = |s: &mut Session, (comp, layer): (u64, u64), v: f64| { + s.execute("prop.set", json!({"comp": comp, "layer": layer, "path": "transform/opacity", "value": v})).unwrap(); + }; + let repath = |s: &mut Session, path: &str| { + let path = path.to_string(); + s.edit("repath", None, move |p, _| { + if let Some(effectcraft_project::ItemKind::Footage(f)) = p.item_mut(unused).map(|i| &mut i.kind) { + f.path = path; + } + Ok(()) + }) + .unwrap(); + }; + // Its own comp only. + expr(&mut s, ml, "thisComp.layer(1).transform.rotation + wiggle(1, 1)[0]"); + expr(&mut s, ol, "wiggle(2, 10)"); + let k0 = key(&s); + opacity(&mut s, ol, 40.0); + expr(&mut s, tl, "time * 10"); + repath(&mut s, "/nowhere/b.png"); + s.execute("comp.addMarker", json!({"comp": third.0, "time": 0.5})).unwrap(); + assert_eq!(key(&s), k0, "edits elsewhere keep the key"); + // An edit in the comp itself changes it. + opacity(&mut s, ml, 30.0); + assert_ne!(key(&s), k0); + // `comp("Other")`: Other counts, Third doesn't. + expr(&mut s, ml, "comp(\"Other\").layer(1).transform.opacity"); + let k1 = key(&s); + expr(&mut s, ol, "wiggle(3, 10)"); + let k2 = key(&s); + assert_ne!(k2, k1, "the comp it names"); + opacity(&mut s, tl, 20.0); + repath(&mut s, "/nowhere/c.png"); + assert_eq!(key(&s), k2, "a comp it doesn't name"); + // Another comp taking the name changes what it may read. + s.edit("rename", None, |p, _| { + p.item_mut(third).unwrap().name = "Other".into(); Ok(()) }) .unwrap(); - // The file changes after the edit started the salt, before the same footage is opened again. - std::fs::write(&path, b"a longer content").unwrap(); - let same = (*s.project).clone(); - s.replace_project(same, None); - let t0 = std::time::Instant::now(); - while s.poll_disk_salt() { - assert!(t0.elapsed().as_secs() < 20, "the salt never landed"); - std::thread::sleep(std::time::Duration::from_millis(5)); + let k3 = key(&s); + assert_ne!(k3, k2, "a second comp named Other"); + // A computed name reaches every item. + expr(&mut s, ml, "comp(\"Oth\" + \"er\").layer(1).transform.opacity"); + let k4 = key(&s); + repath(&mut s, "/nowhere/d.png"); + assert_ne!(key(&s), k4, "unused footage counts with a computed name"); +} + +/// Markers count only for the comps whose expressions can read them (#1083): an expression in +/// another comp that reads markers leaves this comp's markers out of its key. +#[test] +fn markers_count_only_where_an_expression_can_read_them() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap(); + let cid = s.active_comp_id().unwrap(); + s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + let other = s.execute("comp.new", json!({"name": "N", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + let ol = s.execute("layer.newSolid", json!({"comp": other, "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.setExpression", json!({"comp": other, "layer": ol, "path": "transform/opacity", "expression": "thisComp.marker.numKeys"})).unwrap(); + let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, cid); + let k0 = key(&s); + s.execute("comp.addMarker", json!({"comp": cid.0, "time": 0.5, "comment": "beat"})).unwrap(); + assert_eq!(key(&s), k0, "no expression of M reads its markers"); + s.execute("prop.setExpression", json!({"comp": other, "layer": ol, "path": "transform/opacity", "expression": "comp(\"M\").marker.numKeys"})).unwrap(); + let other_key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, effectcraft_project::ItemId(other)); + let n0 = other_key(&s); + s.execute("comp.addMarker", json!({"comp": cid.0, "time": 1.0})).unwrap(); + assert_ne!(other_key(&s), n0, "N reads M's markers"); + assert_eq!(key(&s), k0, "M itself still doesn't"); +} + +/// File ▸ Reload Footage drops the disk entries made of the item's files (#3184): a file +/// replaced by one with the same size and modification time is read again (the check on hit +/// can't tell), and entries of other files stay. +#[test] +fn reload_footage_drops_the_entries_of_its_files() { + use crate::render::reads::{FileStamp, ReadFile, ReadFiles}; + struct Probe; + impl crate::Importer for Probe { + fn probe(&self, path: &str) -> std::result::Result { + Ok(effectcraft_project::Footage { + path: path.into(), + kind: effectcraft_project::FootageKind::Still, + width: 1, + height: 1, + has_video: true, + ..Default::default() + }) + } } - let now = effectcraft_render::disk_cache::footage_salt(&s.project); - assert_eq!(s.disk_salt.map(|(_, salt)| salt), Some(now), "the salt reflects the file as it is now"); - let _ = std::fs::remove_file(&path); + let dir = folder("reload"); + let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() }; + s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": &dir, "disk.diskCacheEnabled": true}})).unwrap(); + let src = std::path::Path::new(&dir).join("src"); + std::fs::create_dir_all(&src).unwrap(); + let mut add = |name: &str| { + let p = src.join(name); + std::fs::write(&p, b"aa").unwrap(); + let path = p.to_string_lossy().to_string(); + let f = effectcraft_project::Footage { + path: path.clone(), + kind: effectcraft_project::FootageKind::Still, + width: 1, + height: 1, + has_video: true, + ..Default::default() + }; + let id = s.edit("add", None, |p, _| Ok(p.add_item(name, effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f)))).unwrap(); + (id, path) + }; + let (a, a_path) = add("a.png"); + let (_, b_path) = add("b.png"); + let dc = s.disk_cache.clone().unwrap(); + let read = |p: &str| -> ReadFiles { [ReadFile::new(p, FileStamp::native(p).unwrap())].into_iter().collect() }; + dc.put_frame_now(1, 1, 1, &[1; 4], &read(&a_path)); + dc.put_frame_now(2, 1, 1, &[2; 4], &read(&b_path)); + dc.put_layer_now(3, &effectcraft_effects::Buf { img: effectcraft_raster::Image::new(1, 1), offset: [0.0; 2], scale: 1.0 }, &read(&a_path)); + // Other bytes, same size, the old time back. + let t = std::fs::metadata(&a_path).unwrap().modified().unwrap(); + std::fs::write(&a_path, b"AA").unwrap(); + std::fs::File::options().append(true).open(&a_path).unwrap().set_modified(t).unwrap(); + assert!(dc.contains(crate::render::disk_cache::Kind::Frame, 1)); + s.execute_checked("file.reloadFootage", json!({"item": a.0})).unwrap(); + dc.flush(); + assert!(dc.get_frame(1, None).is_none(), "the reloaded file's frame renders again"); + assert!(dc.get_layer(3, None).is_none(), "and its layer buffer"); + assert!(dc.get_frame(2, None).is_some(), "another file's frame stays"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Reload Footage while the disk cache is off (#3184): when it is switched on later in the +/// session, the entries made of the reloaded files are dropped before anything is read from it +/// (a file replaced by one with the same size and time would pass the check on hit); entries of +/// other files stay. +#[test] +fn reload_footage_while_the_disk_cache_is_off_drops_its_entries_when_it_is_on() { + use crate::render::disk_cache::{DiskCache, Kind}; + use crate::render::reads::{FileStamp, ReadFile, ReadFiles}; + struct Probe; + impl crate::Importer for Probe { + fn probe(&self, path: &str) -> std::result::Result { + Ok(effectcraft_project::Footage { + path: path.into(), + kind: effectcraft_project::FootageKind::Still, + width: 1, + height: 1, + has_video: true, + ..Default::default() + }) + } + } + let dir = folder("reload-off"); + let src = std::path::Path::new(&dir).join("src"); + std::fs::create_dir_all(&src).unwrap(); + let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() }; + let mut add = |name: &str| { + let p = src.join(name); + std::fs::write(&p, b"aa").unwrap(); + let path = p.to_string_lossy().to_string(); + let f = effectcraft_project::Footage { + path: path.clone(), + kind: effectcraft_project::FootageKind::Still, + width: 1, + height: 1, + has_video: true, + ..Default::default() + }; + let id = s.edit("add", None, |p, _| Ok(p.add_item(name, effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f)))).unwrap(); + (id, path) + }; + let (a, a_path) = add("a.png"); + let (_, b_path) = add("b.png"); + // Entries from an earlier session in the cache folder. + let read = |p: &str| -> ReadFiles { [ReadFile::new(p, FileStamp::native(p).unwrap())].into_iter().collect() }; + { + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + dc.put_frame_now(1, 1, 1, &[1; 4], &read(&a_path)); + dc.put_frame_now(2, 1, 1, &[2; 4], &read(&b_path)); + dc.put_layer_now(3, &effectcraft_effects::Buf { img: effectcraft_raster::Image::new(1, 1), offset: [0.0; 2], scale: 1.0 }, &read(&a_path)); + } + // Other bytes, same size, the old time back; reloaded while the cache is off. + let t = std::fs::metadata(&a_path).unwrap().modified().unwrap(); + std::fs::write(&a_path, b"AA").unwrap(); + std::fs::File::options().append(true).open(&a_path).unwrap().set_modified(t).unwrap(); + assert!(s.disk_cache.is_none()); + s.execute_checked("file.reloadFootage", json!({"item": a.0})).unwrap(); + // Switched on: the reloaded file's entries are misses from the start, and then gone. + s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": &dir, "disk.diskCacheEnabled": true}})).unwrap(); + let dc = s.disk_cache.clone().unwrap(); + assert!(dc.get_frame(1, None).is_none(), "the reloaded file's frame renders again"); + assert!(dc.get_layer(3, None).is_none(), "and its layer buffer"); + dc.flush(); + assert!(!dc.contains(Kind::Frame, 1) && !dc.contains(Kind::Layer, 3)); + assert!(dc.get_frame(2, None).is_some(), "another file's frame stays"); + assert!(s.pending_disk_drops.is_empty(), "applied once"); + drop(dc); + drop(s); + let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/engine/src/tests/rig3d.rs b/crates/engine/src/tests/rig3d.rs index db64a9066..634d84e02 100644 --- a/crates/engine/src/tests/rig3d.rs +++ b/crates/engine/src/tests/rig3d.rs @@ -40,7 +40,17 @@ fn close3(a: [f64; 3], b: [f64; 3], tol: f64) -> bool { fn frame(s: &Session, id: u64) -> (Vec3, Vec3) { let c = cid(s); let comp = s.project.comp(c).unwrap(); - let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &[], media_overrides: &[] }; + let ctx = EvalCtx { + project: &s.project, + comp_id: c, + comp, + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + reads: None, + }; let (e, f, _) = layer_frame(&ctx, comp.layer(LayerId(id)).unwrap()); (e, f) } @@ -244,7 +254,7 @@ fn parented_camera_keeps_its_frame_under_an_offset_null() { fn frame_at(s: &Session, id: u64, t: Tick) -> (Vec3, Vec3, Vec3) { let c = cid(s); let comp = s.project.comp(c).unwrap(); - let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None, property_overrides: &[], media_overrides: &[] }; + let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None, property_overrides: &[], media_overrides: &[], reads: None }; layer_frame(&ctx, comp.layer(LayerId(id)).unwrap()) } diff --git a/crates/engine/src/tests/sequence.rs b/crates/engine/src/tests/sequence.rs index c5faa232b..b084e6b36 100644 --- a/crates/engine/src/tests/sequence.rs +++ b/crates/engine/src/tests/sequence.rs @@ -269,30 +269,31 @@ fn dpx_and_cineon_runs_import_as_sequences() { assert_eq!(got, [("plate.[0001-0003].dpx".to_string(), 3), ("scan_[01-02].cin".to_string(), 2)], "{r}"); } -/// #1926: an edit that leaves the footage alone doesn't stamp every frame of its image sequences -/// again for the disk cache's salt (with large sequences each keystroke in a text field took over -/// half a second); Reload Footage does, and so does new footage. +/// #1926, #3184: an edit does no file work at all for the disk cache (it used to stamp every +/// frame of the project's image sequences for a salt; with large sequences each keystroke in a +/// text field took over half a second): the cache stays attached and no file is looked at, after +/// edits, after a frame of the sequence was rewritten, after Reload Footage and new footage. +/// Entries check the files they list when they are read instead. #[test] -fn edits_keep_the_disk_salt_and_reload_stamps_the_files_again() { +fn edits_do_no_disk_cache_file_work() { let dir = folder(&RUN); - let cache = std::env::temp_dir().join(format!("ec-seq-salt-{}", std::process::id())); + let cache = std::env::temp_dir().join(format!("ec-seq-nofiles-{}", std::process::id())); let _ = std::fs::remove_dir_all(&cache); let mut s = session(); s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": cache.to_string_lossy(), "disk.diskCacheEnabled": true}})).unwrap(); + let dc = s.disk_cache.clone().expect("a disk cache"); + let attached = |s: &Session| s.layer_cache.disk().is_some_and(|d| std::sync::Arc::ptr_eq(&d, &dc)); s.execute("comp.new", json!({"name": "C", "width": 64, "height": 32, "frameRate": 30, "duration": 1})).unwrap(); let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "sequence": true, "addToComp": true})).unwrap(); let item = r["items"][0].clone(); - let salt = s.refresh_disk_salt(false).expect("a disk cache"); // A frame rewritten in place: same footage settings, another size on disk. std::fs::write(at(&dir, "shot_0004.png"), b"a new render of this frame").unwrap(); s.execute("layer.newText", json!({"text": "title"})).unwrap(); s.execute("layer.setText", json!({"text": "title 2"})).unwrap(); - assert_eq!(s.refresh_disk_salt(false), Some(salt), "edits don't stamp the files again"); s.execute("file.reloadFootage", json!({"items": [item]})).unwrap(); - let reloaded = s.refresh_disk_salt(false).unwrap(); - assert_ne!(reloaded, salt, "Reload Footage stamps them again"); std::fs::write(at(&dir, "extra.png"), b"").unwrap(); s.execute("file.import", json!({"paths": [at(&dir, "extra.png")]})).unwrap(); - assert_ne!(s.refresh_disk_salt(false), Some(reloaded), "new footage counts"); + assert!(attached(&s), "attached all along"); + assert_eq!(dc.file_checks(), 0, "no file looked at for the disk cache"); let _ = std::fs::remove_dir_all(&cache); } diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index fa2993f97..43400ea1d 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -297,6 +297,7 @@ impl<'a> Resolver<'a> { } self.comp(c)?.layer(LayerId(l)) } + /// The evaluation context for comp `c` at time `t` seconds, or `None` when the comp does not exist. fn ctx_at(&self, c: u64, t: f64) -> Option> { let comp = self.comp(c)?; let same_comp = c == self.ctx.comp_id.0; @@ -309,6 +310,7 @@ impl<'a> Resolver<'a> { footage: self.ctx.footage, property_overrides: if same_comp { self.ctx.property_overrides } else { &[] }, media_overrides: if same_comp { self.ctx.media_overrides } else { &[] }, + reads: self.ctx.reads, }) } fn prop(&self, c: u64, l: u64, path: &str) -> Option<(&'a Layer, &'a Property)> { diff --git a/crates/expr/src/lib.rs b/crates/expr/src/lib.rs index 9e78d250f..ae9e647f8 100644 --- a/crates/expr/src/lib.rs +++ b/crates/expr/src/lib.rs @@ -172,6 +172,7 @@ fn evaluate_out( footage: ctx.footage, property_overrides: ctx.property_overrides, media_overrides: ctx.media_overrides, + reads: ctx.reads, }; let comp = ctx.comp_id.0; let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid)); diff --git a/crates/expr/src/sample.rs b/crates/expr/src/sample.rs index 28f830bb2..38f617289 100644 --- a/crates/expr/src/sample.rs +++ b/crates/expr/src/sample.rs @@ -4,13 +4,16 @@ //! expression (its footage source; [`EvalCtx::footage`]): source → masks → effects for //! `postEffect = true`, the bare source otherwise, in layer space (before the transform). Buffers //! are cached per thread for the frame, so many expressions sampling the same layer at the same -//! time render it once. +//! time render it once. The source files a sampled render reads are kept with its buffer and +//! noted in the evaluating render's log ([`EvalCtx::reads`]) on every use, so a frame whose +//! expressions sample footage lists that footage too (#3184). use std::cell::RefCell; use std::collections::HashMap; use std::sync::Arc; use effectcraft_project::{ItemId, Layer}; +use effectcraft_render::reads::{ReadFiles, ReadLog}; use effectcraft_render::{EvalCtx, NoFootage, RenderOpts, Renderer}; use effectcraft_time::Tick; @@ -26,7 +29,8 @@ struct Sampled { type Key = (usize, u64, u64, u64, bool, u64); thread_local! { - static CACHE: RefCell>> = RefCell::new(HashMap::new()); + /// The sampled buffers and the source files each was made of. + static CACHE: RefCell, Arc)>> = RefCell::new(HashMap::new()); /// Nested sampleImage renders (a sampled layer whose expressions sample again). static DEPTH: RefCell = const { RefCell::new(0) }; } @@ -42,13 +46,21 @@ fn fingerprint(layer: &Layer) -> u64 { h.finish() } +/// Renders `layer` of `comp` at time `t` for an expression sample, before or after effects as `post` says. +/// Returns `None` once the nesting depth limit is reached. fn render(ctx: EvalCtx, comp: ItemId, layer: &Layer, post: bool, t: f64) -> Option { let depth = DEPTH.with(|d| *d.borrow()); if depth >= MAX_DEPTH { return None; } let key: Key = (ctx.project as *const _ as usize, comp.0, layer.id.0, t.to_bits(), post, fingerprint(layer)); - if let Some(hit) = CACHE.with(|c| c.borrow().get(&key).cloned()) { + let note = |files: &ReadFiles| { + if let Some(log) = ctx.reads { + log.note_all(files); + } + }; + if let Some((hit, files)) = CACHE.with(|c| c.borrow().get(&key).cloned()) { + note(&files); return hit; } DEPTH.with(|d| *d.borrow_mut() += 1); @@ -60,20 +72,24 @@ fn render(ctx: EvalCtx, comp: ItemId, layer: &Layer, post: bool, t: f64) -> Opti } let _pop = Pop; let footage: &dyn effectcraft_render::FootageSource = ctx.footage.unwrap_or(&NoFootage); + let log = ReadLog::new(); let mut r = Renderer::new(ctx.project, footage, RenderOpts { motion_blur: false, ..Default::default() }); r.expr = ctx.expr; + r.reads = Some(&log); let ectx = r.eval_ctx(comp, Tick::from_seconds_f64(t))?; let out = if post { r.content_buf(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img.clone()), offset: b.offset, scale: b.scale }) } else { r.layer_source(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img), offset: b.offset, scale: b.scale }) }; + let files = Arc::new(log.into_files()); + note(&files); CACHE.with(|c| { let mut c = c.borrow_mut(); if c.len() >= MAX_CACHE { c.clear(); } - c.insert(key, out.clone()); + c.insert(key, (out.clone(), files)); }); out } diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index a7d2489c7..050a5a550 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1332,6 +1332,7 @@ fn source_dimensions_follow_media_replacement() { footage: None, property_overrides: &[], media_overrides: &[media], + reads: None, }; assert_eq!(ctx.value(l, l.props.prop(ROT).unwrap()), Value::Scalar(130.0)); } @@ -1554,6 +1555,7 @@ fn text_expression_selector_values() { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let doc = effectcraft_render::text::source_text(&ctx, l).unwrap(); let lay = effectcraft_text::layout_doc(&doc); @@ -1606,6 +1608,43 @@ fn sample_image_reads_rendered_pixels() { assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([-50, -50])"), 0.0), 0.0); } +/// The footage `sampleImage` renders is a file the evaluating render read (#3184): it is noted in +/// the render's log, also when the sampled buffer comes from the per-thread cache. +#[test] +fn sample_image_notes_the_files_it_reads() { + use effectcraft_render::reads::{FileStamp, ReadFile, ReadLog}; + /// A grey picture of any footage, noting `/clip.png` for every read. + struct Clip; + impl effectcraft_render::FootageSource for Clip { + fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option> { + let mut img = effectcraft_render::Image::new(f.width, f.height); + img.data.fill([0.5, 0.5, 0.5, 1.0]); + Some(std::sync::Arc::new(img)) + } + fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) { + f(); + reads.note(ReadFile::new("/clip.png", FileStamp::Native { len: 3, modified: Some(1) })); + } + } + let mut p = Project::default(); + let comp = Comp::new(20, 20, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let footage = + effectcraft_project::Footage { path: "/clip.png".into(), width: 8, height: 8, has_video: true, frame_rate: FrameRate::FPS_30, ..Default::default() }; + let item = p.add_item("Clip", Label::Aqua, None, ItemKind::Footage(footage)); + let layer = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item }, (8, 8), None); + let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let c = p.comp(cid).unwrap(); + for pass in ["rendered", "cached"] { + let log = ReadLog::new(); + let ctx = EvalCtx { footage: Some(&Clip), reads: Some(&log), ..EvalCtx::new(&p, cid, c, Tick::ZERO) }; + let px = crate::sample::sample_image(ctx, cid, &layer, [4.0, 4.0], [0.5, 0.5], false, 0.0).unwrap(); + assert!((px[0] - 0.5).abs() < 1e-3 && px[3] > 0.99, "{pass}: {px:?}"); + let files = log.into_files(); + assert_eq!(files.files().iter().map(|f| &*f.path).collect::>(), ["/clip.png"], "{pass}"); + assert!(files.is_complete(), "{pass}"); + } +} + /// A huge sample point saturates the pixel coordinates; it must not overflow the loop bounds. #[test] fn sample_image_huge_point_is_transparent() { diff --git a/crates/gpu/src/tests/adv3d.rs b/crates/gpu/src/tests/adv3d.rs index 00b1fee14..4914c4912 100644 --- a/crates/gpu/src/tests/adv3d.rs +++ b/crates/gpu/src/tests/adv3d.rs @@ -123,6 +123,7 @@ pub(crate) fn scene(p: &Project, cid: ItemId) -> Scene { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect(); adv::scene_of(&r, &ctx, &run, (ctx.comp.width, ctx.comp.height)) @@ -315,6 +316,7 @@ pub(crate) fn run_of(p: &Project, cid: ItemId) -> (EvalCtx<'_>, Vec<&effectcraft footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect(); (ctx, run) diff --git a/crates/host/src/tests.rs b/crates/host/src/tests.rs index f628ce4e4..55240698f 100644 --- a/crates/host/src/tests.rs +++ b/crates/host/src/tests.rs @@ -343,6 +343,7 @@ fn value(s: &effectcraft_engine::Session, l: u64, path: &str) -> effectcraft_eng footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; ctx.value(layer, layer.props.prop(path).unwrap()) } @@ -606,6 +607,7 @@ fn stereo_rig_expressions_follow_the_master_camera() { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; ctx.value(layer, layer.props.prop(path).unwrap()).as_vec3() }; @@ -624,3 +626,35 @@ fn stereo_rig_expressions_follow_the_master_camera() { assert!(close(v3(&s, "transform/position"), [300.0 - d, 180.0, -800.0]), "{:?}", v3(&s, "transform/position")); assert!(close(v3(&s, "transform/poi"), [300.0, 180.0, 0.0]), "{:?}", v3(&s, "transform/poi")); } + +/// A text layer showing a 3D model's name (`thisComp.layer("Model").source.name`): renaming the +/// model gives the comp a new disk key and the text shows the new name (expressions on). +#[test] +fn renaming_a_model_a_text_expression_reads_changes_the_key_and_the_text() { + use effectcraft_engine::Session; + let mut s = super::session(); + let cid = effectcraft_project::ItemId( + s.execute("comp.new", json!({"name": "M", "width": 160, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap(), + ); + let model = s + .edit("add model", None, |p, _| { + let f = effectcraft_project::Footage { kind: effectcraft_project::FootageKind::Model, ..Default::default() }; + let model = p.add_item("chair.glb", Default::default(), None, effectcraft_project::ItemKind::Footage(f)); + let comp = p.comp(cid).cloned().unwrap(); + let mut l = effectcraft_project::build::layer(p, &comp, "Model", effectcraft_project::LayerSource::Model { item: model }, (16, 16), None); + // (not drawn: no model file; an expression still reads its source) + l.switches.video = false; + p.comp_mut(cid).unwrap().layers.push(l); + Ok(model) + }) + .unwrap(); + let text = s.execute("layer.newText", json!({"comp": cid.0, "text": "x"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.setExpression", json!({"comp": cid.0, "layer": text, "path": "text/sourceText", "expression": "thisComp.layer(\"Model\").source.name"})) + .unwrap(); + let key = effectcraft_engine::render::disk_cache::comp_content_key(&s.project, cid); + let shown = |s: &mut Session| s.execute("prop.get", json!({"comp": cid.0, "layer": text, "path": "text/sourceText"})).unwrap()["evaluated"].to_string(); + assert!(shown(&mut s).contains("chair.glb"), "{}", shown(&mut s)); + s.execute("project.rename", json!({"item": model.0, "name": "table.glb"})).unwrap(); + assert_ne!(effectcraft_engine::render::disk_cache::comp_content_key(&s.project, cid), key, "the comp's frames on disk hold the old name"); + assert!(shown(&mut s).contains("table.glb"), "the text shows the new name: {}", shown(&mut s)); +} diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index a4070297b..4947d928a 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -36,3 +36,7 @@ rayon = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } + +[dev-dependencies] +# (tests render comps that read layers through effects; already a dependency of effectcraft-render) +effectcraft-effects = { workspace = true } diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index c3e7cfc3d..d0acf590f 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -17,6 +17,7 @@ use std::sync::{Arc, Mutex, OnceLock, PoisonError}; use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, split_channel}; use effectcraft_raster::{AuxChannels, Image}; +use effectcraft_render::reads::{ReadFile, ReadFiles}; use exr::block::UncompressedBlock; use exr::block::chunk::TileCoordinates; use exr::image::read::image::ReadImage; @@ -477,8 +478,8 @@ fn layered_picture_of(aux: &AuxChannels, op: AlphaOp) -> Option { /// decoded anyway), else of the picture's channels alone (also when the cache has no channels /// for the file: a read that failed, or a broken part the picture doesn't use). `Err(Some(why))` /// when the file is too large to decode, `Err(None)` when it isn't a readable multi-layer EXR. -pub(crate) fn layered_picture(path: &str, bytes: Arc<[u8]>, op: AlphaOp, shared: bool) -> Result> { - if shared && let Some(d) = cache().get(path, Use::Picture, || Some(bytes.clone())) { +pub(crate) fn layered_picture(path: &str, bytes: Arc<[u8]>, file: &ReadFile, op: AlphaOp, shared: bool) -> Result> { + if shared && let Some(d) = cache().get(path, Use::Picture, || read_as(file, bytes.clone())) { return layered_picture_of(&d.aux, op).ok_or(None); } let (d, rgb, a) = picture_only(&bytes).map_err(|r| match r { @@ -499,8 +500,8 @@ pub(crate) fn layered_picture(path: &str, bytes: Arc<[u8]>, op: AlphaOp, shared: /// decoder shows for a file it reads, [`layered_image`]'s rule for one it refuses. `None` when /// the planes can't stand in for the `image` crate's picture (decode with it), or the file is /// not a readable EXR. -pub(crate) fn cached_picture(path: &str, bytes: Arc<[u8]>, op: AlphaOp) -> Option { - let d = cache().get(path, Use::Picture, || Some(bytes))?; +pub(crate) fn cached_picture(path: &str, bytes: Arc<[u8]>, file: &ReadFile, op: AlphaOp) -> Option { + let d = cache().get(path, Use::Picture, || read_as(file, bytes))?; let aux = &d.aux; match d.rgb { Rgb::None => layered_picture_of(aux, op), @@ -536,8 +537,16 @@ const CACHE_FILES: usize = 64; /// rendered at once. The oldest stops being kept when another comes. const HELD_FILES: usize = 4; -/// A file's decode: done once, by the first caller; the others wait for it. -type Slot = Arc>>>; +/// A file's decode: done once, by the first caller; the others wait for it. With the files read +/// for it (see [`crate::reads`]), noted again on every hit. +type Slot = Arc>, ReadFiles)>>; + +/// Bytes the caller already read from `file`, given to the cache as read from it (so the entry +/// keeps the file's identity). +fn read_as(file: &ReadFile, bytes: Arc<[u8]>) -> Option> { + crate::reads::note(file.clone()); + Some(bytes) +} struct Entry { path: String, @@ -554,8 +563,8 @@ impl Entry { fn in_use(&self) -> bool { match self.slot.get() { None => true, - Some(None) => false, - Some(Some(d)) => Arc::strong_count(d) > 1 || Arc::strong_count(&d.aux) > 1, + Some((None, _)) => false, + Some((Some(d), _)) => Arc::strong_count(d) > 1 || Arc::strong_count(&d.aux) > 1, } } } @@ -648,30 +657,18 @@ impl ChannelCache { // is safe because the decode never waits on rayon's global pool (the exr crate // decompresses on a pool of its own and this thread only receives the blocks), so the // decoding thread can't be waiting for this one. - let v = slot - .get_or_init(|| { - decoded_here = true; - self.decodes.fetch_add(1, Ordering::Relaxed); - #[cfg(test)] - count_decode(true); - let _abandon = Abandon { cache: self, slot: &slot }; - let bytes = read()?; - // The planes it will allocate count against the budget while it decodes. - if let Ok(meta) = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(&bytes[..]), false) - && let Some(need) = planes_bytes(&meta, &|_| true) - { - self.account(&slot, need.min(MAX_DECODE_BYTES)); - } - match decode(&bytes, &|_| true) { - Ok(d) => Some(Arc::new(d)), - Err(Refused::TooLarge(why)) => { - log::warn!("media: {path}: {why}"); - None - } - Err(Refused::Unreadable) => None, - } - }) - .clone(); + let (v, files) = slot.get_or_init(|| { + decoded_here = true; + self.decodes.fetch_add(1, Ordering::Relaxed); + #[cfg(test)] + count_decode(true); + let _abandon = Abandon { cache: self, slot: &slot }; + let (bytes, files) = crate::reads::captured(read); + (bytes.and_then(|bytes| self.decode_file(path, &slot, &bytes)), files) + }); + // The file(s) the decode was made of, read now or for an earlier caller. + crate::reads::note_all(files); + let v = v.clone(); if decoded_here { self.account(&slot, v.as_ref().map_or(0, |d| d.bytes())); } @@ -692,6 +689,24 @@ impl ChannelCache { v } + /// Decode the file at `path` whose bytes are `bytes` for `slot` (see [`ChannelCache::get`]). + fn decode_file(&self, path: &str, slot: &Slot, bytes: &[u8]) -> Option> { + // The planes it will allocate count against the budget while it decodes. + if let Ok(meta) = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(bytes), false) + && let Some(need) = planes_bytes(&meta, &|_| true) + { + self.account(slot, need.min(MAX_DECODE_BYTES)); + } + match decode(bytes, &|_| true) { + Ok(d) => Some(Arc::new(d)), + Err(Refused::TooLarge(why)) => { + log::warn!("media: {path}: {why}"); + None + } + Err(Refused::Unreadable) => None, + } + } + /// Drop least recently used files not in use until the rest fit the budget, keeping `newest`. fn evict(entries: &mut Vec, budget: usize, newest: &Slot) { while entries.iter().map(|e| e.bytes).sum::() > budget || entries.len() > CACHE_FILES { @@ -996,7 +1011,7 @@ mod tests { let path = "/474-shared-fallback.exr"; assert!(cache().get(path, Use::Channels, || None).is_none(), "a read that failed"); let op = AlphaOp::of(&Footage::default(), FloatCurve::Linear); - let img = layered_picture(path, bytes, op, true).unwrap(); + let img = layered_picture(path, bytes, &ReadFile::new(path, effectcraft_render::reads::FileStamp::Virtual), op, true).unwrap(); assert_eq!(img.get(1, 1), [0.5, 0.5, 0.5, 1.0]); } @@ -1174,7 +1189,8 @@ mod tests { for curve in [FloatCurve::Srgb, FloatCurve::Linear, FloatCurve::Gamma24] { let op = AlphaOp::of(&Footage { alpha, premul_color: [0.2, 0.4, 0.6], ..Default::default() }, curve); let want = crate::convert::dynamic_to_image(&img, op); - let got = cached_picture(&path, bytes.as_slice().into(), op).unwrap(); + let got = cached_picture(&path, bytes.as_slice().into(), &ReadFile::new(path.as_str(), effectcraft_render::reads::FileStamp::Virtual), op) + .unwrap(); assert_eq!((got.width, got.height), (want.width, want.height)); assert_eq!(bits(&got), bits(&want), "file {k} {alpha:?} {curve:?}"); } diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 31478f111..0b509c45f 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -25,6 +25,7 @@ mod layered; pub use layered::vector_doc; mod pool; mod probe; +mod reads; mod stream; mod wav_markers; mod webp_anim; @@ -85,7 +86,15 @@ pub fn is_importable(path: &std::path::Path) -> bool { /// A whole file's bytes, refusing anything that isn't a regular file: a project can name /// `/dev/zero` or a FIFO as footage, and reading those never ends (#2134). pub(crate) fn read_regular_file(path: &std::path::Path) -> std::io::Result> { + read_regular_file_stamped(path).map(|(bytes, _)| bytes) +} + +/// [`read_regular_file`] with the file's identity, from the metadata of the open file (no +/// second look at the path). +pub(crate) fn read_regular_file_stamped(path: &std::path::Path) -> std::io::Result<(Vec, effectcraft_render::reads::FileStamp)> { use std::io::Read; + #[cfg(test)] + count_open(path); let mut file = std::fs::File::open(path)?; let meta = file.metadata()?; if !meta.is_file() { @@ -95,7 +104,22 @@ pub(crate) fn read_regular_file(path: &std::path::Path) -> std::io::Result> = std::sync::Mutex::new(Vec::new()); + +/// Count an open of a file (tests: which files a render touched on disk). +#[cfg(test)] +pub(crate) fn count_open(path: &std::path::Path) { + OPENS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_path_buf()); +} + +/// How many times files under `dir` were opened so far (tests). +#[cfg(test)] +pub(crate) fn opens_under(dir: &std::path::Path) -> usize { + OPENS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).iter().filter(|p| p.starts_with(dir)).count() } pub(crate) fn ext_of(path: &std::path::Path) -> String { diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 9e10b4eb3..a60048b66 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -6,6 +6,7 @@ use std::sync::{Arc, Condvar, Mutex, OnceLock}; use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId}; use effectcraft_raster::{Image, Px}; +use effectcraft_render::reads::{FileStamp, ReadFile, ReadFiles, ReadLog}; use effectcraft_render::{FloatCurve, FootageSource}; use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; use filmcraft_media::{FrameRequest, SharedSource}; @@ -14,6 +15,7 @@ use image::metadata::Orientation; use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled, turned}; use crate::downmix::Order; use crate::gif_anim::{self, Anim}; +use crate::reads; use crate::{MediaError, Result}; /// Default frame-cache budget: 1 GiB of decoded frames. @@ -53,6 +55,8 @@ struct Key { struct Entry { img: Arc, + /// The files the frame was decoded from (see [`crate::reads`]). + files: Arc, stamp: u64, bytes: usize, } @@ -106,18 +110,20 @@ struct Lru { } impl Lru { - fn get(&mut self, k: &Key) -> Option> { + /// The cached image and its read files for `k`, marking the entry as most recently used. + fn get(&mut self, k: &Key) -> Option<(Arc, Arc)> { let e = self.map.get_mut(k)?; self.order.remove(&e.stamp); self.clock += 1; e.stamp = self.clock; self.order.insert(e.stamp, k.clone()); - Some(e.img.clone()) + Some((e.img.clone(), e.files.clone())) } - fn insert(&mut self, k: Key, img: Arc, budget: usize) { + /// Stores an image with its read files under `k` and evicts the least recently used entries until the cache fits `budget` bytes. + fn insert(&mut self, k: Key, img: Arc, files: Arc, budget: usize) { let bytes = img.data.len() * std::mem::size_of::() + 64; self.clock += 1; - if let Some(old) = self.map.insert(k.clone(), Entry { img, stamp: self.clock, bytes }) { + if let Some(old) = self.map.insert(k.clone(), Entry { img, files, stamp: self.clock, bytes }) { self.order.remove(&old.stamp); self.bytes -= old.bytes; } @@ -180,8 +186,9 @@ struct CacheState { } /// A movie/audio source by path ([`Opened`]), opened by the first caller while the others wait -/// for it (`None`: failed to open; not retried until `forget`). -type SourceSlot = Arc>>; +/// for it (`None`: failed to open; not retried until `forget`), with the file's identity as +/// opened. +type SourceSlot = Arc, ReadFile)>>; /// An opened movie/audio source, the turn its picture is shown with and the order its decoded /// audio channels arrive in. @@ -211,7 +218,7 @@ fn channel_order(src: &SharedSource, header: Option<&[u8]>, file: Option<&dyn fi } /// An animated GIF by path, as [`SourceSlot`] (the error message when it failed to load). -type GifSlot = Arc, String>>>; +type GifSlot = Arc, String>, ReadFile)>>; struct Inner { /// Opened movie/audio sources by path. @@ -220,8 +227,9 @@ struct Inner { opened: AtomicU64, /// In-memory files registered with `add_bytes` (web builds, tests). files: Mutex>>, - /// Parsed 3D models by path (`None`: failed to load; not retried until `forget`). - models: Mutex>>>, + /// Parsed 3D models by path (`None`: failed to load; not retried until `forget`), with the + /// files read for them (the model's and the resources it names). + models: Mutex>, Arc)>>, /// Decoded animated GIFs and WebPs by path, loaded by the first caller while the others wait (an /// `Err` is not retried until `forget`). gifs: Mutex, GifSlot>>, @@ -439,11 +447,13 @@ impl MediaPool { } /// The conformed-audio file of `path` at `rate` (named by a hash of the path, size and - /// modification time, so an edited file conforms again, and of `CONFORM_VERSION`). + /// modification time, so an edited file conforms again, and of `CONFORM_VERSION`). Notes + /// `path` as read with that identity (see [`crate::reads`]): its samples come from it. pub fn conformed_path(&self, path: &str, rate: u32) -> Option { use std::hash::{Hash, Hasher}; let folder = lock(&self.inner.conform).clone()?; let meta = std::fs::metadata(path).ok()?; + reads::note(ReadFile::new(path, FileStamp::of_metadata(&meta))); let mut h = std::collections::hash_map::DefaultHasher::new(); (CONFORM_VERSION, path, meta.len(), meta.modified().ok()).hash(&mut h); Some(folder.join(format!("{:016x}_{rate}.ecaf", h.finish()))) @@ -612,16 +622,20 @@ impl Inner { /// A 3D model file and its sibling resources (buffers, textures, MTL files), parsed once. fn model(&self, path: &str) -> Option> { - if let Some(m) = lock(&self.models).get(path) { - return m.clone(); + let found = lock(&self.models).get(path).cloned(); + if let Some((m, files)) = found { + reads::note_all(&files); + return m; } - let loaded = self.read(path).map_err(|e| e.to_string()).and_then(|bytes| { - // Files the model names are read only when local or on the model's own network share. - let resolve = |uri: &str| -> Option> { - let p = craft_docpath::resolve_absolute(uri, Some(std::path::Path::new(path))).ok()?; - self.read(&p.to_string_lossy()).ok().map(|b| b.to_vec()) - }; - effectcraft_model::load(path, &bytes, &resolve).map_err(|e| e.to_string()) + let (loaded, files) = reads::captured(|| { + self.read(path).map_err(|e| e.to_string()).and_then(|bytes| { + // Files the model names are read only when local or on the model's own network share. + let resolve = |uri: &str| -> Option> { + let p = craft_docpath::resolve_absolute(uri, Some(std::path::Path::new(path))).ok()?; + self.read(&p.to_string_lossy()).ok().map(|b| b.to_vec()) + }; + effectcraft_model::load(path, &bytes, &resolve).map_err(|e| e.to_string()) + }) }); let m = match loaded { Ok(m) => Some(Arc::new(m)), @@ -630,14 +644,29 @@ impl Inner { None } }; - lock(&self.models).entry(path.to_string()).or_insert(m).clone() + let (m, files) = lock(&self.models).entry(path.to_string()).or_insert((m, Arc::new(files))).clone(); + reads::note_all(&files); + m } + /// The bytes of the file at `path`, noted as read (see [`crate::reads`]). fn read(&self, path: &str) -> Result> { - if let Some(b) = lock(&self.files).get(path) { - return Ok(b.clone()); + let (bytes, file) = self.read_file(path); + reads::note(file); + bytes + } + + /// The bytes of the file at `path` and its identity as read ([`FileStamp::Missing`] when it + /// can't be read), not noted. + fn read_file(&self, path: &str) -> (Result>, ReadFile) { + let in_memory = lock(&self.files).get(path).cloned(); + if let Some(b) = in_memory { + return (Ok(b), ReadFile::new(path, FileStamp::Virtual)); + } + match crate::read_regular_file_stamped(std::path::Path::new(path)) { + Ok((bytes, stamp)) => (Ok(bytes.into()), ReadFile::new(path, stamp)), + Err(e) => (Err(MediaError::Io(format!("{path}: {e}"))), ReadFile::new(path, FileStamp::Missing)), } - crate::read_regular_file(std::path::Path::new(path)).map(Into::into).map_err(|e| MediaError::Io(format!("{path}: {e}"))) } /// The opened movie/audio source for `path` ([`Opened`]). The first caller opens it, outside @@ -645,35 +674,42 @@ impl Inner { /// files are decoded whole); callers asking for the same file meanwhile wait for that instead /// of opening it again: a preview's frame workers, audio feeder, conform and waveform threads /// each decoded a long FLAC in full at once (#408). + /// Each call notes the file as read (see [`crate::reads`]), with its identity as opened. fn opened(&self, path: &Arc) -> Option { let slot = lock(&self.sources).entry(path.clone()).or_default().clone(); - slot.get_or_init(|| { - self.opened.fetch_add(1, Ordering::Relaxed); - // Files on disk are read in place (the index, then samples on demand) rather than - // whole; in-memory files (`add_bytes`) and wasm32 builds open from bytes. - let in_memory = lock(&self.files).contains_key(&**path); - let file = std::path::Path::new(&**path); - let in_place = if in_memory { None } else { crate::stream::open_source(file) }; - let in_place = in_place.map(|src| { - let (header, reader) = (crate::stream::movie_header(file), crate::stream::file_reader(file)); - src.map(|src| Opened::new(src, header.as_deref(), reader.as_deref())) + let (opened, file) = slot.get_or_init(|| self.open(path)); + reads::note(file.clone()); + opened.clone() + } + + /// Open the movie/audio file at `path` (see [`Inner::opened`]), with its identity as opened. + fn open(&self, path: &Arc) -> (Option, ReadFile) { + self.opened.fetch_add(1, Ordering::Relaxed); + // Files on disk are read in place (the index, then samples on demand) rather than + // whole; in-memory files (`add_bytes`) and wasm32 builds open from bytes. + let in_memory = lock(&self.files).contains_key(&**path); + let file = std::path::Path::new(&**path); + let in_place = if in_memory { None } else { crate::stream::open_source(file) }; + let in_place = in_place.map(|(src, stamp)| { + let (header, reader) = (crate::stream::movie_header(file), crate::stream::file_reader(file)); + (src.map(|src| Opened::new(src, header.as_deref(), reader.as_deref())), ReadFile::new(path.clone(), stamp)) + }); + let (opened, read) = in_place.unwrap_or_else(|| { + let (bytes, read) = self.read_file(path); + let opened = bytes.and_then(|bytes| { + let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let src = filmcraft_codecs::open_bytes(&name, bytes.clone()).map_err(MediaError::from)?; + Ok(Opened::new(src, Some(&bytes), Some(&filmcraft_media::reader::MemReader(bytes.clone())))) }); - let opened = in_place.unwrap_or_else(|| { - self.read(path).and_then(|bytes| { - let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let src = filmcraft_codecs::open_bytes(&name, bytes.clone()).map_err(MediaError::from)?; - Ok(Opened::new(src, Some(&bytes), Some(&filmcraft_media::reader::MemReader(bytes.clone())))) - }) - }); - match opened { - Ok(s) => Some(s), - Err(e) => { - self.note_problem(path, "", &e); - None - } + (opened, read) + }); + match opened { + Ok(s) => (Some(s), read), + Err(e) => { + self.note_problem(path, "", &e); + (None, read) } - }) - .clone() + } } /// Remember why the movie at `path` cannot be shown (`codec` is the footage's, when known) @@ -694,13 +730,15 @@ impl Inner { /// map's lock; callers asking for the same file meanwhile wait for that. fn gif(&self, path: &Arc) -> Result> { let slot = lock(&self.gifs).entry(path.clone()).or_default().clone(); - let loaded = slot.get_or_init(|| { - let loaded = self.read(path).and_then(|bytes| Anim::load(&bytes)); - loaded.map(Arc::new).map_err(|e| { + let (loaded, file) = slot.get_or_init(|| { + let (bytes, file) = self.read_file(path); + let loaded = bytes.and_then(|bytes| Anim::load(&bytes)).map(Arc::new).map_err(|e| { log::warn!("media: cannot decode {path}: {e}"); e.to_string() - }) + }); + (loaded, file) }); + reads::note(file.clone()); loaded.clone().map_err(|e| MediaError::Decode(format!("{path}: {e}"))) } @@ -783,19 +821,15 @@ impl Inner { let mut c = lock(&self.cache); // sequential playback of a movie: read the next frame ahead if playback && movie { - let prev = c.last.insert(loc.key.path.clone(), loc.key.frame); - if prev == Some(loc.key.frame - 1) && self.read_ahead.load(Ordering::Relaxed) { - let next_t = t + footage.frame_rate.frame_duration(); - let mut next = Self::locate(footage, next_t, float); - next.key.size = loc.key.size; - if next.key != loc.key && !c.lru.map.contains_key(&next.key) && !c.inflight.contains(&next.key) { - self.prefetch(footage, next_t, size, float); - } - } + self.prefetch_next(&mut c, &loc, footage, t, size, float); } + // (Each exit notes the files the frame came from, see `crate::reads`; a cached frame + // notes those it was decoded from, without touching them.) loop { - if let Some(img) = c.lru.get(&loc.key) { + if let Some((img, files)) = c.lru.get(&loc.key) { self.hits.fetch_add(1, Ordering::Relaxed); + drop(c); + reads::note_all(&files); return Ok(img); } if !c.inflight.contains(&loc.key) { @@ -808,7 +842,9 @@ impl Inner { if rayon::current_thread_index().is_some() { self.misses.fetch_add(1, Ordering::Relaxed); drop(c); - return Ok(Arc::new(self.decode(&loc, footage)?)); + let (img, files) = reads::captured(|| self.decode(&loc, footage)); + reads::note_all(&files); + return Ok(Arc::new(img?)); } c = self.done.wait(c).unwrap_or_else(std::sync::PoisonError::into_inner); } @@ -816,11 +852,26 @@ impl Inner { c.inflight.insert(loc.key.clone()); drop(c); let _guard = Inflight { inner: self, key: loc.key.clone() }; - let img = Arc::new(self.decode(&loc, footage)?); - lock(&self.cache).lru.insert(loc.key, img.clone(), self.budget()); + let (img, files) = reads::captured(|| self.decode(&loc, footage)); + reads::note_all(&files); + let img = Arc::new(img?); + lock(&self.cache).lru.insert(loc.key, img.clone(), Arc::new(files), self.budget()); Ok(img) } + /// Sequential playback of a movie (`loc`, at `t`): read the frame after it ahead. + fn prefetch_next(self: &Arc, c: &mut CacheState, loc: &Loc, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) { + let prev = c.last.insert(loc.key.path.clone(), loc.key.frame); + if prev == Some(loc.key.frame - 1) && self.read_ahead.load(Ordering::Relaxed) { + let next_t = t + footage.frame_rate.frame_duration(); + let mut next = Self::locate(footage, next_t, float); + next.key.size = loc.key.size; + if next.key != loc.key && !c.lru.map.contains_key(&next.key) && !c.inflight.contains(&next.key) { + self.prefetch(footage, next_t, size, float); + } + } + } + #[cfg(not(target_arch = "wasm32"))] fn prefetch(self: &Arc, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) { let (me, f) = (self.clone(), footage.clone()); @@ -856,13 +907,17 @@ impl Inner { } } + /// Decodes the still image at `loc` and applies the footage alpha and float handling. + /// A placeholder path decodes to colour bars. fn decode_still(&self, loc: &Loc, footage: &Footage) -> Result { let op = AlphaOp::of(footage, loc.float); let path = &loc.key.path; if &**path == PLACEHOLDER { return Ok(colour_bars(footage.width, footage.height)); } - let bytes = self.read(path)?; + let (bytes, file) = self.read_file(path); + reads::note(file.clone()); + let bytes = bytes?; if let Some(img) = crate::dpx::decode(path, &bytes)? { return Ok(dynamic_to_image(&img, op)); } @@ -880,7 +935,7 @@ impl Inner { let shared = lock(&self.aux_footage).contains(&footage.path); if shared && path.to_ascii_lowercase().ends_with(".exr") - && let Some(img) = crate::exr_channels::cached_picture(path, bytes.clone(), op) + && let Some(img) = crate::exr_channels::cached_picture(path, bytes.clone(), &file, op) { return Ok(img); } @@ -889,7 +944,7 @@ impl Inner { match crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes)) { Ok(img) => Ok(dynamic_to_image(&img, op)), // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer. - Err(e) => crate::exr_channels::layered_picture(path, bytes, op, shared) + Err(e) => crate::exr_channels::layered_picture(path, bytes, &file, op, shared) .map_err(|why| MediaError::Decode(format!("{path}: {}", why.unwrap_or_else(|| e.to_string())))), } } @@ -957,6 +1012,13 @@ impl FootageSource for MediaPool { fn cancellable(&self, cancel: &Arc, f: &mut dyn FnMut()) { filmcraft_media::cancel::with_cancel(cancel, f) } + /// Every read of a file notes it (see [`crate::reads`]): a frame's image file or sequence + /// frame, a movie, a model and the files it names, an EXR's channels, a movie's audio. A + /// frame (or other entry) served from memory notes the files it was made of, as they were + /// when read, without reading them again. A missing sequence frame (colour bars) reads none. + fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) { + reads::recording(reads, f) + } fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option> { self.frame_sized(item, footage, t, None, FloatCurve::Srgb) } @@ -1207,13 +1269,13 @@ mod tests { let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3], size: (0, 0), nominal: (0, 0) }; let budget = 3 * (4096 + 64); for i in 0..10 { - c.insert(k(i), img(), budget); + c.insert(k(i), img(), Arc::default(), budget); } assert_eq!(c.map.len(), 3); assert!(c.get(&k(9)).is_some() && c.get(&k(6)).is_none()); // touching 7 makes 8 the oldest c.get(&k(7)); - c.insert(k(10), img(), budget); + c.insert(k(10), img(), Arc::default(), budget); assert!(c.get(&k(7)).is_some() && c.get(&k(8)).is_none()); assert_eq!(c.spare.len(), MAX_SPARE); assert_eq!(c.take_spare(256).len(), 256); diff --git a/crates/media/src/reads.rs b/crates/media/src/reads.rs new file mode 100644 index 000000000..43ea209b8 --- /dev/null +++ b/crates/media/src/reads.rs @@ -0,0 +1,120 @@ +//! Which files the [`MediaPool`](crate::MediaPool) reads for a render +//! ([`FootageSource::recording`](effectcraft_render::FootageSource::recording)). +//! +//! Every read of a file notes it, with the identity it had when read, in the innermost scope open +//! on this thread. The pool's caches (decoded frames, opened movies, GIFs, models, EXR channels) +//! keep what their entries were made of and note it again on each hit, so a frame served from +//! memory reports its file without touching the disk. +//! +//! The scopes are per thread: a read noted while a render's scope is open on the thread belongs +//! to that render. A thread waiting inside a decode can run another job meanwhile (rayon); that +//! job's reads go to its own scope when it opens one, and otherwise to the scope below it, which +//! then lists a file more than it read (never fewer). + +use std::cell::RefCell; + +use effectcraft_render::reads::{ReadFile, ReadFiles, ReadLog}; + +/// Scopes nested deeper than this are not opened (a thread that keeps taking jobs while it waits +/// cannot pile them up without bound): their reads go to the scope below, and they come back +/// incomplete. +const MAX_DEPTH: usize = 64; + +thread_local! { + /// The open scopes on this thread, innermost last, each with the files noted in it. + static SCOPES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// Note `file` in the innermost open scope on this thread (nothing when none is open). +pub(crate) fn note(file: ReadFile) { + let _ = SCOPES.try_with(|s| { + if let Ok(mut s) = s.try_borrow_mut() + && let Some(top) = s.last_mut() + { + top.add(file); + } + }); +} + +/// Note every file of `files` in the innermost open scope on this thread. +pub(crate) fn note_all(files: &ReadFiles) { + if files.is_empty() && files.is_complete() { + return; + } + let _ = SCOPES.try_with(|s| { + if let Ok(mut s) = s.try_borrow_mut() + && let Some(top) = s.last_mut() + { + top.extend(files); + } + }); +} + +/// Closes the scope it opened, also when what runs inside panics: its files go to `out`. +struct Scope<'a> { + out: Out<'a>, + opened: bool, +} + +enum Out<'a> { + /// A render's log ([`recording`]). + Log(&'a ReadLog), + /// The caller's ([`captured`]). + Files(&'a mut ReadFiles), +} + +impl Scope<'_> { + /// Starts collecting the files read on this thread into `out`. + /// At the nesting limit the scope stays closed and reports an incomplete list. + fn open(out: Out<'_>) -> Scope<'_> { + let opened = SCOPES + .try_with(|s| match s.try_borrow_mut() { + Ok(mut s) if s.len() < MAX_DEPTH => { + s.push(ReadFiles::default()); + true + } + _ => false, + }) + .unwrap_or(false); + Scope { out, opened } + } +} + +impl Drop for Scope<'_> { + /// Ends the scope and hands the collected files to its output, marked incomplete when the scope could not be opened. + fn drop(&mut self) { + let files = if self.opened { SCOPES.try_with(|s| s.try_borrow_mut().ok().and_then(|mut s| s.pop())).ok().flatten() } else { None }; + // A scope that could not open saw nothing: what it made can't name its files. + let files = files.unwrap_or_else(|| { + let mut f = ReadFiles::default(); + f.mark_incomplete(); + f + }); + match &mut self.out { + Out::Log(log) => log.note_all(&files), + Out::Files(out) => out.extend(&files), + } + } +} + +/// Run `f` with the files it reads on this thread noted in `log` +/// ([`FootageSource::recording`](effectcraft_render::FootageSource::recording)). +pub(crate) fn recording(log: &ReadLog, f: &mut dyn FnMut()) { + let _scope = Scope::open(Out::Log(log)); + f(); +} + +/// Run `f` and return what it returns with the files it read on this thread (to keep with a +/// cache entry made of them). They are not noted in the enclosing scope: the caller notes them, +/// as it does on each later hit of the entry. +pub(crate) fn captured(f: impl FnOnce() -> T) -> (T, ReadFiles) { + let mut files = ReadFiles::default(); + let out = { + let _scope = Scope::open(Out::Files(&mut files)); + f() + }; + (out, files) +} + +#[cfg(test)] +mod tests; diff --git a/crates/media/src/reads/tests.rs b/crates/media/src/reads/tests.rs new file mode 100644 index 000000000..5dae8415a --- /dev/null +++ b/crates/media/src/reads/tests.rs @@ -0,0 +1,390 @@ +//! The files a render reads, as the media pool reports them (#3184): exactly the files the frame +//! was made of, also when its pixels come from memory. + +use std::path::{Path, PathBuf}; + +use effectcraft_color::Label; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerSource, Project, Solid, Value}; +use effectcraft_render::reads::{FileStamp, ReadFiles, ReadLog}; +use effectcraft_render::{LayerCache, RenderOpts, Renderer}; +use effectcraft_time::FrameRate; + +use crate::MediaPool; + +const RATE: FrameRate = FrameRate::FPS_30; +const SIZE: (u32, u32) = (16, 8); + +/// A fresh folder for test `name` (on the build drive, not the system temp folder). +fn folder(name: &str) -> PathBuf { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("../../target/test-reads-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A PNG at `path`, a flat grey of `level`. +fn png(path: &Path, level: u8) -> String { + image::RgbaImage::from_pixel(SIZE.0, SIZE.1, image::Rgba([level, level, level, 255])).save(path).unwrap(); + path.to_string_lossy().to_string() +} + +/// An image sequence of `n` PNG frames in `dir`. +fn sequence(dir: &Path, n: usize) -> Footage { + let files: Vec = (0..n).map(|i| png(&dir.join(format!("seq_{i:04}.png")), 20 + i as u8 * 10)).collect(); + let mut f = Footage { kind: FootageKind::Sequence, sequence: files, frame_rate: RATE, ..crate::probe(dir.join("seq_0000.png")).unwrap() }; + f.sync_sequence_duration(); + f +} + +/// A still footage item of `path`. +fn still(path: &Path, level: u8) -> Footage { + png(path, level); + crate::probe(path).unwrap() +} + +fn comp() -> Comp { + Comp::new(SIZE.0, SIZE.1, RATE, RATE.tick_of(10)) +} + +fn footage_layer(p: &mut Project, comp: &Comp, name: &str, f: Footage) -> Layer { + let item = p.add_item(name, Label::Aqua, None, ItemKind::Footage(f)); + build::layer(p, comp, name, LayerSource::Footage { item }, SIZE, Some(comp.duration)) +} + +fn add_comp(p: &mut Project, name: &str, mut comp: Comp, layers: Vec) -> ItemId { + comp.layers = layers.into(); + p.add_item(name, Label::Sandstone, None, ItemKind::Comp(comp.into())) +} + +/// Render frame `frame` of `comp` with `pool` and `cache`: the picture's top-left pixel and the +/// files the render reports. +fn render(p: &Project, pool: &MediaPool, cache: &LayerCache, comp: ItemId, frame: i64) -> ([f32; 4], ReadFiles) { + let log = ReadLog::new(); + let mut r = Renderer::new(p, pool, RenderOpts::default()); + r.cache = Some(cache); + r.reads = Some(&log); + let img = r.comp_frame(comp, RATE.tick_of(frame)); + (img.get(0, 0), log.into_files()) +} + +fn paths(files: &ReadFiles) -> Vec { + files.files().iter().map(|f| f.path.to_string()).collect() +} + +/// Every listed file has the identity it has on disk now. +fn assert_stamps_match_disk(files: &ReadFiles) { + for f in files.files() { + assert_eq!(Some(f.stamp), FileStamp::native(&f.path), "{}", f.path); + } +} + +/// (a) One frame of a comp with an image-sequence layer reads that frame's file and nothing else +/// of the sequence; a large sequence in the project that no layer uses adds nothing, not even a +/// look at its files. +#[test] +fn a_sequence_frame_reports_only_its_own_file() { + let dir = folder("sequence"); + let unused_dir = dir.join("unused"); + let mut p = Project::default(); + let c = comp(); + let seq = sequence(&dir, 5); + let (frame_2, frame_4) = (seq.sequence[2].clone(), seq.sequence[4].clone()); + let layer = footage_layer(&mut p, &c, "Seq", seq); + // 5000 frames nobody shows (they don't even exist). + let unused = Footage { + kind: FootageKind::Sequence, + sequence: (0..5000).map(|i| unused_dir.join(format!("big_{i:05}.exr")).to_string_lossy().to_string()).collect(), + frame_rate: RATE, + width: SIZE.0, + height: SIZE.1, + has_video: true, + ..Default::default() + }; + p.add_item("Unused", Label::Aqua, None, ItemKind::Footage(unused)); + let cid = add_comp(&mut p, "C", c, vec![layer]); + let (pool, cache) = (MediaPool::new(), LayerCache::default()); + let opened = crate::opens_under(&dir); + let (px, files) = render(&p, &pool, &cache, cid, 2); + assert!((px[0] - 40.0 / 255.0).abs() < 0.01, "frame 2 shows: {px:?}"); + assert_eq!(paths(&files), [frame_2]); + assert!(files.is_complete()); + assert_stamps_match_disk(&files); + assert_eq!(crate::opens_under(&dir) - opened, 1, "one file opened: the frame's"); + assert_eq!(crate::opens_under(&unused_dir), 0, "the unused sequence was not looked at"); + // Another frame reads its own file, also through the viewer's wrapper of the pool. + let viewer = effectcraft_render::CancellableFootage { inner: &pool, cancel: Default::default() }; + let log = ReadLog::new(); + let mut r = Renderer::new(&p, &viewer, RenderOpts::default()); + r.cache = Some(&cache); + r.reads = Some(&log); + r.comp_frame(cid, RATE.tick_of(4)); + let files = log.into_files(); + assert_eq!(paths(&files), [frame_4]); + assert!(files.is_complete()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// (b) A precomp reports the files of what it draws, and an effect that reads another layer +/// reports that layer's files (the layer itself is hidden: its pixels come only through the +/// effect). +#[test] +fn precomps_and_layers_read_by_effects_report_their_files() { + let dir = folder("nested"); + let mut p = Project::default(); + let c = comp(); + let seq = sequence(&dir, 4); + let nested_frame = seq.sequence[3].clone(); + let inner_layer = footage_layer(&mut p, &c, "Seq", seq); + let inner = add_comp(&mut p, "Inner", c.clone(), vec![inner_layer]); + let pre = build::layer(&mut p, &c, "Inner", LayerSource::Comp { item: inner }, SIZE, Some(c.duration)); + let matte_path = dir.join("matte.png"); + let mut matte = footage_layer(&mut p, &c, "Matte", still(&matte_path, 255)); + matte.switches.video = false; + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: SIZE.0, height: SIZE.1, pixel_aspect: 1.0 })); + let mut solid = build::layer(&mut p, &c, "S", LayerSource::Solid { item: sid }, SIZE, Some(c.duration)); + let spec = effectcraft_effects::find("ec.channel.setmatte").unwrap(); + let mut next = p.next_id; + let mut fx = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [SIZE.0 as f64, SIZE.1 as f64]); + p.next_id = next; + fx.prop_mut("takeMatteFromLayer").unwrap().value = Value::Layer(Some(matte.id.0)); + solid.props.sub_mut("effects").unwrap().children.push(fx.into()); + let cid = add_comp(&mut p, "Outer", c, vec![solid, matte, pre]); + let (pool, cache) = (MediaPool::new(), LayerCache::default()); + let (_, files) = render(&p, &pool, &cache, cid, 3); + let mut want = vec![matte_path.to_string_lossy().to_string(), nested_frame]; + want.sort(); + assert_eq!(paths(&files), want); + assert!(files.is_complete()); + assert_stamps_match_disk(&files); + let _ = std::fs::remove_dir_all(&dir); +} + +/// (c) A frame rendered again comes from memory (the sequence frame from the media pool, the +/// still layer from the layer cache) and reports the same files, with the identities they had +/// when read, without opening anything: here the files are even gone by then. +#[test] +fn a_frame_served_from_memory_reports_its_files_without_touching_them() { + let dir = folder("memory"); + let mut p = Project::default(); + let c = comp(); + // The sequence at half opacity over the still: both show. + let mut seq = footage_layer(&mut p, &c, "Seq", sequence(&dir, 3)); + seq.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(50.0); + let plate = footage_layer(&mut p, &c, "Plate", still(&dir.join("plate.png"), 128)); + let cid = add_comp(&mut p, "C", c, vec![seq, plate]); + let (pool, cache) = (MediaPool::new(), LayerCache::default()); + let (px, first) = render(&p, &pool, &cache, cid, 1); + assert_eq!(first.len(), 2, "{:?}", paths(&first)); + assert_stamps_match_disk(&first); + std::fs::remove_dir_all(&dir).unwrap(); + let opened = crate::opens_under(&dir); + let hits = cache.stats().hits; + let (again_px, again) = render(&p, &pool, &cache, cid, 1); + assert_eq!(again_px, px, "the same picture"); + assert_eq!(again, first, "the same files, as they were when read"); + assert!(cache.stats().hits > hits, "the still layer came from the layer cache"); + // The media pool alone (a new layer cache) reports them too. + let (_, from_pool) = render(&p, &pool, &LayerCache::default(), cid, 1); + assert_eq!(from_pool, first); + assert_eq!(crate::opens_under(&dir), opened, "nothing was opened on disk"); +} + +// ---------------------------------------------------------------- layer entries on disk (#3184) + +use std::sync::Arc; + +use effectcraft_render::disk_cache::{DiskCache, Kind}; + +/// A "session": a new media pool and layer cache over the disk cache in `cache` (opened anew, +/// as after a restart), writing every layer buffer however quick to render. +fn session(cache: &Path) -> (MediaPool, LayerCache, Arc) { + let dc = DiskCache::open(cache, 1 << 30).unwrap(); + dc.set_min_layer_ms(0); + let lc = LayerCache::default(); + lc.set_disk(Some(dc.clone())); + (MediaPool::new(), lc, dc) +} + +/// Give `path` a later modification time, so a rewrite within the clock's resolution counts. +fn touch_later(path: &Path) { + let t = std::fs::metadata(path).unwrap().modified().unwrap() + std::time::Duration::from_secs(7); + std::fs::File::options().append(true).open(path).unwrap().set_modified(t).unwrap(); +} + +/// Two still plates (the upper one at half opacity, so both show), and a 5000-frame sequence +/// in the project that no layer uses (its files don't even exist). +fn plates(dir: &Path) -> (Project, ItemId, PathBuf) { + let mut p = Project::default(); + let c = comp(); + let a = dir.join("a.png"); + let mut top = footage_layer(&mut p, &c, "A", still(&a, 60)); + top.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(50.0); + let bottom = footage_layer(&mut p, &c, "B", still(&dir.join("b.png"), 200)); + let unused = dir.join("unused"); + let seq = Footage { + kind: FootageKind::Sequence, + sequence: (0..5000).map(|i| unused.join(format!("big_{i:05}.exr")).to_string_lossy().to_string()).collect(), + frame_rate: RATE, + width: SIZE.0, + height: SIZE.1, + has_video: true, + ..Default::default() + }; + p.add_item("Unused", Label::Aqua, None, ItemKind::Footage(seq)); + let cid = add_comp(&mut p, "C", c, vec![top, bottom]); + (p, cid, a) +} + +/// Correct and no scan: after a restart, only the layer buffer whose source file changed is +/// rendered again (only that file is opened); the other comes from disk with its file list, so +/// the frame's list is complete again (its frame entry can be stored). The disk cache checks +/// exactly the files its entries list, never the unused sequence. The re-rendered buffer +/// replaces its entry: the next restart opens nothing. +#[test] +fn a_changed_file_renders_only_the_layer_buffers_that_read_it_again() { + let dir = folder("layer-disk"); + let (src, cache) = (dir.join("src"), dir.join("cache")); + std::fs::create_dir_all(&src).unwrap(); + let (p, cid, a) = plates(&src); + let (pool, lc, dc) = session(&cache); + let (px, files) = render(&p, &pool, &lc, cid, 0); + assert!(files.is_complete() && files.len() == 2, "{:?}", paths(&files)); + dc.flush(); + let entries = dc.keys(Kind::Layer).len(); + assert!(entries >= 2, "both plates' buffers are on disk: {entries}"); + drop((pool, lc, dc)); + + // Restart without a change: everything from disk, nothing opened, a complete list. + let (pool, lc, dc) = session(&cache); + let opened = crate::opens_under(&src); + let (again, files_again) = render(&p, &pool, &lc, cid, 0); + assert_eq!(again, px); + assert_eq!(files_again, files, "the files of the entries, as they were read"); + assert!(files_again.is_complete(), "a frame made of buffers from disk can be stored"); + assert_eq!(crate::opens_under(&src), opened, "nothing opened"); + let checks = dc.file_checks(); + assert!(checks >= 2 && checks as usize <= entries, "one check per entry used: {checks} of {entries}"); + drop((pool, lc, dc)); + + // a.png gets new pixels: only the top plate renders again. + png(&a, 120); + touch_later(&a); + let (pool, lc, dc) = session(&cache); + let opened = crate::opens_under(&src); + let (changed, files_changed) = render(&p, &pool, &lc, cid, 0); + assert_ne!(changed, px, "the new pixels show"); + assert_eq!(crate::opens_under(&src) - opened, 1, "only the changed file is read"); + assert!(files_changed.is_complete() && files_changed.len() == 2); + assert_stamps_match_disk(&files_changed); + assert!(dc.file_checks() as usize <= entries, "only listed files are checked"); + assert_eq!(crate::opens_under(&src.join("unused")), 0); + dc.flush(); + drop((pool, lc, dc)); + + // Its entry was replaced: the next restart opens nothing and shows the new pixels. + let (pool, lc, _dc) = session(&cache); + let opened = crate::opens_under(&src); + let (last, _) = render(&p, &pool, &lc, cid, 0); + assert_eq!(last, changed); + assert_eq!(crate::opens_under(&src), opened, "both from disk"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Two projects whose footage items have the same ids but name different files never share a +/// layer entry: the layer key names the item's path and settings. +#[test] +fn projects_with_the_same_item_ids_never_share_a_layer_entry() { + let dir = folder("layer-disk-projects"); + let (src, cache) = (dir.join("src"), dir.join("cache")); + std::fs::create_dir_all(&src).unwrap(); + let mut p = Project::default(); + let c = comp(); + let layer = footage_layer(&mut p, &c, "Plate", still(&src.join("one.png"), 40)); + let LayerSource::Footage { item } = layer.source else { panic!("a footage layer") }; + let cid = add_comp(&mut p, "C", c, vec![layer]); + let mut other = p.clone(); + other.item_mut(item).unwrap().kind = ItemKind::Footage(still(&src.join("two.png"), 220)); + let (pool, lc, dc) = session(&cache); + let (one, _) = render(&p, &pool, &lc, cid, 0); + dc.flush(); + drop((pool, lc, dc)); + let (pool, lc, dc) = session(&cache); + let hits = dc.stats().hits; + let (two, files) = render(&other, &pool, &lc, cid, 0); + assert!((two[0] - 220.0 / 255.0).abs() < 0.01, "the other project's own file shows: {two:?}"); + assert_ne!(two, one); + assert_eq!(dc.stats().hits, hits, "no entry of the first project is used"); + assert_eq!(paths(&files), [src.join("two.png").to_string_lossy().to_string()]); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Safe: a damaged layer entry, a source file that is missing, one replaced by something +/// unreadable (a folder), and one back with other pixels never serve a stale buffer after a +/// restart; the damaged entry is rendered again, replaced and then used. +#[test] +fn missing_unreadable_files_and_damaged_layer_entries_never_serve_stale_buffers() { + let dir = folder("layer-disk-safe"); + let (src, cache) = (dir.join("src"), dir.join("cache")); + std::fs::create_dir_all(&src).unwrap(); + let mut p = Project::default(); + let c = comp(); + let path = src.join("plate.png"); + let layer = footage_layer(&mut p, &c, "Plate", still(&path, 90)); + let cid = add_comp(&mut p, "C", c, vec![layer]); + let (pool, lc, dc) = session(&cache); + let (shown, _) = render(&p, &pool, &lc, cid, 0); + assert!(shown[3] > 0.99, "the plate shows: {shown:?}"); + dc.flush(); + drop((pool, lc, dc)); + + // A damaged entry: a miss, deleted, rendered again and stored anew. + let layers = cache.join("v1").join(Kind::Layer.dir()); + for shard in std::fs::read_dir(&layers).unwrap().flatten() { + for f in std::fs::read_dir(shard.path()).unwrap().flatten() { + let mut d = std::fs::read(f.path()).unwrap(); + let n = d.len() / 2; + d[n] ^= 0x5a; + std::fs::write(f.path(), d).unwrap(); + } + } + let (pool, lc, dc) = session(&cache); + let opened = crate::opens_under(&src); + let (again, files) = render(&p, &pool, &lc, cid, 0); + assert_eq!(again, shown); + assert_eq!(crate::opens_under(&src) - opened, 1, "read again, not served from the damaged entry"); + assert!(files.is_complete()); + dc.flush(); + drop((pool, lc, dc)); + let (pool, lc, dc) = session(&cache); + let hits = dc.stats().hits; + assert_eq!(render(&p, &pool, &lc, cid, 0).0, shown); + assert!(dc.stats().hits > hits, "the rewritten entry is used"); + drop((pool, lc, dc)); + + // The file is gone: not the old plate. + std::fs::remove_file(&path).unwrap(); + let (pool, lc, dc) = session(&cache); + let hits = dc.stats().hits; + let (gone, files) = render(&p, &pool, &lc, cid, 0); + assert!(gone[3] < 0.01, "a missing file shows nothing, never the cached plate: {gone:?}"); + assert_eq!(dc.stats().hits, hits); + assert!(files.files().iter().all(|f| f.stamp == FileStamp::Missing), "{files:?}"); + dc.flush(); + drop((pool, lc, dc)); + + // Something unreadable at its path (a folder): still not the old plate. + std::fs::create_dir(&path).unwrap(); + let (pool, lc, _dc) = session(&cache); + let (unreadable, _) = render(&p, &pool, &lc, cid, 0); + assert!(unreadable[3] < 0.01, "{unreadable:?}"); + std::fs::remove_dir(&path).unwrap(); + + // Back with other pixels: those show, not the entry made of the old file. + png(&path, 240); + touch_later(&path); + let (pool, lc, _dc) = session(&cache); + let (back, _) = render(&p, &pool, &lc, cid, 0); + assert!((back[0] - 240.0 / 255.0).abs() < 0.01, "{back:?}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/media/src/stream.rs b/crates/media/src/stream.rs index 80db21fc4..0155b4acd 100644 --- a/crates/media/src/stream.rs +++ b/crates/media/src/stream.rs @@ -9,6 +9,7 @@ use std::path::Path; +use effectcraft_render::reads::FileStamp; use filmcraft_media::SharedSource; use crate::Result; @@ -19,17 +20,22 @@ use crate::Result; pub(crate) struct FileReader { file: std::fs::File, len: u64, + /// The file's identity when it was opened. + stamp: FileStamp, } #[cfg(any(unix, windows))] impl FileReader { + /// Opens `path` for reading and records its length and modification time. pub(crate) fn open(path: &Path) -> std::io::Result { + #[cfg(test)] + crate::count_open(path); let file = std::fs::File::open(path)?; let m = file.metadata()?; if !m.is_file() { return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{} is not a file", path.display()))); } - Ok(Self { file, len: m.len() }) + Ok(Self { file, len: m.len(), stamp: FileStamp::of_metadata(&m) }) } } @@ -72,16 +78,19 @@ fn name_of(path: &Path) -> String { /// Open a movie/audio file for the media pool, reading only what its container needs; files the /// reader openers don't handle are read whole and opened from memory, exactly as before. `None` /// when the file can't be opened in place (the caller reads it whole, which reports the error). +/// With the file's identity as opened. #[cfg(any(unix, windows))] -pub(crate) fn open_source(path: &Path) -> Option> { +pub(crate) fn open_source(path: &Path) -> Option<(Result, FileStamp)> { let reader = FileReader::open(path).ok()?; + let stamp = reader.stamp; let reader: filmcraft_media::SharedReader = std::sync::Arc::new(reader); let opened = filmcraft_media::reader::open_reader(&name_of(path), reader, &filmcraft_codecs::reader_openers(), &filmcraft_codecs::openers()); - Some(opened.map_err(Into::into)) + Some((opened.map_err(Into::into), stamp)) } +/// Without a native file system there is no in-place source: always `None`. #[cfg(not(any(unix, windows)))] -pub(crate) fn open_source(_path: &Path) -> Option> { +pub(crate) fn open_source(_path: &Path) -> Option<(Result, FileStamp)> { None } diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index fabcd607f..6d0a72976 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -35,6 +35,7 @@ mod footage; mod item; mod layer; mod layers; +mod reach; mod settings; pub use comp::{Comp, Guide, Renderer, Resolution}; @@ -46,6 +47,7 @@ pub use layer::{ reads_other_frames, text_layer_name, }; pub use layers::Layers; +pub use reach::{ExprReach, PixelReach, expression_reach}; pub use settings::{ APP_VERSION, BitDepth, ColorEngine, CuePoint, HdrMode, Marker, ProjectError, ProjectSettings, SCHEMA_EFFECT_SPACE, SCHEMA_VERSION, SavedView, TimeDisplayStyle, is_newer_version, saved_by, saved_view, diff --git a/crates/project/src/reach.rs b/crates/project/src/reach.rs new file mode 100644 index 000000000..31a2ed567 --- /dev/null +++ b/crates/project/src/reach.rs @@ -0,0 +1,405 @@ +//! What a composition's pixels can depend on, from the project in memory: the items it draws +//! and, when expressions are among them, what those expressions can read (#3184). The disk +//! cache keys a comp's frames by exactly these items, so an edit elsewhere in the project keeps +//! them. +//! +//! An expression reaches its own comp's layers (`thisComp`, `thisLayer`), the sources of the +//! layers it can read (`layer.source`, a precomp's layers through it), and other items by name: +//! `comp("Name")` and `footage("Name")`. A name given as a plain string literal is followed; a +//! computed name (`comp("Shot " + index)`), the object model's constructors and internals +//! (`new Comp(7)`, `__h`), dynamic code (`eval`, `Function`) and anything the scanner can't read +//! for sure (a regular expression literal, a template string, an escape in a name) count as +//! reaching every item of the project. The scan errs on the side of reaching more: an item +//! wrongly left out would serve a stale frame, one wrongly let in only renders again. +//! +//! Known limit (`ponytail`): an expression that builds the names of the object model's private +//! fields at run time into a variable and writes them with a computed key it reads from that +//! variable could forge another comp's id. That is deliberate tampering with the internals, not +//! an expression one writes; the scan refuses every direct form of it (`__` names, the +//! constructors, `Object`, `JSON`, `call`/`apply`/`bind`, computed writes and computed keys). +//! Upgrade: have the expression host record which items an evaluation reached. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::{Comp, ItemId, ItemKind, LayerId, Node, Project, PropGroup, Value}; + +/// What an expression's text can reach outside its own comp and the layers it can read. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ExprReach { + /// Only items named by string literals: comps by `comp("…")`, footage by `footage("…")`. + Names { comps: Vec, footage: Vec }, + /// Any item of the project. + Anything, +} + +/// Names that hand an expression another item by id, run code built at run time, or give it the +/// tools to forge the object model's private fields. +const REACHES_ANYTHING: &[&str] = &[ + "Comp", + "Footage", + "Layer", + "Prop", + "Markers", + "eval", + "Function", + "globalThis", + "this", + "self", + "window", + "arguments", + "constructor", + "prototype", + "Object", + "Reflect", + "Proxy", + "JSON", + "call", + "apply", + "bind", + "class", + "with", + "import", + "require", + "fromCharCode", + "fromCodePoint", + "getOwnPropertyDescriptor", + "getOwnPropertyDescriptors", + "getPrototypeOf", + "setPrototypeOf", + "defineProperty", +]; + +/// Keywords after which a `/` starts a regular expression. +const BEFORE_REGEX: &[&str] = &["return", "typeof", "instanceof", "in", "of", "new", "delete", "void", "throw", "case", "do", "else", "yield", "await"]; + +/// Keywords whose `( … )` a regular expression may follow (`if (a) /x/.test(s)`). +const CONTROL: &[&str] = &["if", "while", "for", "with"]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Tok<'a> { + Ident(&'a str), + /// A string literal's text between its quotes; `plain` when it has no escape. + Str { + text: &'a str, + plain: bool, + }, + Num, + Punct(u8), +} + +/// The tokens of `text`, or `None` when it can't be read for sure. +fn tokens(text: &str) -> Option>> { + let b = text.as_bytes(); + let mut out: Vec> = Vec::new(); + // Per open `(`: whether a control keyword is before it; whether the last `)` closed one + // (only looked at right after a `)`). + let mut parens: Vec = Vec::new(); + let mut control_close = false; + let mut i = 0; + while let Some(&c) = b.get(i) { + match c { + b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c => i += 1, + b'/' if b.get(i + 1) == Some(&b'/') => { + while b.get(i).is_some_and(|c| *c != b'\n') { + i += 1; + } + } + b'/' if b.get(i + 1) == Some(&b'*') => { + let end = text.get(i + 2..)?.find("*/")?; + i += 2 + end + 2; + } + b'/' => { + if regex_may_start(out.last(), control_close) { + return None; + } + out.push(Tok::Punct(c)); + i += 1; + } + b'"' | b'\'' => { + let start = i + 1; + let mut j = start; + let mut plain = true; + loop { + match *b.get(j)? { + q if q == c => break, + b'\n' | b'\r' => return None, + b'\\' => { + // Only escapes that can't spell a name: `\n`, `\t`, a quote... + if !matches!(b.get(j + 1)?, b'n' | b'r' | b't' | b'b' | b'f' | b'v' | b'\'' | b'"' | b'\\') { + return None; + } + plain = false; + j += 2; + } + _ => j += 1, + } + } + let s = text.get(start..j)?; + if s.contains("__") { + return None; + } + out.push(Tok::Str { text: s, plain }); + i = j + 1; + } + b'0'..=b'9' => { + while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'.') { + i += 1; + } + out.push(Tok::Num); + } + b'.' if b.get(i + 1).is_some_and(u8::is_ascii_digit) => { + i += 1; + while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'.') { + i += 1; + } + out.push(Tok::Num); + } + c if c.is_ascii_alphabetic() || c == b'_' || c == b'$' => { + let start = i; + while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'$') { + i += 1; + } + out.push(Tok::Ident(text.get(start..i)?)); + } + // A template string (code inside `${}`), an escaped identifier, a name in another + // alphabet: not followed. + b'`' | b'\\' => return None, + c if !c.is_ascii() => return None, + c => { + match c { + b'(' => parens.push(matches!(out.last(), Some(Tok::Ident(k)) if CONTROL.contains(k))), + b')' => control_close = parens.pop().unwrap_or(false), + _ => {} + } + out.push(Tok::Punct(c)); + i += 1; + } + } + } + Some(out) +} + +/// Whether a `/` after `prev` may start a regular expression literal (which the scanner doesn't +/// read: its text could hide a quote). +fn regex_may_start(prev: Option<&Tok<'_>>, control_close: bool) -> bool { + match prev { + None => true, + Some(Tok::Ident(k)) => BEFORE_REGEX.contains(k), + Some(Tok::Num | Tok::Str { .. }) => false, + Some(Tok::Punct(b')')) => control_close, + Some(Tok::Punct(b']')) => false, + Some(Tok::Punct(_)) => true, + } +} + +/// What the expression `text` can reach by name, or [`ExprReach::Anything`]. +pub fn expression_reach(text: &str) -> ExprReach { + expression_names(text).map_or(ExprReach::Anything, |(comps, footage)| ExprReach::Names { comps, footage }) +} + +/// The comp names and footage names an expression references by literal name. +/// Returns `None` when it can reach anything by a computed name or cannot be tokenised. +fn expression_names(text: &str) -> Option<(Vec, Vec)> { + let toks = tokens(text)?; + let (mut comps, mut footage) = (Vec::new(), Vec::new()); + // Per open `[`: a computed key (`{[k]: v}`), a computed member (`o[k]`). + let mut brackets: Vec<(bool, bool)> = Vec::new(); + for (i, t) in toks.iter().enumerate() { + let prev = i.checked_sub(1).and_then(|p| toks.get(p)); + let next = |n: usize| toks.get(i + n); + match *t { + Tok::Ident(name) if name.starts_with("__") || REACHES_ANYTHING.contains(&name) => return None, + Tok::Ident(name @ ("comp" | "footage")) => { + // Only a call with a plain literal, not a property of something else. + let (Some(Tok::Punct(b'(')), Some(Tok::Str { text, plain: true }), Some(Tok::Punct(b')'))) = (next(1), next(2), next(3)) else { return None }; + if matches!(prev, Some(Tok::Punct(b'.'))) { + return None; + } + if name == "comp" { comps.push((*text).to_string()) } else { footage.push((*text).to_string()) } + } + Tok::Punct(b'[') => { + let key = matches!(prev, Some(Tok::Punct(b'{' | b',')) | Some(Tok::Ident("get" | "set" | "static" | "async")) | Some(Tok::Punct(b'*'))); + let member = matches!(prev, Some(Tok::Ident(_) | Tok::Str { .. } | Tok::Num | Tok::Punct(b')' | b']' | b'.'))); + brackets.push((key, member)); + } + Tok::Str { .. } if brackets.last().is_some_and(|(_, member)| *member) => return None, + Tok::Punct(b']') => { + let (key, _) = brackets.pop().unwrap_or((true, true)); + // A computed key (`{[k]: v}`, `{[k]() {}}`) or a write through brackets (`o[k] = v`, + // `o[k] ||= v`, `for (o[k] in …)`, `[o[k]] = v`) could name a private field. + if key && matches!(next(1), Some(Tok::Punct(b':' | b'('))) { + return None; + } + if assigns(&toks, i + 1) || matches!(next(1), Some(Tok::Ident("in" | "of"))) { + return None; + } + } + // A destructuring write (`({a: o[k]} = v)`). + Tok::Punct(b'}') if assigns(&toks, i + 1) => return None, + _ => {} + } + } + Some((comps, footage)) +} + +/// Whether the tokens from `at` are an assignment operator (`=`, `+=`, `>>>=`, `??=`...), not a +/// comparison (`==`, `<=`) or an arrow (`=>`). +fn assigns(toks: &[Tok<'_>], at: usize) -> bool { + let mut op = Vec::new(); + for t in toks.iter().skip(at).take(5) { + match t { + Tok::Punct(c) if b"+-*/%&|^<>?=".contains(c) => op.push(*c), + _ => break, + } + } + let Some(eq) = op.iter().position(|c| *c == b'=') else { return false }; + let (before, after) = op.split_at(eq); + if after.get(1).is_some_and(|c| *c == b'=' || *c == b'>') { + return false; + } + matches!(before, b"" | b"+" | b"-" | b"*" | b"/" | b"%" | b"&" | b"|" | b"^" | b"**" | b"<<" | b">>" | b">>>" | b"&&" | b"||" | b"??") +} + +/// The items a comp's pixels can depend on ([`Project::pixel_reach`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PixelReach { + /// The items whose state counts; `None`: every item of the project. + pub items: Option>, + /// Expressions are among them: they can read every layer of the comps they reach (hidden + /// and silent ones too) and the markers, which therefore count as well (#1083). + pub expressions: bool, + /// The names expressions look items up by (`true`: a comp's, `false`: footage's), each with + /// the ids of the items of that kind that have it now, in id order (the first one is what + /// the lookup finds; another item taking or giving up the name changes it). + pub names: Vec<(bool, String, Vec)>, +} + +/// Each property of a group and its subgroups (iterative: no depth limit to hit). +fn each_prop<'a>(g: &'a PropGroup, f: &mut dyn FnMut(&'a crate::Property) -> Option<()>) -> Option<()> { + let mut stack = vec![g]; + while let Some(g) = stack.pop() { + for n in &g.children { + match n { + Node::Prop(p) => f(p)?, + Node::Group(sub) => stack.push(sub), + } + } + } + Some(()) +} + +impl Project { + /// The items comp `comp` draws ([`Self::silent_layers`] aside, with `heard`) and the + /// footage its precomp layers swap in (Essential Graphics Media Replacement); with an + /// expression in any of its comps, everything those expressions can reach + /// ([`expression_reach`]). Memory only: no file is looked at. + pub fn pixel_reach(&self, comp: ItemId, heard: &crate::Heard) -> PixelReach { + let Some(drawn) = self.reach_walk(comp, &|id, c| self.silent_layers(id, c, heard), &mut |_| Some(vec![])) else { return PixelReach::every() }; + let has_expression = |g: &PropGroup| each_prop(g, &mut |p| p.expr.is_none().then_some(())).is_none(); + if !drawn.iter().filter_map(|id| self.comp(*id)).any(|c| c.layers.iter().any(|l| has_expression(&l.props))) { + return PixelReach { items: Some(drawn), expressions: false, names: vec![] }; + } + // Expressions: every layer of a comp they reach counts, and what they name. + let mut names: BTreeMap<(bool, String), Vec> = BTreeMap::new(); + let mut scan = |p: &crate::Property| match p.expr.as_ref().map(|e| expression_reach(&e.text)) { + None => Some(vec![]), + Some(ExprReach::Anything) => None, + Some(ExprReach::Names { comps, footage }) => { + let mut found = vec![]; + for (is_comp, name) in comps.into_iter().map(|n| (true, n)).chain(footage.into_iter().map(|n| (false, n))) { + let ids = self.named(is_comp, &name); + found.extend(ids.iter().copied()); + names.insert((is_comp, name), ids); + } + Some(found) + } + }; + let Some(mut items) = self.reach_walk(comp, &|_, _| BTreeSet::new(), &mut scan) else { return PixelReach::every() }; + // A source's data is read by its name (`layer.source.sourceData`). + let footage_names: BTreeSet<&str> = + items.iter().filter_map(|id| self.item(*id)).filter(|i| matches!(i.kind, ItemKind::Footage(_))).map(|i| i.name.as_str()).collect(); + let same_name: Vec = + self.items.values().filter(|i| matches!(i.kind, ItemKind::Footage(_)) && footage_names.contains(i.name.as_str())).map(|i| i.id).collect(); + items.extend(same_name); + PixelReach { items: Some(items), expressions: true, names: names.into_iter().map(|((c, n), ids)| (c, n, ids)).collect() } + } + + /// The items of one kind (`is_comp`: comps, else footage) named `name`, in id order. + fn named(&self, is_comp: bool, name: &str) -> Vec { + self.items + .values() + .filter(|i| i.name == name && if is_comp { matches!(i.kind, ItemKind::Comp(_)) } else { matches!(i.kind, ItemKind::Footage(_)) }) + .map(|i| i.id) + .collect() + } + + /// The items reached from `comp` through its layers (but those `quiet` names) and the items + /// `scan` returns for each of their properties; `None` when something reaches every item. + /// (A stack and a visited set: each item is visited once, cycles included.) + fn reach_walk( + &self, + comp: ItemId, + quiet: &dyn Fn(ItemId, &Comp) -> BTreeSet, + scan: &mut dyn FnMut(&crate::Property) -> Option>, + ) -> Option> { + let mut seen = BTreeSet::new(); + let mut stack = vec![comp]; + while let Some(id) = stack.pop() { + if !seen.insert(id) { + continue; + } + let Some(c) = self.comp(id) else { continue }; + let quiet = quiet(id, c); + for l in c.layers.iter().filter(|l| !quiet.contains(&l.id)) { + // Every source item: a 3D model's too (it is drawn, and `layer.source` reads its + // name and size). + if let Some(item) = l.source.item() { + stack.push(item); + } + stack.extend(self.media_replacements(l)?); + let mut reached = vec![]; + each_prop(&l.props, &mut |p| { + reached.extend(scan(p)?); + Some(()) + })?; + stack.extend(reached); + } + } + Some(seen) + } + + /// The footage a precomp layer swaps in through its Essential Properties (Media Replacement): + /// the items its overridden properties name. `None` when they can't be listed (an + /// expression or keyframes choose the item over time). + fn media_replacements(&self, layer: &crate::Layer) -> Option> { + let over = crate::essential::overridden(layer); + let Some(g) = crate::essential::group(layer).filter(|_| !over.is_empty()) else { return Some(vec![]) }; + let mut out = vec![]; + each_prop(g, &mut |p| { + if !over.contains(&p.uid) { + return Some(()); + } + if p.expr.is_some() || !p.keys.is_empty() { + return None; + } + if let Value::Scalar(id) = p.value { + let item = ItemId(id.max(0.0) as u64); + if matches!(self.item(item).map(|i| &i.kind), Some(ItemKind::Footage(_))) { + out.push(item); + } + } + Some(()) + })?; + Some(out) + } +} + +impl PixelReach { + /// Every item of the project, with expressions. + fn every() -> PixelReach { + PixelReach { items: None, expressions: true, names: vec![] } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/project/src/reach/tests.rs b/crates/project/src/reach/tests.rs new file mode 100644 index 000000000..53db8172a --- /dev/null +++ b/crates/project/src/reach/tests.rs @@ -0,0 +1,198 @@ +use super::*; +use crate::{Expression, LayerSource, Solid, build}; +use effectcraft_time::{FrameRate, Tick}; + +fn names(comps: &[&str], footage: &[&str]) -> ExprReach { + ExprReach::Names { comps: comps.iter().map(|s| s.to_string()).collect(), footage: footage.iter().map(|s| s.to_string()).collect() } +} + +/// Expressions that read their own comp, or other items by literal names, are followed; every +/// way to reach items by a computed name or an id, or text the scanner can't read for sure, +/// reaches everything. +#[test] +fn expression_reach_follows_literal_names_and_refuses_the_rest() { + let follows = [ + ("wiggle(2, 20)", names(&[], &[])), + ("thisComp.layer(\"Comp 1 Layer\").transform.opacity * 2 / 3", names(&[], &[])), + ("value + [10, 0]; value[0] / 2", names(&[], &[])), + ("// comp(name) in a comment\n/* footage(x) */ value", names(&[], &[])), + ("\"a string that says comp(x) and Layer\"", names(&[], &[])), + ("comp(\"Other\").layer(1).transform.position", names(&["Other"], &[])), + ("comp( 'A' ).width + comp(\"B\").height", names(&["A", "B"], &[])), + ("footage(\"data.json\").sourceData.x", names(&[], &["data.json"])), + ("x = a[0] <= b ? a[1] : 0; y = a[0] == 1", names(&[], &[])), + ("thisLayer.source.layer(1).name", names(&[], &[])), + ("if (a) x = 1; else x = 2; (a + b) / 2", names(&[], &[])), + ("\"Ünïcödé name\"", names(&[], &[])), + ("const f = (v) => v * 2; f(1)", names(&[], &[])), + ]; + for (text, want) in follows { + assert_eq!(expression_reach(text), want, "{text}"); + } + let anything = [ + "comp(\"Shot \" + index).layer(1)", + "comp(name)", + "var n = \"Other\"; comp(n)", + "comp(\"O\\\"ther\")", + "x.comp(\"Other\")", + "var comp = 1", + "footage(thisComp.name)", + "new Comp(7).layer(1)", + "Layer(7, 3).name", + "__h(__COMP, 7)", + "o.__c = 7", + "eval('1')", + "Function('return 1')()", + "this.x", + "({}).constructor", + "Object.getPrototypeOf(thisComp)", + "JSON.parse(s)", + "thisComp.layer.call(o, 1)", + "o['_' + '_c']", + "o[k] = 7", + "o[k] ||= 7", + "o[k] += 7", + "[o[k]] = [7]", + "({a: o[k]} = {a: 7})", + "for (o[k] in x) {}", + "({[k]: 7})", + "({a: 1, [k]: 7})", + "o = {'__c': 7}", + "'\\x5f\\x5fc'", + "'\\_\\_c'", + "`${x}`", + "c\\u006fmp('A')", + "comp\u{0430}('A')", + "/\"/.test(s); comp(n); \"", + "return /x/", + "if (a) /\"/.test(s)", + "x = '", + "/* comp(n)", + "class X {}", + "String.fromCharCode(95)", + ]; + for text in anything { + assert_eq!(expression_reach(text), ExprReach::Anything, "{text}"); + } +} + +/// `a / b` divides after a name, a number, a string and a closing bracket or parenthesis. +#[test] +fn division_is_not_a_regular_expression() { + for text in ["a / b", "2 / 3", "'a' / 2", "x[0] / 2", "(a + b) / 2", "f(x) / 2 / g(y)"] { + assert_eq!(expression_reach(text), names(&[], &[]), "{text}"); + } +} + +struct Fixture { + p: Project, + main: ItemId, + other: ItemId, + third: ItemId, + unused: ItemId, + solid: ItemId, +} + +/// Comps "Main" (a solid), "Other" (a solid), "Third" (empty), an unused footage item. +fn fixture() -> Fixture { + let mut p = Project::default(); + let comp = Comp::new(64, 64, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let solid = + p.add_item("Solid", effectcraft_color::Label::None, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 64, height: 64, pixel_aspect: 1.0 })); + let main = p.add_item("Main", effectcraft_color::Label::None, None, ItemKind::Comp(comp.clone().into())); + let other = p.add_item("Other", effectcraft_color::Label::None, None, ItemKind::Comp(comp.clone().into())); + let third = p.add_item("Third", effectcraft_color::Label::None, None, ItemKind::Comp(comp.clone().into())); + let unused = p.add_item("unused.png", effectcraft_color::Label::None, None, ItemKind::Footage(crate::Footage { has_video: true, ..Default::default() })); + for c in [main, other] { + let l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: solid }, (64, 64), None); + p.comp_mut(c).unwrap().layers.push(l); + } + Fixture { p, main, other, third, unused, solid } +} + +fn set_expr(p: &mut Project, comp: ItemId, text: &str) { + let l = p.comp_mut(comp).unwrap().layers.first_mut().unwrap(); + l.props.prop_mut("transform/opacity").unwrap().expr = Some(Expression { text: text.into(), enabled: true }); +} + +fn reach(p: &Project, comp: ItemId) -> PixelReach { + p.pixel_reach(comp, &p.heard_layers()) +} + +#[test] +fn pixel_reach_is_what_the_comp_draws_and_what_its_expressions_name() { + let mut f = fixture(); + let ids = |r: &PixelReach| r.items.clone().map(|s| s.into_iter().collect::>()); + assert_eq!(reach(&f.p, f.main), PixelReach { items: Some([f.solid, f.main].into()), expressions: false, names: vec![] }); + // An expression reading only its own comp: what it draws, with expressions. + set_expr(&mut f.p, f.main, "thisComp.layer(1).transform.opacity"); + let r = reach(&f.p, f.main); + assert!(r.expressions); + assert_eq!(ids(&r), Some(vec![f.solid, f.main])); + // An expression in another comp doesn't reach this one. + set_expr(&mut f.p, f.other, "wiggle(1, 1)"); + assert_eq!(ids(&reach(&f.p, f.main)), Some(vec![f.solid, f.main])); + // A literal name: that comp and what it draws, with the name and the comps that have it. + set_expr(&mut f.p, f.main, "comp(\"Other\").layer(1).transform.opacity"); + let r = reach(&f.p, f.main); + assert_eq!(ids(&r), Some(vec![f.solid, f.main, f.other])); + assert_eq!(r.names, vec![(true, "Other".to_string(), vec![f.other])]); + // Another comp taking the name is part of it, whichever the lookup finds. + f.p.item_mut(f.third).unwrap().name = "Other".into(); + let r = reach(&f.p, f.main); + assert_eq!(r.names, vec![(true, "Other".to_string(), vec![f.other, f.third])]); + assert!(r.items.unwrap().contains(&f.third)); + // A name no item has yet still counts (with no item). + set_expr(&mut f.p, f.main, "footage(\"later.json\").sourceData"); + assert_eq!(reach(&f.p, f.main).names, vec![(false, "later.json".to_string(), vec![])]); + // A computed name reaches everything. + set_expr(&mut f.p, f.main, "comp(\"Ot\" + \"her\").width"); + assert_eq!(reach(&f.p, f.main), PixelReach { items: None, expressions: true, names: vec![] }); + // Footage reached as a layer's source brings the footage of its name (its data is read by + // name). + let comp = f.p.comp(f.main).unwrap().clone(); + let l = build::layer(&mut f.p, &comp, "U", LayerSource::Footage { item: f.unused }, (64, 64), None); + f.p.comp_mut(f.main).unwrap().layers.push(l); + let twin = f.p.add_item("unused.png", effectcraft_color::Label::None, None, ItemKind::Footage(crate::Footage::default())); + set_expr(&mut f.p, f.main, "value"); + let r = reach(&f.p, f.main).items.unwrap(); + assert!(r.contains(&f.unused) && r.contains(&twin), "{r:?}"); +} + +/// A comp nested in the reach is scanned too: its expression's names count for the outer comp. +#[test] +fn nested_comps_bring_their_expressions_reach() { + let mut f = fixture(); + let comp = f.p.comp(f.main).unwrap().clone(); + let pre = build::layer(&mut f.p, &comp, "Pre", LayerSource::Comp { item: f.other }, (64, 64), None); + f.p.comp_mut(f.main).unwrap().layers.push(pre); + set_expr(&mut f.p, f.other, "comp(\"Third\").width"); + let r = reach(&f.p, f.main); + assert!(r.expressions && r.items.unwrap().contains(&f.third)); + // A cycle through names ends. + let l = build::layer(&mut f.p, &comp, "S", LayerSource::Solid { item: f.solid }, (64, 64), None); + f.p.comp_mut(f.third).unwrap().layers.push(l); + set_expr(&mut f.p, f.third, "comp(\"Main\").width"); + assert!(reach(&f.p, f.main).items.unwrap().contains(&f.main)); +} + +/// A 3D model layer's item is reached like any other source: it is drawn, and an expression +/// reads its name and size through `layer.source` (a renamed model left the comp's disk key and +/// its old frames in place). +#[test] +fn model_sources_are_reached() { + let mut f = fixture(); + let model = f.p.add_item( + "chair.glb", + effectcraft_color::Label::None, + None, + ItemKind::Footage(crate::Footage { kind: crate::FootageKind::Model, ..Default::default() }), + ); + let comp = f.p.comp(f.main).unwrap().clone(); + let l = build::layer(&mut f.p, &comp, "Model", LayerSource::Model { item: model }, (64, 64), None); + f.p.comp_mut(f.main).unwrap().layers.push(l); + assert!(reach(&f.p, f.main).items.unwrap().contains(&model), "drawn"); + set_expr(&mut f.p, f.main, "thisComp.layer(\"Model\").source.name.length"); + let r = reach(&f.p, f.main); + assert!(r.expressions && r.items.unwrap().contains(&model), "read by an expression"); +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index efbd1fc19..cd67e846a 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -26,3 +26,6 @@ rayon = { workspace = true } web-time = { workspace = true } serde = { workspace = true } log = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/render/src/accel.rs b/crates/render/src/accel.rs index f545a62ee..0f271c59d 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -224,7 +224,7 @@ impl<'a> Renderer<'a> { LayerSource::Footage { item } => { let item = ctx.media_item(layer, *item); let ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None }; - let a = self.footage.aux(item, f, ctx.source_time(layer))?; + let a = self.read(|| self.footage.aux(item, f, ctx.source_time(layer)))?; // Aux pixels per layer pixel (the layer is the footage at its nominal size). The // planes are only copied when that changes the scale. let scale = a.width as f64 / f.width as f64; @@ -349,20 +349,19 @@ impl<'a> Renderer<'a> { pub fn adjustment_footprint(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { // Cached, so an accelerator sees the same buffer (and uploads it once) every frame. let key = self.cache.and_then(|_| cache::footprint_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer))); - if let (Some(c), Some(k)) = (self.cache, key) - && let Some(b) = c.get(k) - { + let key = self.mask_gate_key(ctx, layer, key); + if let Some((b, _)) = self.cached(key) { return Some(b); } - let mut foot = self.source(ctx, layer)?; - let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer)); - let foot = Arc::new(foot); + let (foot, files) = self.recorded(key, |r| { + // (what the masks' expressions read, at shutter times too, goes to the buffer's files) + let ctx = &r.with_reads(ctx); + let mut foot = r.source(ctx, layer)?; + let _ = masks::apply(ctx, layer, &mut foot, r.mask_blur(ctx, layer)); + Some(foot) + }); // The buffer the cache keeps (another worker's, if it kept one first). - let foot = match (self.cache_to_keep(), key) { - (Some(c), Some(k)) => c.insert(k, foot), - _ => foot, - }; - Some(foot) + Some(self.keep(key, Arc::new(foot?), &files).0) } /// Draw one 2D layer into `canvas` on the CPU (any kind: adjustment, wireframe, styled…). diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index cf1a84e82..8a3d7b8ae 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -22,27 +22,36 @@ use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex}; use effectcraft_effects::Buf; +use effectcraft_keyframe::Value; use effectcraft_project::{FootageKind, ItemKind, Layer, LayerSource, Node, PropGroup}; +use crate::disk_cache::{EntryFiles, SharedVirtualFileId, VirtualFileId, layer_disk_key}; use crate::eval::EvalCtx; +use crate::reads::ReadFiles; mod dependencies; /// Thread-safe, memory-budgeted cache of processed layer buffers, optionally backed by the /// persistent [`DiskCache`](crate::disk_cache::DiskCache): a memory miss reads the disk, and -/// buffers that were slow to render are written there (keys salted per project, see -/// [`LayerCache::set_disk`]). +/// buffers that were slow to render are written there with the source files they were made of +/// (keys: [`crate::disk_cache::layer_disk_key`]; an entry is used only while its files are +/// unchanged, #3184). pub struct LayerCache { inner: Mutex, - disk: Mutex, u64)>>, + disk: Mutex>>, /// Another persistent store when there is no disk cache (the browser's, [`PrefetchStore`]). - store: Mutex, u64)>>, + store: Mutex>>, + /// Size and content hash of files served from memory (the browser file table), to store + /// and check the source files of persistent entries. + virtual_id: Mutex>, /// While set, inserts are dropped (see [`LayerCache::set_gate`]). gate: Mutex>>, } struct Entry { buf: Arc, + /// The source files the buffer was made of (see [`crate::reads`]). + files: Arc, bytes: usize, last_use: u64, } @@ -54,7 +63,9 @@ struct Inner { clock: u64, hits: u64, misses: u64, - /// When each recent miss happened (to time the render that follows it). + /// When each recent miss happened, to time the render that follows it (the drop generation + /// a render's reads started at travels with its files instead, [`ReadFiles::since`]: two + /// renders of a key may overlap). missed_at: HashMap, } @@ -80,6 +91,7 @@ impl LayerCache { inner: Mutex::new(Inner { map: HashMap::new(), bytes: 0, budget, clock: 0, hits: 0, misses: 0, missed_at: HashMap::new() }), disk: Mutex::new(None), store: Mutex::new(None), + virtual_id: Mutex::new(None), gate: Mutex::new(None), } } @@ -98,77 +110,75 @@ impl LayerCache { self.gate.lock().ok().and_then(|g| g.as_ref().map(|g| g.load(std::sync::atomic::Ordering::Relaxed))).unwrap_or(false) } - /// Attach (or detach) the disk cache. `salt` separates projects and footage versions (see - /// `footage_salt`); keys on disk are `(salt, key)`. - pub fn set_disk(&self, disk: Option<(Arc, u64)>) { + /// Attach (or detach) the disk cache. It is used at once: its entries check their own + /// source files when read, so nothing has to be looked at first. + pub fn set_disk(&self, disk: Option>) { if let Ok(mut d) = self.disk.lock() { *d = disk; } } - /// Change the disk salt (after footage changed) keeping the attached disk cache. - pub fn set_disk_salt(&self, salt: u64) { - if let Ok(mut d) = self.disk.lock() - && let Some((_, s)) = d.as_mut() - { - *s = salt; - } - } - + /// The attached disk cache, if any. pub fn disk(&self) -> Option> { - self.disk.lock().ok()?.as_ref().map(|d| d.0.clone()) + self.disk.lock().ok()?.clone() } /// Attach (or detach) a [`LayerStore`] used when no disk cache is attached (the browser's - /// Origin Private File System through a [`PrefetchStore`]); `salt` as in - /// [`LayerCache::set_disk`]. - pub fn set_store(&self, store: Option<(Arc, u64)>) { + /// Origin Private File System through a [`PrefetchStore`]). + pub fn set_store(&self, store: Option>) { if let Ok(mut s) = self.store.lock() { *s = store; } } - /// Change the store's salt (a new project or footage), keeping the store. - pub fn set_store_salt(&self, salt: u64) { - if let Ok(mut d) = self.store.lock() - && let Some((_, s)) = d.as_mut() - { - *s = salt; + /// Size and content hash of files served from memory (the browser file table): what a + /// persistent entry keeps for them and checks them against. + pub fn set_virtual_id(&self, id: Option) { + if let Ok(mut v) = self.virtual_id.lock() { + *v = id; } } - fn disk_key(&self, key: u64) -> Option<(Arc, u128)> { - let salted = |salt: u64| ((salt as u128) << 64) | key as u128; - if let Some((dc, salt)) = self.disk.lock().ok()?.as_ref() { - return Some((dc.clone() as Arc, salted(*salt))); - } - let s = self.store.lock().ok()?; - let (st, salt) = s.as_ref()?; - Some((st.clone(), salted(*salt))) + /// The attached virtual-file identity lookup, if any. + fn virtual_id(&self) -> Option { + self.virtual_id.lock().ok()?.clone() } - pub fn get(&self, key: u64) -> Option> { - let mut g = self.inner.lock().ok()?; - g.clock += 1; - let now = g.clock; - match g.map.get_mut(&key) { - Some(e) => { - e.last_use = now; - let b = e.buf.clone(); - g.hits += 1; - Some(b) - } + /// The persistent store behind the memory: the disk cache, else the other store. + fn persistent(&self) -> Option> { + if let Some(dc) = self.disk.lock().ok()?.as_ref() { + return Some(dc.clone() as Arc); + } + self.store.lock().ok()?.clone() + } + + /// The buffer kept under `key` and the source files it was made of (see [`crate::reads`]). + /// A buffer from the disk cache or another store comes with the files its entry lists, after + /// they were found unchanged. A buffer made of a file dropped since its reads began (File ▸ + /// Reload Footage) is never served: it is forgotten, and the lookup is a miss. + pub fn get(&self, key: u64) -> Option<(Arc, Arc)> { + match self.kept(key, true) { + Some(found) => Some(found), None => { + let mut g = self.inner.lock().ok()?; g.misses += 1; - if g.missed_at.len() > 4096 { - g.missed_at.clear(); - } - g.missed_at.insert(key, web_time::Instant::now()); drop(g); - // Disk cache: a hit comes back into memory. - let (dc, dk) = self.disk_key(key)?; - let buf = dc.get_layer(dk)?; - Some(self.insert_mem(key, buf)) + let store = self.persistent(); + // (before the entry is read: what it is made of may be dropped meanwhile) + let since = store.as_ref().map_or(0, |s| s.drop_generation()); + if let Ok(mut g) = self.inner.lock() { + if g.missed_at.len() > 4096 { + g.missed_at.clear(); + } + g.missed_at.insert(key, web_time::Instant::now()); + } + // Disk cache: a hit comes back into memory, read at generation `since`. + let store = store?; + let id = self.virtual_id(); + let (buf, mut files) = store.get_layer(layer_disk_key(key), id.as_deref())?; + files.note_since(since); + let files = Arc::new(files); + Some(self.insert_mem(key, buf, files)) } } } @@ -178,39 +188,113 @@ impl LayerCache { /// together each rendered every layer; sharing the first buffer, the GPU compositor uploads /// and packs it once instead of once per worker, which ran it out of memory). Renders don't /// wait for each other: on the frame workers' shared thread pool a wait can end up under - /// the very work it waits for. - pub fn insert(&self, key: u64, buf: Arc) -> Arc { + /// the very work it waits for. `files`: the source files `buf` was made of (see + /// [`crate::reads`]); a buffer whose files can't all be named stays in memory. The files + /// returned are the ones the returned buffer was made of. + /// + /// The drop generation `buf`'s reads started at comes with `files` ([`ReadFiles::since`], + /// from [`LayerCache::drop_generation`] when the render began): a buffer made of a file + /// dropped since (File ▸ Reload Footage while it rendered) is kept neither on disk nor in + /// memory. A list that doesn't say counts as made before every drop. A buffer made of values + /// its key doesn't capture ([`ReadFiles::is_unkeyed`]) stays in memory. + pub fn insert(&self, key: u64, buf: Arc, files: Arc) -> (Arc, Arc) { if self.gated() { - return buf; + return (buf, files); } - if let Some(kept) = self.inner.lock().ok().and_then(|g| g.map.get(&key).map(|e| e.buf.clone())) { + if let Some(kept) = self.kept(key, false) { return kept; } + let store = self.persistent(); + let since = files.since().unwrap_or(0); + // (before the miss's time is taken: it may time another render of the key) + if store.as_ref().is_some_and(|s| s.dropped_since(&files, since)) { + return (buf, files); + } let started = self.inner.lock().ok().and_then(|mut g| g.missed_at.remove(&key)); - if let Some((dc, dk)) = self.disk_key(key) { - let slow = started.is_some_and(|t| t.elapsed().as_secs_f64() * 1e3 >= dc.min_layer_ms() as f64); - if slow { - dc.put_layer(dk, &buf); + if let Some(store) = store { + let id = self.virtual_id(); + // (an incomplete list, or a file from memory without its identity: not stored) + if let Some(t) = started + && !files.is_unkeyed() + && t.elapsed().as_secs_f64() * 1e3 >= store.min_layer_ms() as f64 + && let Some(entry) = EntryFiles::of(&files, id.as_deref()) + { + store.put_layer(layer_disk_key(key), &buf, &entry, since); } } - self.insert_mem(key, buf) + self.insert_mem(key, buf, files) } - /// See [`LayerCache::insert`]: the buffer kept under `key` (`buf`, unless one was kept first - /// or `buf` is too large to keep). - fn insert_mem(&self, key: u64, buf: Arc) -> Arc { + /// The buffer kept in memory under `key` with its files, if it is not made of a file dropped + /// since its reads began (one that is, is forgotten). `hit`: count it as a hit and as just + /// used. (A render that started before a drop may publish its buffer after the drop's + /// purge, Reload Footage clearing the cache while the buffer was being written: the check + /// here, not the order of the two, keeps it from being served.) + fn kept(&self, key: u64, hit: bool) -> Option<(Arc, Arc)> { + let found = { + let mut g = self.inner.lock().ok()?; + g.clock += 1; + let now = g.clock; + let e = g.map.get_mut(&key)?; + if hit { + e.last_use = now; + } + (e.buf.clone(), e.files.clone()) + }; + if self.made_before_a_drop(&found.1) { + if let Ok(mut g) = self.inner.lock() + && g.map.get(&key).is_some_and(|e| Arc::ptr_eq(&e.files, &found.1)) + && let Some(e) = g.map.remove(&key) + { + g.bytes = g.bytes.saturating_sub(e.bytes); + } + return None; + } + if hit && let Ok(mut g) = self.inner.lock() { + g.hits += 1; + } + Some(found) + } + + /// Whether a buffer made of `files` holds a file as it was before a drop that came after its + /// reads began ([`ReadFiles::since`]; a list that doesn't say counts as made before every + /// drop). + fn made_before_a_drop(&self, files: &ReadFiles) -> bool { + self.persistent().is_some_and(|s| s.dropped_since(files, files.since().unwrap_or(0))) + } + + /// The persistent store's drop generation now ([`LayerStore::drop_generation`]), taken by a + /// render before it starts reading (`None` without a store). + pub fn drop_generation(&self) -> Option { + self.persistent().map(|s| s.drop_generation()) + } + + /// See [`LayerCache::insert`]: the buffer kept under `key` and its files (`buf` and `files`, + /// unless one was kept first or `buf` is too large to keep). + fn insert_mem(&self, key: u64, buf: Arc, files: Arc) -> (Arc, Arc) { let bytes = buf.img.data.len() * std::mem::size_of::(); - let Ok(mut g) = self.inner.lock() else { return buf }; + let Ok(mut g) = self.inner.lock() else { return (buf, files) }; if bytes > g.budget / 4 { - return buf; + return (buf, files); } g.clock += 1; let now = g.clock; if let Some(e) = g.map.get_mut(&key) { - e.last_use = now; - return e.buf.clone(); + // (one made before a drop gives way: see [`LayerCache::kept`]) + if !self.made_before_a_drop(&e.files) { + e.last_use = now; + return (e.buf.clone(), e.files.clone()); + } + if let Some(old) = g.map.remove(&key) { + g.bytes = g.bytes.saturating_sub(old.bytes); + } } - g.map.insert(key, Entry { buf: buf.clone(), bytes, last_use: now }); + // (checked again just before it is published: a drop may have come since `insert` + // checked; one that comes later is caught when the buffer is looked up) + if self.made_before_a_drop(&files) { + return (buf, files); + } + g.map.insert(key, Entry { buf: buf.clone(), files: files.clone(), bytes, last_use: now }); g.bytes += bytes; if g.bytes > g.budget { // Evict least-recently used entries down to 3/4 of the budget. @@ -226,7 +310,7 @@ impl LayerCache { } } } - buf + (buf, files) } /// The memory budget in bytes. @@ -264,12 +348,28 @@ impl LayerCache { } } -/// Persistent storage of layer buffers behind a [`LayerCache`] (keys salted per project). +/// Persistent storage of layer buffers behind a [`LayerCache`] (keys: +/// [`crate::disk_cache::layer_disk_key`]). Every entry keeps the source files its buffer was made +/// of and is used only while they are unchanged (#3184). pub trait LayerStore: Send + Sync { - /// A stored buffer (synchronous: mid-render). - fn get_layer(&self, key: u128) -> Option>; - /// Keep a buffer that was slow to render. - fn put_layer(&self, key: u128, buf: &Buf); + /// A stored buffer whose source files are unchanged, with those files (synchronous: + /// mid-render). An entry whose files changed, or damaged, is a miss and is dropped, so the + /// buffer rendered again replaces it. `virtual_id` names files served from memory. + fn get_layer(&self, key: u128, virtual_id: Option>) -> Option<(Arc, ReadFiles)>; + /// Keep a buffer that was slow to render, with the source files it was made of. `since`: the + /// [`LayerStore::drop_generation`] when its render started (a buffer made of a file dropped + /// after that is refused). + fn put_layer(&self, key: u128, buf: &Buf, files: &EntryFiles, since: u64); + /// How many times files were dropped from the store (File ▸ Reload Footage, + /// [`crate::disk_cache::DiskCache::drop_files`]); `0` for a store that never drops. + fn drop_generation(&self) -> u64 { + 0 + } + /// Whether one of `files` was dropped after generation `since`: what was made of them is + /// from before the drop. `false` for a store that never drops. + fn dropped_since(&self, _files: &ReadFiles, _since: u64) -> bool { + false + } /// Buffers faster to render than this (ms) are not stored. fn min_layer_ms(&self) -> u64 { 0 @@ -281,9 +381,10 @@ pub const PREFETCH_MIN_LAYER_MS: u64 = 20; /// A [`LayerStore`] over storage that can't be read in the middle of a render (the browser's /// Origin Private File System, read asynchronously). Lookups are served from buffers fetched -/// before the frame ([`PrefetchStore::provide`]); the keys a render looked up and didn't find -/// are recorded ([`PrefetchStore::take_misses`]) so the transport can fetch them before the next -/// frame; buffers to store queue up as sealed entries ([`PrefetchStore::take_writes`]) for the +/// before the frame ([`PrefetchStore::provide`]), once the files their entries list are found +/// unchanged; the keys a render looked up and didn't find are recorded +/// ([`PrefetchStore::take_misses`]) so the transport can fetch them before the next frame; +/// buffers to store queue up as sealed entries ([`PrefetchStore::take_writes`]) for the /// transport to write after the frame. Fetched buffers are kept least recently used first, up /// to a byte budget. pub struct PrefetchStore { @@ -292,9 +393,21 @@ pub struct PrefetchStore { min_ms: u64, } +/// A fetched entry: the buffer, the source files it was made of, its last use. +struct Fetched { + buf: Arc, + files: Arc, + used: u64, +} + +/// The memory a buffer's pixels take, in bytes. +fn buf_bytes(b: &Buf) -> usize { + b.img.data.len().saturating_mul(std::mem::size_of::()) +} + #[derive(Default)] struct Prefetched { - ready: HashMap, u64)>, + ready: HashMap, bytes: usize, clock: u64, misses: Vec, @@ -304,6 +417,22 @@ struct Prefetched { known: std::collections::HashSet, } +impl Prefetched { + /// Records a missed key for prefetching, up to a fixed bound. + fn note_miss(&mut self, key: u128) { + if !self.misses.contains(&key) && self.misses.len() < 1024 { + self.misses.push(key); + } + } + + /// Drops the prefetched entry for `key` and frees its bytes. + fn remove(&mut self, key: u128) { + if let Some(f) = self.ready.remove(&key) { + self.bytes = self.bytes.saturating_sub(buf_bytes(&f.buf)); + } + } +} + impl Default for PrefetchStore { fn default() -> Self { PrefetchStore::new(256 << 20) @@ -327,23 +456,21 @@ impl PrefetchStore { } /// A fetched entry (sealed bytes, [`crate::disk_cache::layer_entry`]) for `key`; `false` - /// when damaged. + /// when damaged or of another format. Its files are checked when the buffer is used. pub fn provide(&self, key: u128, entry: &[u8]) -> bool { - let Some(buf) = crate::disk_cache::read_layer_entry(entry) else { return false }; - let bytes = buf.img.data.len() * std::mem::size_of::(); + let Some((buf, files)) = crate::disk_cache::read_layer_entry(entry) else { return false }; + let buf = Arc::new(buf); + let bytes = buf_bytes(&buf); let Ok(mut s) = self.st.lock() else { return false }; s.clock += 1; - let now = s.clock; - if let Some((old, _)) = s.ready.insert(key, (Arc::new(buf), now)) { - s.bytes -= old.img.data.len() * std::mem::size_of::(); - } - s.bytes += bytes; + let used = s.clock; + s.remove(key); + s.ready.insert(key, Fetched { buf, files: Arc::new(files), used }); + s.bytes = s.bytes.saturating_add(bytes); s.known.insert(key); while s.bytes > self.budget { - let Some((&k, _)) = s.ready.iter().min_by_key(|(_, (_, t))| *t) else { break }; - if let Some((b, _)) = s.ready.remove(&k) { - s.bytes -= b.img.data.len() * std::mem::size_of::(); - } + let Some(oldest) = s.ready.iter().min_by_key(|(_, f)| f.used).map(|(k, _)| *k) else { break }; + s.remove(oldest); } true } @@ -372,32 +499,46 @@ impl PrefetchStore { } impl LayerStore for PrefetchStore { - fn get_layer(&self, key: u128) -> Option> { + /// The prefetched layer for `key` with its read files, or `None` when it is not ready. + fn get_layer(&self, key: u128, virtual_id: Option>) -> Option<(Arc, ReadFiles)> { + let found = { + let mut s = self.st.lock().ok()?; + s.clock += 1; + let now = s.clock; + match s.ready.get_mut(&key) { + Some(f) => { + f.used = now; + Some((f.buf.clone(), f.files.clone())) + } + None => { + s.note_miss(key); + None + } + } + }; + let (buf, files) = found?; + // (looked at without the lock: one call of the hook, or one `metadata`, per file) + let unchanged = files.unchanged(virtual_id); let mut s = self.st.lock().ok()?; - s.clock += 1; - let now = s.clock; - match s.ready.get_mut(&key) { - Some((b, t)) => { - *t = now; - let b = b.clone(); - if !s.hits.contains(&key) { - s.hits.push(key); - } - Some(b) - } - None => { - if !s.misses.contains(&key) && s.misses.len() < 1024 { - s.misses.push(key); - } - None - } + if !unchanged { + // Made of files that changed since: forgotten, so the buffer rendered again is + // written over its entry. + s.remove(key); + s.known.remove(&key); + s.note_miss(key); + return None; } + if !s.hits.contains(&key) { + s.hits.push(key); + } + Some((buf, files.to_read_files())) } - fn put_layer(&self, key: u128, buf: &Buf) { + /// Queues the layer for writing to the persistent store the first time `key` is seen. + fn put_layer(&self, key: u128, buf: &Buf, files: &EntryFiles, _since: u64) { let Ok(mut s) = self.st.lock() else { return }; if s.known.insert(key) { - s.writes.push((key, crate::disk_cache::layer_entry(buf))); + s.writes.push((key, crate::disk_cache::layer_entry(buf, files))); } } @@ -470,6 +611,12 @@ fn time_dependent(layer: &Layer, effects: bool) -> bool { false } +/// A text layer with an Expression Selector: its amount is evaluated per character, and the +/// layer's key holds only the first character's (see [`crate::reads::ReadFiles::is_unkeyed`]). +pub(crate) fn per_character_expressions(layer: &Layer) -> bool { + matches!(layer.source, LayerSource::Text) && layer.props.sub("text").is_some_and(|t| group_has(t, "expressionSelector")) +} + fn group_has(g: &PropGroup, match_id: &str) -> bool { g.children.iter().any(|c| match c { Node::Group(sub) => sub.match_id == match_id || group_has(sub, match_id), @@ -741,6 +888,21 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo Some(h.finish()) } +/// Does the layer read another layer through an enabled effect's layer parameter (Gradient Wipe, +/// Set Matte…)? Those layers are drawn by the reading layer's renderer, so the switches it +/// passes down belong in the key (`Renderer::nested_look`). +pub(crate) fn reads_layers(ctx: &EvalCtx, layer: &Layer) -> bool { + /// Whether any enabled layer-valued property of `layer` points at another layer. + fn reads(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, depth: usize) -> bool { + depth < 32 + && g.children.iter().any(|n| match n { + Node::Prop(p) => matches!(p.value, Value::Layer(_)) && ctx.value(layer, p).as_layer().is_some_and(|id| id != layer.id.0), + Node::Group(sub) => sub.enabled && reads(ctx, layer, sub, depth + 1), + }) + } + layer.switches.effects && layer.effects().is_some_and(|fx| fx.groups().any(|g| g.enabled && reads(ctx, layer, g, 0))) +} + /// Does the layer run an effect that reads the comp camera or lights (Card Dance, CC Particle /// World…)? fn reads_comp_scene(layer: &Layer) -> bool { @@ -786,8 +948,211 @@ mod shared_insert_tests { let cache = LayerCache::default(); let buf = || Arc::new(Buf { img: Image::new(4, 4), offset: [0.0; 2], scale: 1.0 }); let (a, b) = (buf(), buf()); - assert!(Arc::ptr_eq(&cache.insert(3, a.clone()), &a)); - assert!(Arc::ptr_eq(&cache.insert(3, b), &a), "the later render gets the first buffer"); - assert!(Arc::ptr_eq(&cache.get(3).unwrap(), &a)); + assert!(Arc::ptr_eq(&cache.insert(3, a.clone(), Default::default()).0, &a)); + assert!(Arc::ptr_eq(&cache.insert(3, b, Default::default()).0, &a), "the later render gets the first buffer"); + assert!(Arc::ptr_eq(&cache.get(3).unwrap().0, &a)); + } + + /// The browser's store (#3184): a fetched entry is used only while the files it lists are + /// unchanged (here from memory, through the hook), and comes with them; one made of files + /// that changed is dropped and counts as a miss, so the buffer rendered again is queued over + /// it. A buffer whose files can't all be named is not queued at all. + #[test] + fn the_prefetch_store_checks_the_files_of_its_entries() { + use super::{LayerStore, PrefetchStore}; + use crate::disk_cache::{EntryFiles, layer_disk_key, layer_entry}; + use crate::reads::{FileStamp, ReadFile, ReadFiles}; + let buf = || Buf { img: Image::new(2, 2), offset: [0.0; 2], scale: 1.0 }; + let files: ReadFiles = [ReadFile::new("/v.png", FileStamp::Virtual)].into_iter().collect(); + let (one, two) = (|_: &str| Some((3, 1)), |_: &str| Some((3, 2))); + let entry = layer_entry(&buf(), &EntryFiles::of(&files, Some(&one)).unwrap()); + let store = PrefetchStore::new(1 << 20).with_min_ms(0); + assert!(store.provide(5, &entry)); + assert!(!store.provide(6, b"damaged")); + let (_, got) = store.get_layer(5, Some(&one)).unwrap(); + assert_eq!(got, files); + assert_eq!(store.take_hits(), [5]); + // The file changed: a miss, forgotten, and the new buffer is queued over it. + assert!(store.get_layer(5, Some(&two)).is_none()); + assert!(!store.has(5)); + assert_eq!(store.take_misses(), [5]); + store.put_layer(5, &buf(), &EntryFiles::of(&files, Some(&two)).unwrap(), 0); + assert_eq!(store.take_writes().len(), 1, "written over the changed entry"); + // Through the layer cache: a hit reports its files; incomplete lists are not queued. + let cache = LayerCache::default(); + let store = Arc::new(PrefetchStore::new(1 << 20).with_min_ms(0)); + cache.set_store(Some(store.clone())); + cache.set_virtual_id(Some(Arc::new(one))); + assert!(store.provide(layer_disk_key(9), &entry)); + let (_, from_store) = cache.get(9).unwrap(); + assert!(*from_store == files && from_store.is_complete()); + let mut incomplete = files.clone(); + incomplete.mark_incomplete(); + assert!(cache.get(10).is_none()); + cache.insert(10, Arc::new(buf()), Arc::new(incomplete)); + assert!(cache.get(11).is_none()); + cache.insert(11, Arc::new(buf()), Arc::new(files.clone())); + let written: Vec = store.take_writes().into_iter().map(|w| w.0).collect(); + assert_eq!(written, [layer_disk_key(11)], "only the buffer whose files are all named"); + } + + /// A layer buffer whose render started before Reload Footage dropped its file (#3184) is + /// not written to the disk cache when it is kept afterwards; one whose render started after + /// the drop is. + #[test] + fn a_buffer_rendered_across_a_drop_is_not_written() { + use crate::disk_cache::{DiskCache, Kind, layer_disk_key}; + use crate::reads::{FileStamp, ReadFile, ReadLog}; + let dir = tempfile::tempdir().unwrap(); + let dc = DiskCache::open(dir.path(), 1 << 30).unwrap(); + dc.set_min_layer_ms(0); + let cache = LayerCache::default(); + cache.set_disk(Some(dc.clone())); + let buf = || Arc::new(Buf { img: Image::new(2, 2), offset: [0.0; 2], scale: 1.0 }); + // A render's files, read after it took the drop generation (`Renderer::recorded`). + let render = || { + let log = ReadLog::starting_at(cache.drop_generation().unwrap()); + move || { + log.note(ReadFile::new("/a.png", FileStamp::Native { len: 3, modified: Some(1) })); + Arc::new(log.into_files()) + } + }; + // The miss starts the render; the file is reloaded before the buffer is kept. + assert!(cache.get(1).is_none()); + let a = render(); + dc.drop_files(["/a.png"]); + dc.flush(); + // Another render of the same key starts after the drop, while the first one still runs + // (its miss must not lend the first render its generation). + assert!(cache.get(1).is_none()); + let b = render(); + let (stale, _) = cache.insert(1, buf(), a()); + dc.flush(); + assert!(!dc.contains(Kind::Layer, layer_disk_key(1)), "made of the file as it was before the drop"); + // Not kept in memory either: the second render keeps its own buffer, and writes it. + let fresh = buf(); + let (kept, _) = cache.insert(1, fresh.clone(), b()); + assert!(Arc::ptr_eq(&kept, &fresh) && !Arc::ptr_eq(&kept, &stale)); + // A render of another key after the drop. + assert!(cache.get(2).is_none()); + cache.insert(2, buf(), render()()); + dc.flush(); + assert!(dc.contains(Kind::Layer, layer_disk_key(1)), "the render after the drop"); + assert!(dc.contains(Kind::Layer, layer_disk_key(2))); + // A list that doesn't say when its reads began counts as made before the drop. + assert!(cache.get(3).is_none()); + cache.insert(3, buf(), Arc::new([ReadFile::new("/a.png", FileStamp::Native { len: 3, modified: Some(1) })].into_iter().collect())); + dc.flush(); + assert!(!dc.contains(Kind::Layer, layer_disk_key(3))); + drop((cache, dc)); + } + + /// A disk cache whose layer writes stop at a barrier until the test lets them go on. + struct PausedWrites { + dc: Arc, + arrived: std::sync::Barrier, + go_on: std::sync::Barrier, + } + + impl super::LayerStore for PausedWrites { + fn get_layer(&self, key: u128, virtual_id: Option>) -> Option<(Arc, crate::reads::ReadFiles)> { + super::LayerStore::get_layer(&*self.dc, key, virtual_id) + } + fn put_layer(&self, key: u128, buf: &Buf, files: &crate::disk_cache::EntryFiles, since: u64) { + self.arrived.wait(); + self.go_on.wait(); + super::LayerStore::put_layer(&*self.dc, key, buf, files, since); + } + fn drop_generation(&self) -> u64 { + self.dc.drop_generation() + } + fn dropped_since(&self, files: &crate::reads::ReadFiles, since: u64) -> bool { + self.dc.dropped_since(files, since) + } + } + + /// Reload Footage while a render from before it is writing its buffer: the cache is cleared + /// and the file dropped after the buffer passed its check, before it is kept. The buffer + /// then reaches memory after the purge, but it is never served, and a render after the drop + /// keeps its own buffer (#3191 review). + #[test] + fn a_buffer_kept_after_the_purge_of_a_reload_is_not_served() { + use crate::reads::{FileStamp, ReadFile, ReadLog}; + let dir = tempfile::tempdir().unwrap(); + let dc = crate::disk_cache::DiskCache::open(dir.path(), 1 << 30).unwrap(); + let store = Arc::new(PausedWrites { dc: dc.clone(), arrived: std::sync::Barrier::new(2), go_on: std::sync::Barrier::new(2) }); + let cache = Arc::new(LayerCache::default()); + cache.set_store(Some(store.clone())); + let buf = || Arc::new(Buf { img: Image::new(2, 2), offset: [0.0; 2], scale: 1.0 }); + let files = |cache: &LayerCache| { + let log = ReadLog::starting_at(cache.drop_generation().unwrap()); + log.note(ReadFile::new("/a.png", FileStamp::Native { len: 3, modified: Some(1) })); + Arc::new(log.into_files()) + }; + // The old render: a miss, its reads, then its insert, which stops in the disk write. + assert!(cache.get(1).is_none()); + let old_files = files(&cache); + let old = buf(); + let writer = { + let (cache, old) = (cache.clone(), old.clone()); + std::thread::spawn(move || cache.insert(1, old, old_files)) + }; + store.arrived.wait(); + // Reload Footage meanwhile: the layer cache is purged and the file dropped. + cache.clear(); + dc.drop_files(["/a.png"]); + dc.flush(); + store.go_on.wait(); + let _ = writer.join().unwrap(); + dc.flush(); + assert!(!dc.contains(crate::disk_cache::Kind::Layer, crate::disk_cache::layer_disk_key(1)), "not on disk"); + assert!(cache.get(1).is_none(), "the old pixels are not served from memory"); + // A render after the drop keeps its own buffer. + let fresh = buf(); + let new_files = files(&cache); + let mut kept = None; + std::thread::scope(|s| { + s.spawn(|| { + store.arrived.wait(); + store.go_on.wait(); + }); + kept = Some(cache.insert(1, fresh.clone(), new_files).0); + }); + let kept = kept.unwrap(); + assert!(Arc::ptr_eq(&kept, &fresh) && !Arc::ptr_eq(&kept, &old)); + assert!(Arc::ptr_eq(&cache.get(1).unwrap().0, &fresh)); + } + + /// A render that gets another render's buffer gets that buffer's files too (#3184): when a + /// file changed between the two reads, its frame lists the file with both identities, so the + /// list is incomplete and the frame is not kept on disk with the new identity and the old + /// pixels. + #[test] + fn the_buffer_kept_first_brings_its_files() { + use crate::reads::{FileStamp, ReadFile, ReadFiles, ReadLog}; + use crate::{NoFootage, RenderOpts, Renderer}; + let cache = LayerCache::default(); + let buf = || Arc::new(Buf { img: Image::new(4, 4), offset: [0.0; 2], scale: 1.0 }); + let files = |len| Arc::new([ReadFile::new("/a.png", FileStamp::Native { len, modified: Some(1) })].into_iter().collect::()); + let (first, second) = (files(1), files(2)); + let a = buf(); + cache.insert(3, a.clone(), first.clone()); + let project = effectcraft_project::Project::default(); + let log = ReadLog::new(); + let mut r = Renderer::new(&project, &NoFootage, RenderOpts::default()); + r.cache = Some(&cache); + r.reads = Some(&log); + // (what `Renderer::recorded` noted for the second render) + log.note_all(&second); + let (kept, kept_files) = r.keep(Some(3), buf(), &second); + assert!(Arc::ptr_eq(&kept, &a)); + assert_eq!(kept_files, first); + assert!(!log.files().is_complete()); + // The same files: nothing changes. + let same = ReadLog::new(); + r.reads = Some(&same); + same.note_all(&first); + let _ = r.keep(Some(3), buf(), &files(1)); + assert!(same.files().is_complete()); } } diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index db1465c07..dc4216098 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -156,7 +156,7 @@ impl Dependencies<'_> { layer.layer_time(ctx.time).0.hash(self.h); } match layer.source { - LayerSource::Solid { item } | LayerSource::Footage { item } | LayerSource::Comp { item } => { + LayerSource::Solid { item } | LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Model { item } => { let item = match layer.source { LayerSource::Footage { .. } => ctx.media_item(layer, item), _ => item, diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index 427198ad2..f509a5896 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -2,15 +2,25 @@ //! and layer cache entries kept on disk between sessions, like After Effects' disk cache. //! //! * Entries are files keyed by a 128-bit content hash (`/v1//<2 hex>/<32 hex>.ecc`): -//! frames by the hash of the composition's content (the comp and everything it uses, footage -//! files' size and modification time included) plus frame, scale and view; layer buffers by -//! the layer cache's content key salted with the project's footage fingerprint. A changed -//! project therefore never reads stale pixels: its keys simply change. +//! frames by the hash of the composition's content as it is in memory (the comp and what it can +//! draw, footage items with their settings and paths, and what its expressions can read; no +//! file is looked at, [`comp_content_key`]) plus frame, scale and view; layer buffers by the +//! layer cache's content key (which names the footage items a buffer uses with their settings +//! and paths) and the renderer version ([`layer_disk_key`]). A changed project therefore never +//! reads stale pixels: its keys simply change, and an edit elsewhere keeps them. +//! * Frame and layer entries keep the source files they were made of (footage, and the font +//! files text is drawn with), each with the size and modification time it was read with +//! ([`EntryFiles`], #3184). An entry is used only while those files are unchanged: a handful +//! of `metadata` calls when it is read, never a scan of the project's files. A frame or +//! buffer whose files can't all be named is not stored (text in a family that isn't installed +//! or with fallback glyphs, footage refused by automation read roots, …). File ▸ Reload +//! Footage deletes the entries made of the reloaded files ([`DiskCache::drop_files`]): a file +//! replaced by one of the same size and time is read again. //! * Payloads are LZ4-compressed (`lz4_flex`, MIT): frames as 8-bit premultiplied RGBA (or, for //! 32 bpc viewer frames, half-float RGBA with the bytes shuffled into planes; the entry -//! header's format byte tells them apart, 8-bit entries keep format 0), layer -//! buffers as `f32` RGBA with the bytes shuffled into planes (exact, so cached and fresh -//! renders are bit-identical), Roto Brush / Object Matte segmentations as +//! header's format byte tells them apart: 2 and 3, both with their source files first), layer +//! buffers (format 4, their source files first) as `f32` RGBA with the bytes shuffled into +//! planes (exact, so cached and fresh renders are bit-identical), Roto Brush / Object Matte segmentations as //! `FrameSeg::to_bytes` (keyed by their content-keyed chain key and scale, so they are //! reused whenever the same frames, settings and prompt come back, also after a restart). Every file ends in a checksum; damaged files read as misses and //! are deleted. @@ -32,6 +42,11 @@ use std::sync::{Arc, Condvar, Mutex}; use effectcraft_effects::Buf; use effectcraft_raster::Image; +use crate::reads::ReadFiles; + +mod entry_files; +pub use entry_files::{EntryFile, EntryFiles, EntryStamp}; + /// File format version (part of the folder layout). const VERSION: &str = "v1"; const MAGIC: &[u8; 4] = b"ECDC"; @@ -87,9 +102,16 @@ pub struct FrameF16 { pub rgba: Vec, } -/// The entry header's format byte of frame entries. +/// The entry header's format byte: segmentations (and 8-bit frames and layer buffers before +/// #3184). const FORMAT_RGBA8: u8 = 0; -const FORMAT_F16: u8 = 1; +/// Frame entries: their source files ([`EntryFiles`]), then 8-bit or half-float pixels. The +/// formats before them (0: 8-bit, 1: half float, without files) read as misses and are deleted. +const FORMAT_RGBA8_FILES: u8 = 2; +const FORMAT_F16_FILES: u8 = 3; +/// Layer entries: their source files ([`EntryFiles`]), then the buffer. The format before it +/// (0, without files) reads as a miss and is deleted. +const FORMAT_LAYER_FILES: u8 = 4; /// Counters for tests, the Info panel and `cache.diskStats`. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -220,7 +242,7 @@ pub struct LayerPrefetch { const PREFETCH_RECENT: usize = 512; impl LayerPrefetch { - /// A frame's layer-cache misses (salted keys). + /// A frame's layer-cache misses (disk keys, [`layer_disk_key`]). pub fn note_misses(&mut self, keys: impl IntoIterator) { for k in keys { self.recent.retain(|x| *x != k); @@ -243,6 +265,8 @@ impl LayerPrefetch { enum Job { /// Kind, key, header format byte, payload. Write(Kind, u128, u8, Vec), + /// Delete the frame and layer entries made of these files ([`DiskCache::drop_files`]). + Drop(Vec>), } struct Queue { @@ -255,14 +279,55 @@ struct Queue { busy: bool, /// Keys of frames queued or being written. pending_frames: std::collections::HashSet, + /// The drops so far (under the queue's lock: a write is queued either before a drop, which + /// then sweeps it, or after it, when this refuses it if it was made from before). + drops: Drops, +} + +/// Files dropped from the cache files beyond this are not remembered one by one: everything made +/// before the drop that forgot them is refused instead. +const MAX_DROPPED_PATHS: usize = 1 << 16; + +/// The [`DiskCache::drop_files`] calls so far: their number (the drop generation) and the last one +/// of each file. An entry made of a file read before that file's last drop (its render started at +/// an earlier generation) holds the pixels from before and is refused. +#[derive(Default)] +struct Drops { + generation: u64, + last: HashMap, u64>, + /// Everything made before this generation is refused (the per-file record was forgotten). + floor: u64, +} + +impl Drops { + /// Notes that the given paths were dropped, bumping the generation and tracking them for later checks. + fn record(&mut self, paths: &[Arc]) { + self.generation = self.generation.saturating_add(1); + if self.last.len().saturating_add(paths.len()) > MAX_DROPPED_PATHS { + self.last.clear(); + self.floor = self.generation; + return; + } + for p in paths { + self.last.insert(p.clone(), self.generation); + } + } + + /// Whether something made of `paths`, whose reads started at generation `since`, read one of + /// them before it was dropped. + fn stale<'a>(&self, mut paths: impl Iterator>, since: u64) -> bool { + since < self.floor || paths.any(|p| self.last.get(p).is_some_and(|g| *g > since)) + } } impl Queue { + /// Takes the next queued job, frames first, and releases the bytes it held. #[cfg(not(target_arch = "wasm32"))] fn pop(&mut self) -> Option { if let Some(j) = self.frames.pop_front() { - let Job::Write(_, _, _, p) = &j; - self.frame_bytes = self.frame_bytes.saturating_sub(p.len()); + if let Job::Write(_, _, _, p) = &j { + self.frame_bytes = self.frame_bytes.saturating_sub(p.len()); + } return Some(j); } self.jobs.pop_front() @@ -280,16 +345,25 @@ pub struct DiskCache { disk_cap: AtomicU64, /// Free space is limiting the cache (warned once per episode). space_limited: AtomicBool, + /// The free space the cache leaves on its disk ([`disk_reserve`]); 0 = unknown. + reserve: AtomicU64, hits: AtomicU64, misses: AtomicU64, writes: AtomicU64, evictions: AtomicU64, dropped: AtomicU64, failed: AtomicU64, + /// Source files looked at to check frame and layer entries ([`DiskCache::file_checks`]). + file_checks: AtomicU64, + /// Frame and layer entries compressed for a queued write ([`DiskCache::encodes`]). + encodes: AtomicU64, tmp_counter: AtomicU64, queue: Arc<(Mutex, Condvar)>, /// Layer buffers that took less than this to render are not written (ms). min_layer_ms: AtomicU64, + /// Files whose entries are being deleted ([`DiskCache::drop_files`]), each with the number + /// of drops of it pending: until then an entry made of one is a miss. + dropping: Mutex, u32>>, } /// FNV-1a 128-bit (content keys). @@ -326,6 +400,23 @@ fn checksum(data: &[u8]) -> u64 { h } +/// The least free space the disk cache leaves on its disk. +pub const MIN_DISK_RESERVE: u64 = 2 << 30; +/// The most free space the disk cache leaves on its disk: 5% of a large disk is far more than +/// the system needs to keep working (on a 4 TB disk it held a 20 GB cache at 0 with 190 GB free). +pub const MAX_DISK_RESERVE: u64 = 10 << 30; +/// Below this much room left by free disk space, frame and layer writes are refused before they +/// are encoded ([`DiskCache::accepts_writes`]): a few frames would fill it, and the rest would be +/// compressed only to be refused. +const MIN_WRITE_ROOM: u64 = 64 << 20; + +/// The free space the disk cache leaves on a disk of `total` bytes: 5% of it, at least 2 GB and +/// at most 10 GB. It keeps the cache from filling the disk; the cache's size is the user's +/// Maximum Disk Cache Size (After Effects has that maximum and no reserve). +pub fn disk_reserve(total: u64) -> u64 { + (total / 20).clamp(MIN_DISK_RESERVE, MAX_DISK_RESERVE) +} + /// The platform's default disk cache folder. pub fn default_folder() -> PathBuf { let home = std::env::var_os("HOME").map(PathBuf::from); @@ -356,12 +447,15 @@ impl DiskCache { max_bytes: AtomicU64::new(max_bytes), disk_cap: AtomicU64::new(u64::MAX), space_limited: AtomicBool::new(false), + reserve: AtomicU64::new(0), hits: AtomicU64::new(0), misses: AtomicU64::new(0), writes: AtomicU64::new(0), evictions: AtomicU64::new(0), dropped: AtomicU64::new(0), failed: AtomicU64::new(0), + file_checks: AtomicU64::new(0), + encodes: AtomicU64::new(0), tmp_counter: AtomicU64::new(0), queue: Arc::new(( Mutex::new(Queue { @@ -370,10 +464,12 @@ impl DiskCache { jobs: Default::default(), busy: cfg!(not(target_arch = "wasm32")), pending_frames: Default::default(), + drops: Drops::default(), }), Condvar::new(), )), min_layer_ms: AtomicU64::new(30), + dropping: Mutex::new(HashMap::new()), }); dc.rescan(); // Shrinking a large cache deletes many files: the writer thread does it. @@ -446,19 +542,22 @@ impl DiskCache { self.max_bytes().min(self.disk_cap.load(Ordering::Relaxed)) } - /// The cache's disk: `(total, available)` bytes, `None` = unknown. The cache leaves at least - /// 2 GB or 5% of the disk free, whichever is more (evicting when it must). + /// The cache's disk: `(total, available)` bytes, `None` = unknown. The cache leaves + /// [`disk_reserve`] free (5% of the disk, at least 2 GB and at most 10 GB), evicting when it + /// must. pub fn set_disk_space(&self, space: Option<(u64, u64)>) { + let mut reserve = 0; let cap = match space { // (a disk reported as 0 bytes, such as an automount map, is unknown) Some((total, available)) if total > 0 => { - let reserve = (2u64 << 30).max(total / 20); + reserve = disk_reserve(total); let used = self.index.lock().map(|g| g.total()).unwrap_or_default(); // The cache's own files count as free: below the reserve it gives space back. used.saturating_add(available).saturating_sub(reserve) } _ => u64::MAX, }; + self.reserve.store(reserve, Ordering::Relaxed); self.disk_cap.store(cap, Ordering::Relaxed); let limited = cap < self.max_bytes(); if self.space_limited.swap(limited, Ordering::Relaxed) != limited && limited { @@ -467,6 +566,33 @@ impl DiskCache { self.evict(); } + /// Whether frame and layer writes are worth encoding now: `false` while free disk space + /// holds the limit at 0 or under 64 MB (a limit the setting asks for is always used). Two + /// atomic reads, so writes check it before they spend time converting and compressing + /// pixels that [`DiskCache::write_format`] would refuse anyway. (Whether a given entry fits + /// is known only once it is compressed: `write_format` decides.) + pub fn accepts_writes(&self) -> bool { + let (max, limit) = (self.max_bytes(), self.limit_bytes()); + limit > 0 && (limit >= MIN_WRITE_ROOM || limit == max) + } + + /// The free space the cache leaves on its disk ([`disk_reserve`]), once the disk's size is + /// known ([`DiskCache::set_disk_space`]). + pub fn reserve_bytes(&self) -> Option { + Some(self.reserve.load(Ordering::Relaxed)).filter(|r| *r > 0) + } + + /// Frame and layer entries compressed for writing so far (tests: a write refused up front + /// costs no encode). + pub fn encodes(&self) -> u64 { + self.encodes.load(Ordering::Relaxed) + } + + /// Count one entry compressed for a queued write. + fn count_encode(&self) { + self.encodes.fetch_add(1, Ordering::Relaxed); + } + /// Layer buffers faster to render than this are not worth a disk write. pub fn set_min_layer_ms(&self, ms: u64) { self.min_layer_ms.store(ms, Ordering::Relaxed); @@ -541,36 +667,94 @@ impl DiskCache { /// An entry's payload and its header's format byte. fn read_entry(&self, kind: Kind, key: u128) -> Option<(u8, Vec)> { + let entry = self.load(kind, key); + match &entry { + Some(_) => self.hit(kind, key), + None => _ = self.misses.fetch_add(1, Ordering::Relaxed), + } + entry + } + + /// An entry's payload and format byte, without counting it as a hit or miss. A damaged file + /// is deleted; a missing one forgotten. + fn load(&self, kind: Kind, key: u128) -> Option<(u8, Vec)> { if !self.contains(kind, key) { - self.misses.fetch_add(1, Ordering::Relaxed); return None; } let path = self.path(kind, key); let data = std::fs::read(&path).ok(); - let ok = data.as_deref().and_then(|d| verify(d, kind).map(|p| (d[5], p))); - match ok { - Some((format, payload)) => { - let payload = (format, payload.to_vec()); - self.hits.fetch_add(1, Ordering::Relaxed); - if let Ok(mut g) = self.index.lock() { - g.touch(kind, key); - } - // Persist the recency for the next session (best effort). - if let Ok(f) = std::fs::File::options().append(true).open(&path) { - let _ = f.set_modified(std::time::SystemTime::now()); - } - Some(payload) - } - None => { - self.misses.fetch_add(1, Ordering::Relaxed); - // Gone (another process evicted it) or damaged: forget it. - if data.is_some() { - let _ = std::fs::remove_file(&path); - } - self.forget(kind, key); - None + let ok = data.as_deref().and_then(|d| Some((*d.get(5)?, verify(d, kind)?.to_vec()))); + if ok.is_none() { + // Gone (another process evicted it) or damaged: forget it. + if data.is_some() { + let _ = std::fs::remove_file(&path); } + self.forget(kind, key); } + ok + } + + /// Count a hit: the entry becomes the most recently used, also for the next session. + fn hit(&self, kind: Kind, key: u128) { + self.hits.fetch_add(1, Ordering::Relaxed); + if let Ok(mut g) = self.index.lock() { + g.touch(kind, key); + } + // Persist the recency for the next session (best effort). + if let Ok(f) = std::fs::File::options().append(true).open(self.path(kind, key)) { + let _ = f.set_modified(std::time::SystemTime::now()); + } + } + + /// An entry of `kind` and `format` whose source files are unchanged, decoded by `decode`, + /// with those files. An entry whose files changed, of the format before #3184, or damaged, + /// is a miss and is deleted (it renders again and is stored anew). A frame entry of the + /// other current frame format (8-bit and half-float frames have keys of their own) is a miss + /// and stays. + fn get_checked( + &self, + kind: Kind, + key: u128, + format: u8, + virtual_id: Option>, + decode: impl FnOnce(&[u8]) -> Option, + ) -> Option<(T, ReadFiles)> { + let miss = || { + self.misses.fetch_add(1, Ordering::Relaxed); + None + }; + // (before the file is read: the writer may finish a drop's sweep between the read and the + // check below, and a drop may start meanwhile) + let drops = self.drops_now(); + let Some((found, payload)) = self.load(kind, key) else { return miss() }; + if found != format { + let other_frame_format = kind == Kind::Frame && (found == FORMAT_RGBA8_FILES || found == FORMAT_F16_FILES); + if !other_frame_format { + self.discard(kind, key); + } + return miss(); + } + let Some((files, body)) = EntryFiles::decode(&payload) else { + self.discard(kind, key); + return miss(); + }; + self.file_checks.fetch_add(files.len() as u64, Ordering::Relaxed); + if self.made_of_dropped(&files, &drops) || !files.unchanged(virtual_id) { + self.discard(kind, key); + return miss(); + } + let Some(value) = decode(body) else { + self.discard(kind, key); + return miss(); + }; + self.hit(kind, key); + Some((value, files.to_read_files())) + } + + /// Source files looked at so far to check frame and layer entries before using them (one per + /// file a read entry lists; tests). + pub fn file_checks(&self) -> u64 { + self.file_checks.load(Ordering::Relaxed) } /// A valid file whose payload does not decode: delete it, as a damaged one. @@ -587,13 +771,19 @@ impl DiskCache { /// Queue a write; `false` when it was dropped (a full queue). fn enqueue(&self, kind: Kind, key: u128, payload: Vec) -> bool { - self.enqueue_format(kind, key, FORMAT_RGBA8, payload) + self.enqueue_format(kind, key, FORMAT_RGBA8, payload, None) } - /// [`DiskCache::enqueue`] with the entry's header format byte. - fn enqueue_format(&self, kind: Kind, key: u128, format: u8, payload: Vec) -> bool { + /// [`DiskCache::enqueue`] with the entry's header format byte. `made`: the source files of a + /// frame or layer entry and the drop generation its reads started at + /// ([`DiskCache::drop_generation`]); refused (`false`) when one of them was dropped since. + fn enqueue_format(&self, kind: Kind, key: u128, format: u8, payload: Vec, made: Option<(&EntryFiles, u64)>) -> bool { + let stale = |q: &Queue| made.is_some_and(|(files, since)| q.drops.stale(files.files().iter().map(|f| &f.path), since)); #[cfg(target_arch = "wasm32")] { + if self.queue.0.lock().is_ok_and(|q| stale(&q)) { + return false; + } self.write_format(kind, key, format, &payload); true } @@ -601,6 +791,9 @@ impl DiskCache { { let (m, cv) = &*self.queue; let Ok(mut q) = m.lock() else { return false }; + if stale(&q) { + return false; + } let full = match kind { Kind::Frame => q.frame_bytes.saturating_add(payload.len()) > FRAME_QUEUE_BYTES, _ => q.jobs.len() >= QUEUE_LIMIT, @@ -622,10 +815,6 @@ impl DiskCache { } /// Write an entry now (atomic rename), then evict down to the limit. - fn write_now(&self, kind: Kind, key: u128, payload: &[u8]) { - self.write_format(kind, key, FORMAT_RGBA8, payload); - } - fn write_format(&self, kind: Kind, key: u128, format: u8, payload: &[u8]) { if self.contains(kind, key) { return; @@ -680,70 +869,229 @@ impl DiskCache { } } + /// Delete the frame and layer entries made of any of `paths` (File ▸ Reload Footage: a file + /// replaced by one with the same size and modification time must be read again, which the + /// check on hit can't tell). The index knows keys, not files, so the writer thread reads the + /// files section at the start of each frame and layer entry (no pixels) and deletes the + /// entries that list one of `paths`, after the writes queued before (those are swept too); + /// until it is done, an entry made of one of them is a miss. Entries are deleted for good, + /// so they don't come back after a restart. Nothing else in the cache is touched. + /// + /// A render that was under way meanwhile may still offer an entry made of one of `paths` as + /// it was before: it is refused, see [`DiskCache::drop_generation`]. + pub fn drop_files(&self, paths: impl IntoIterator>>) { + let paths: Vec> = paths.into_iter().map(Into::into).collect(); + if paths.is_empty() { + return; + } + { + let mut d = self.dropping.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + for p in &paths { + let n = d.entry(p.clone()).or_default(); + *n = n.saturating_add(1); + } + } + #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] + let (m, cv) = &*self.queue; + match m.lock() { + Ok(mut q) => { + q.drops.record(&paths); + #[cfg(not(target_arch = "wasm32"))] + { + // (never refused for a full queue) + q.jobs.push_back(Job::Drop(paths)); + cv.notify_all(); + return; + } + } + // (no writer thread to take the job any more) + Err(poisoned) => poisoned.into_inner().drops.record(&paths), + } + self.sweep(&paths); + } + + /// The number of [`DiskCache::drop_files`] calls so far. A render takes it before it starts + /// reading and hands it to `put_frame` / `put_layer` with what it made: an entry listing a + /// file dropped after that is refused (the render may have read the file as it was before). + pub fn drop_generation(&self) -> u64 { + self.queue.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).drops.generation + } + + /// Whether one of `files` was dropped after generation `since` ([`DiskCache::drop_generation`]), + /// or after the earlier generation the list's own reads started at ([`ReadFiles::since`]: a + /// buffer made by an earlier render, from memory): what was made of them then is from before + /// the drop (shown, never stored). + pub fn dropped_since(&self, files: &ReadFiles, since: u64) -> bool { + let since = files.since().map_or(since, |s| s.min(since)); + self.queue.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).drops.stale(files.files().iter().map(|f| &f.path), since) + } + + /// Whether a [`DiskCache::drop_files`] is pending. + pub fn is_dropping(&self) -> bool { + !self.dropping.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_empty() + } + + /// The drop generation and the files being dropped now, taken before an entry is read + /// (see [`DiskCache::made_of_dropped`]). + fn drops_now(&self) -> (u64, Vec>) { + let generation = self.drop_generation(); + let d = self.dropping.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + (generation, d.keys().cloned().collect()) + } + + /// An entry read after [`DiskCache::drops_now`] gave `drops` that is made of a file whose + /// drop was pending then (its sweep may have ended since, after the entry was read) or that + /// was dropped since. + fn made_of_dropped(&self, files: &EntryFiles, drops: &(u64, Vec>)) -> bool { + let (since, pending) = drops; + let paths = || files.files().iter().map(|f| &f.path); + paths().any(|p| pending.contains(p)) || self.queue.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).drops.stale(paths(), *since) + } + + /// The writer's part of [`DiskCache::drop_files`]: delete the entries made of `paths`, then + /// end their drop. + fn sweep(&self, paths: &[Arc]) { + let set: std::collections::HashSet<&str> = paths.iter().map(|p| &**p).collect(); + for kind in [Kind::Frame, Kind::Layer] { + for key in self.keys(kind) { + if self.entry_files(kind, key).is_some_and(|f| f.files().iter().any(|f| set.contains(&*f.path))) { + self.discard(kind, key); + } + } + } + let mut d = self.dropping.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + for p in paths { + if let Some(n) = d.get_mut(p) { + *n = n.saturating_sub(1); + if *n == 0 { + d.remove(p); + } + } + } + } + + /// The source files a frame or layer entry lists, read from the start of its file (the + /// whole file only when the list is longer than that). `None` when it has none to read (an + /// earlier format, gone, damaged): such an entry is never used anyway. + fn entry_files(&self, kind: Kind, key: u128) -> Option { + use std::io::Read; + const HEAD: u64 = 64 << 10; + let parse = |d: &[u8]| { + let format = *d.get(5)?; + let ours = d.get(..4)? == MAGIC && *d.get(4)? == kind.tag(); + if !ours || !matches!(format, FORMAT_RGBA8_FILES | FORMAT_F16_FILES | FORMAT_LAYER_FILES) { + return None; + } + EntryFiles::decode(d.get(8..)?).map(|(files, _)| files) + }; + let path = self.path(kind, key); + let mut head = Vec::new(); + std::fs::File::open(&path).ok()?.take(HEAD).read_to_end(&mut head).ok()?; + parse(&head).or_else(|| if head.len() as u64 == HEAD { parse(&std::fs::read(&path).ok()?) } else { None }) + } + // ------------------------------------------------------------ typed entries - /// An 8-bit frame (`None` for a half-float entry of that key, which [`DiskCache::get_frame_f16`] - /// reads). - pub fn get_frame(&self, key: u128) -> Option { - let (format, p) = self.read_entry(Kind::Frame, key)?; - if format != FORMAT_RGBA8 { - return None; - } - decode_frame(&p) + /// An 8-bit frame whose source files are unchanged (see [`DiskCache::get_checked`]), with + /// those files; `None` for a half-float entry of that key, which + /// [`DiskCache::get_frame_f16`] reads. `virtual_id` names files served from memory. + pub fn get_frame(&self, key: u128, virtual_id: Option>) -> Option<(Frame8, ReadFiles)> { + self.get_checked(Kind::Frame, key, FORMAT_RGBA8_FILES, virtual_id, decode_frame) } - /// A half-float frame (32 bpc viewer frames). A damaged one is deleted. - pub fn get_frame_f16(&self, key: u128) -> Option { - let (format, p) = self.read_entry(Kind::Frame, key)?; - if format != FORMAT_F16 { - return None; - } - let f = decode_frame_f16(&p); - if f.is_none() { - self.discard(Kind::Frame, key); - } - f + /// A half-float frame (32 bpc viewer frames), as [`DiskCache::get_frame`]. + pub fn get_frame_f16(&self, key: u128, virtual_id: Option>) -> Option<(FrameF16, ReadFiles)> { + self.get_checked(Kind::Frame, key, FORMAT_F16_FILES, virtual_id, decode_frame_f16) } - /// Queue a half-float frame for writing (`width * height * 4` binary16 values). `true` when - /// it is on disk or queued; `false` when refused (wrong size, a full queue). - pub fn put_frame_f16(&self, key: u128, width: u32, height: u32, rgba: &[u16]) -> bool { + /// Queue a half-float frame for writing (`width * height * 4` binary16 values) with the + /// source files it was made of. `true` when it is on disk or queued; `false` when refused + /// (wrong size, a full queue, no room on the disk, files that can't be checked later: see + /// [`EntryFiles::of`]; a file dropped after `since`, the [`DiskCache::drop_generation`] the + /// render started at). + #[allow(clippy::too_many_arguments)] + pub fn put_frame_f16( + &self, + key: u128, + width: u32, + height: u32, + rgba: &[u16], + files: &ReadFiles, + virtual_id: Option>, + since: u64, + ) -> bool { if rgba.len() as u64 != width as u64 * height as u64 * 4 { return false; } - self.contains(Kind::Frame, key) || self.enqueue_format(Kind::Frame, key, FORMAT_F16, encode_frame_f16(width, height, rgba)) - } - - /// Write a half-float frame synchronously (tests). - pub fn put_frame_f16_now(&self, key: u128, width: u32, height: u32, rgba: &[u16]) { - self.write_format(Kind::Frame, key, FORMAT_F16, &encode_frame_f16(width, height, rgba)); - } - - /// 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 { + let Some(files) = EntryFiles::of(files, virtual_id) else { return false }; + if self.contains(Kind::Frame, key) { + return true; + } + // (no room on the disk: refused before the pixels are compressed) + if !self.accepts_writes() { return false; } - self.contains(Kind::Frame, key) || self.enqueue(Kind::Frame, key, encode_frame(width, height, rgba)) + self.count_encode(); + let payload = with_files(&files, encode_frame_f16(width, height, rgba)); + self.enqueue_format(Kind::Frame, key, FORMAT_F16_FILES, payload, Some((&files, since))) } - pub fn get_layer(&self, key: u128) -> Option { - let p = self.read(Kind::Layer, key)?; - decode_layer(&p) + /// Write a half-float frame synchronously (tests); a list that can't be stored is not. + pub fn put_frame_f16_now(&self, key: u128, width: u32, height: u32, rgba: &[u16], files: &ReadFiles) { + if let Some(files) = EntryFiles::of(files, None) { + self.write_format(Kind::Frame, key, FORMAT_F16_FILES, &with_files(&files, encode_frame_f16(width, height, rgba))); + } } - /// Queue a layer buffer for writing. - pub fn put_layer(&self, key: u128, buf: &Buf) { - if self.contains(Kind::Layer, key) { + /// Queue a frame for writing (8-bit premultiplied RGBA, `width * height * 4` bytes) with the + /// source files it was made of. `true` when it is on disk or queued; `false` when refused + /// (wrong size, a full queue, no room on the disk, files that can't be checked later: see + /// [`EntryFiles::of`]; a file dropped after `since`, the [`DiskCache::drop_generation`] the + /// render started at). + #[allow(clippy::too_many_arguments)] + pub fn put_frame(&self, key: u128, width: u32, height: u32, rgba: &[u8], files: &ReadFiles, virtual_id: Option>, since: u64) -> bool { + if rgba.len() as u64 != width as u64 * height as u64 * 4 { + return false; + } + let Some(files) = EntryFiles::of(files, virtual_id) else { return false }; + if self.contains(Kind::Frame, key) { + return true; + } + // (no room on the disk: refused before the pixels are compressed) + if !self.accepts_writes() { + return false; + } + self.count_encode(); + let payload = with_files(&files, encode_frame(width, height, rgba)); + self.enqueue_format(Kind::Frame, key, FORMAT_RGBA8_FILES, payload, Some((&files, since))) + } + + /// A layer buffer (key: [`layer_disk_key`]) whose source files are unchanged, with those + /// files, as [`DiskCache::get_checked`]: an entry whose files changed, damaged, or of the + /// format before #3184 is a miss and is deleted, so the buffer renders again and replaces it. + /// `virtual_id` names files served from memory. + pub fn get_layer(&self, key: u128, virtual_id: Option>) -> Option<(Buf, ReadFiles)> { + self.get_checked(Kind::Layer, key, FORMAT_LAYER_FILES, virtual_id, decode_layer) + } + + /// Queue a layer buffer for writing with the source files it was made of (see + /// [`EntryFiles::of`]: a buffer whose files can't all be named must not be stored). Refused + /// when one of them was dropped after `since`, the [`DiskCache::drop_generation`] its render + /// started at. + pub fn put_layer(&self, key: u128, buf: &Buf, files: &EntryFiles, since: u64) { + // (no room on the disk: refused before the buffer is compressed) + if self.contains(Kind::Layer, key) || !self.accepts_writes() { return; } - let _ = self.enqueue(Kind::Layer, key, encode_layer(buf)); + self.count_encode(); + let _ = self.enqueue_format(Kind::Layer, key, FORMAT_LAYER_FILES, with_files(files, encode_layer(buf)), Some((files, since))); } - /// Write a layer buffer synchronously (tests). - pub fn put_layer_now(&self, key: u128, buf: &Buf) { - self.write_now(Kind::Layer, key, &encode_layer(buf)); + /// Write a layer buffer synchronously (tests); a list that can't be stored is not. + pub fn put_layer_now(&self, key: u128, buf: &Buf, files: &ReadFiles) { + if let Some(files) = EntryFiles::of(files, None) { + self.write_format(Kind::Layer, key, FORMAT_LAYER_FILES, &with_files(&files, encode_layer(buf))); + } } /// A segmentation (chain key, scale) written by [`DiskCache::put_seg`]. @@ -767,7 +1115,8 @@ impl DiskCache { /// 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) { + // (while files are being dropped, `from` may be made of one: it renders again instead) + if !self.contains(Kind::Frame, from) || self.contains(Kind::Frame, to) || self.is_dropping() { return false; } let (src, dst) = (self.path(Kind::Frame, from), self.path(Kind::Frame, to)); @@ -794,12 +1143,23 @@ impl DiskCache { true } - /// Write a frame synchronously (tests). - pub fn put_frame_now(&self, key: u128, width: u32, height: u32, rgba: &[u8]) { - self.write_now(Kind::Frame, key, &encode_frame(width, height, rgba)); + /// Write a frame synchronously (tests); a list that can't be stored is not. + pub fn put_frame_now(&self, key: u128, width: u32, height: u32, rgba: &[u8], files: &ReadFiles) { + if let Some(files) = EntryFiles::of(files, None) { + self.write_format(Kind::Frame, key, FORMAT_RGBA8_FILES, &with_files(&files, encode_frame(width, height, rgba))); + } } } +/// A frame or layer entry's payload: its source files, then its pixels. +fn with_files(files: &EntryFiles, pixels: Vec) -> Vec { + let mut out = Vec::with_capacity(pixels.len() + 64); + files.encode(&mut out); + out.extend_from_slice(&pixels); + out +} + +/// The writer thread: evicts to the limit at open, then drains the job queue until the cache is dropped. #[cfg(not(target_arch = "wasm32"))] fn writer(dc: std::sync::Weak, q: Arc<(Mutex, Condvar)>) { let (m, cv) = &*q; @@ -830,12 +1190,20 @@ fn writer(dc: std::sync::Weak, q: Arc<(Mutex, Condvar)>) { } }; let Some(cache) = dc.upgrade() else { return }; - let Job::Write(kind, key, format, payload) = job; - cache.write_format(kind, key, format, &payload); + let written = match job { + Job::Write(kind, key, format, payload) => { + cache.write_format(kind, key, format, &payload); + (kind == Kind::Frame).then_some(key) + } + Job::Drop(paths) => { + cache.sweep(&paths); + None + } + }; drop(cache); if let Ok(mut g) = m.lock() { g.busy = false; - if kind == Kind::Frame { + if let Some(key) = written { g.pending_frames.remove(&key); } } @@ -843,12 +1211,9 @@ fn writer(dc: std::sync::Weak, q: Arc<(Mutex, Condvar)>) { } } -/// An entry file's bytes: header (magic, kind, format 0), payload, checksum. -pub fn seal(kind: Kind, payload: &[u8]) -> Vec { - seal_format(kind, FORMAT_RGBA8, payload) -} - -/// [`seal`] with the header's format byte (frames: 0 = 8-bit, 1 = half float). +/// An entry file's bytes: header (magic, kind, the format byte: [`FORMAT_RGBA8`] for +/// segmentations, [`FORMAT_RGBA8_FILES`] / [`FORMAT_F16_FILES`] for frames, +/// [`FORMAT_LAYER_FILES`] for layer buffers), payload, checksum. fn seal_format(kind: Kind, format: u8, payload: &[u8]) -> Vec { let mut data = Vec::with_capacity(payload.len() + 16); data.extend_from_slice(MAGIC); @@ -860,26 +1225,47 @@ fn seal_format(kind: Kind, format: u8, payload: &[u8]) -> Vec { data } -/// A sealed frame entry ([`seal`] of the encoded frame): what the browser's frame workers -/// write to the Origin Private File System. -pub fn frame_entry(width: u32, height: u32, rgba: &[u8]) -> Vec { - seal(Kind::Frame, &encode_frame(width, height, rgba)) +/// A sealed 8-bit frame entry with the source files it was made of: what the browser's frame +/// workers write to the Origin Private File System. `None` when the frame must not be stored +/// (its files can't be checked later, see [`EntryFiles::of`]; or a wrong size). +pub fn frame_entry(width: u32, height: u32, rgba: &[u8], files: &ReadFiles, virtual_id: Option>) -> Option> { + if rgba.len() as u64 != width as u64 * height as u64 * 4 { + return None; + } + let files = EntryFiles::of(files, virtual_id)?; + Some(seal_format(Kind::Frame, FORMAT_RGBA8_FILES, &with_files(&files, encode_frame(width, height, rgba)))) } -/// A frame from a sealed entry's bytes (`None` when damaged). -pub fn read_frame_entry(data: &[u8]) -> Option { - decode_frame(verify(data, Kind::Frame)?) +/// The frame of a sealed entry's bytes, if its source files are unchanged. `None` when damaged, +/// of another format, or made of files that changed since: a miss, to be rendered again. +pub fn read_frame_entry(data: &[u8], virtual_id: Option>) -> Option { + let payload = verify(data, Kind::Frame)?; + if data.get(5) != Some(&FORMAT_RGBA8_FILES) { + return None; + } + let (files, pixels) = EntryFiles::decode(payload)?; + if !files.unchanged(virtual_id) { + return None; + } + decode_frame(pixels) } -/// A sealed layer-buffer entry: what the browser's frame workers write to the Origin Private -/// File System (`layers/<32 hex>.ecc`). -pub fn layer_entry(buf: &Buf) -> Vec { - seal(Kind::Layer, &encode_layer(buf)) +/// A sealed layer-buffer entry with the source files it was made of: what the browser's frame +/// workers write to the Origin Private File System (`layers/<32 hex>.ecc`). +pub fn layer_entry(buf: &Buf, files: &EntryFiles) -> Vec { + seal_format(Kind::Layer, FORMAT_LAYER_FILES, &with_files(files, encode_layer(buf))) } -/// A layer buffer from a sealed entry's bytes (`None` when damaged). -pub fn read_layer_entry(data: &[u8]) -> Option { - decode_layer(verify(data, Kind::Layer)?) +/// A layer buffer and its source files from a sealed entry's bytes (`None` when damaged or of +/// another format). The files are not looked at here: the store checks them when the buffer is +/// used ([`EntryFiles::unchanged`]). +pub fn read_layer_entry(data: &[u8]) -> Option<(Buf, EntryFiles)> { + let payload = verify(data, Kind::Layer)?; + if data.get(5) != Some(&FORMAT_LAYER_FILES) { + return None; + } + let (files, body) = EntryFiles::decode(payload)?; + Some((decode_layer(body)?, files)) } /// The disk key of a segmentation: its chain key and scale. @@ -901,11 +1287,20 @@ impl effectcraft_effects::roto::SegStore for DiskCache { } impl crate::cache::LayerStore for DiskCache { - fn get_layer(&self, key: u128) -> Option> { - DiskCache::get_layer(self, key).map(Arc::new) + /// The layer for `key` from the disk cache, or `None` when it is missing or stale. + fn get_layer(&self, key: u128, virtual_id: Option>) -> Option<(Arc, ReadFiles)> { + DiskCache::get_layer(self, key, virtual_id).map(|(b, f)| (Arc::new(b), f)) } - fn put_layer(&self, key: u128, buf: &Buf) { - DiskCache::put_layer(self, key, buf); + /// Queues a layer write on the disk cache. + fn put_layer(&self, key: u128, buf: &Buf, files: &EntryFiles, since: u64) { + DiskCache::put_layer(self, key, buf, files, since); + } + /// The current drop generation of the disk cache. + fn drop_generation(&self) -> u64 { + DiskCache::drop_generation(self) + } + fn dropped_since(&self, files: &ReadFiles, since: u64) -> bool { + DiskCache::dropped_since(self, files, since) } fn min_layer_ms(&self) -> u64 { DiskCache::min_layer_ms(self) @@ -1015,6 +1410,7 @@ fn encode_layer(b: &Buf) -> Vec { out } +/// Decodes a stored layer payload into a buffer, or `None` when it is too short or damaged. fn decode_layer(p: &[u8]) -> Option { if p.len() < 32 { return None; @@ -1025,8 +1421,8 @@ fn decode_layer(p: &[u8]) -> Option { let offset = [f(8)?, f(16)?]; let scale = f(24)?; let planes = lz4_flex::decompress_size_prepended(&p[32..]).ok()?; - let n = w as usize * h as usize; - if planes.len() != n * 16 { + let n = (w as usize).checked_mul(h as usize)?; + if planes.len() != n.checked_mul(16)? { return None; } let mut img = Image::new(w, h); @@ -1047,6 +1443,7 @@ fn decode_layer(p: &[u8]) -> Option { /// Bump when rendering changes in a way that makes old disk entries wrong. const RENDER_VERSION: &str = "effectcraft-render-6"; +/// Feeds the `Debug` form of `v` into the hash, followed by a separator byte. fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) { h.write(format!("{v:?}").as_bytes()); h.write(&[0xff]); @@ -1055,126 +1452,63 @@ fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) { /// Size and content hash of a path that has no native metadata (the browser file table). pub type VirtualFileId<'a> = &'a (dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync); -/// A file's size and modification time (so edited footage gets new keys). When the path has no -/// native metadata, `virtual_id` supplies a durable size and content hash instead. -fn file_stamp(h: &mut Hash128, path: &str, virtual_id: Option>) { - if path.is_empty() { - return; - } - if let Ok(md) = std::fs::metadata(path) { - h.write_u64(md.len()); - if let Ok(t) = md.modified() - && let Ok(d) = t.duration_since(std::time::UNIX_EPOCH) - { - h.write_u64(d.as_nanos() as u64); - } - return; - } - if let Some((len, hash)) = virtual_id.and_then(|id| id(path)) { - h.write_u64(len); - h.write_u64(hash); - } -} +/// A [`VirtualFileId`] a cache can keep (the browser file table's identities). +pub type SharedVirtualFileId = Arc Option<(u64, u64)> + Send + Sync>; -fn footage_stamp(h: &mut Hash128, f: &effectcraft_project::Footage, virtual_id: Option>) { - file_stamp(h, &f.path, virtual_id); - for p in &f.sequence { - file_stamp(h, p, virtual_id); - } -} +/// Layer entries with their source files (#3184), part of every layer disk key: entries of the +/// earlier format (keyed by a project-wide footage salt instead) are never looked up again; +/// least recently used, they are evicted. +const LAYER_ENTRY_VERSION: &[u8] = b"layer-entry-files-2"; -/// Salt for layer cache keys on disk: the renderer version and every footage file's identity -/// (layer keys name footage items, not file contents). -pub fn footage_salt(project: &effectcraft_project::Project) -> u64 { - footage_salt_with(project, None) -} - -/// [`footage_salt`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe. -pub fn footage_salt_with(project: &effectcraft_project::Project, virtual_id: Option>) -> u64 { +/// Disk key of a layer buffer: the layer cache's content key ([`crate::cache::layer_key`] and +/// the keys derived from it), the layer entry format and the renderer version. The content key +/// comes from memory alone: it names the footage items the buffer uses with their settings and +/// paths, so two projects only share an entry for the same pixels of the same files; whether +/// those files still hold what the buffer was made of is checked when the entry is read +/// ([`EntryFiles`]). No file is looked at here. +pub fn layer_disk_key(key: u64) -> u128 { let mut h = Hash128::default(); + h.write(LAYER_ENTRY_VERSION); 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 { - // (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); - } - if let Some(proxy) = it.proxy.as_ref().filter(|p| p.enabled) { - h.write_u64(id.0); - hash_debug(&mut h, proxy); - footage_stamp(&mut h, &proxy.footage, virtual_id); - } - } - let v = h.finish(); - (v as u64) ^ ((v >> 64) as u64) + h.write_u64(key); + h.finish() } -/// Content key of a composition's pixels: project render settings, the comp and everything it -/// uses (nested comps, footage, solids, their proxies and Use Proxy switches, footage and proxy -/// files' size and time). With expressions in play (which can read any comp) the whole project -/// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count, and -/// neither do markers unless an expression mentions them. +/// Content key of a composition's pixels, from the project in memory alone (no file is looked +/// at: the frame entries check their own source files when read, see [`EntryFiles`]): project +/// render settings, the comp and the items it can draw (nested comps, footage items with their +/// settings and paths, solids, Media Replacement footage, their proxies and Use Proxy switches; +/// [`effectcraft_project::Project::pixel_reach`]). With expressions among them, everything those +/// expressions can read counts too: every layer of their comps, markers, the comps and footage +/// they name with literal names (and which items have those names), or the whole project when +/// they compute a name or reach items another way. An edit to an item the comp can't reach keeps +/// its key and its frames on disk (#3184, #1492). Switches that don't change pixels (Audio, Lock, +/// Shy, Hide Shy Layers) don't count, and neither do markers unless an expression can read them. pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 { - comp_content_key_with(project, comp, None) + comp_content_key_for_renderer(project, comp, RENDER_VERSION) } -/// [`comp_content_key`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe. -pub fn comp_content_key_with(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId, virtual_id: Option>) -> u128 { - comp_content_key_for_renderer(project, comp, virtual_id, RENDER_VERSION) -} - -fn comp_content_key_for_renderer( - project: &effectcraft_project::Project, - comp: effectcraft_project::ItemId, - virtual_id: Option>, - renderer: &str, -) -> u128 { - use effectcraft_project::{ItemKind, LayerSource}; +/// The content key of `comp` for one renderer: its settings, the comps and footage it uses, and the renderer name. +fn comp_content_key_for_renderer(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId, renderer: &str) -> u128 { + use effectcraft_project::ItemKind; 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 reach = project.pixel_reach(comp, &heard); + // Expressions can read any layer of the comps they reach, hidden or not, and their markers + // (#1083); without them, layers that can't change the picture (music, hidden layers nothing + // reads) don't count. + let expressions = reach.expressions; 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]; - 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 { - 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); - } - } - } + // (the names expressions look items up by: which items have them now) + if !reach.names.is_empty() { + hash_debug(&mut h, &reach.names); } - // Markers are pixels only to the expressions that read them (#1083). - let markers = expressions; - let ids: Vec = if expressions { project.items.keys().copied().collect() } else { seen.into_iter().collect() }; + let ids: Vec = match reach.items { + Some(items) => items.into_iter().collect(), + None => project.items.keys().copied().collect(), + }; for id in ids { let Some(it) = project.item(id) else { continue }; h.write_u64(id.0); @@ -1184,30 +1518,30 @@ fn comp_content_key_for_renderer( 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)); + hash_debug(&mut h, &c.pixel_form_with(expressions, &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)); + hash_debug(&mut h, &pic.pixel_form_with(expressions, &heard)); } } k => hash_debug(&mut h, k), } - if let ItemKind::Footage(f) = &it.kind { - footage_stamp(&mut h, f, virtual_id); - } hash_debug(&mut h, &it.proxy); - if let Some(px) = &it.proxy { - footage_stamp(&mut h, &px.footage, virtual_id); - } } h.write_u64(comp.0); h.finish() } +/// Frame entries with their source files (#3184), part of every frame key: entries of the +/// earlier format (keyed by footage stamps instead) are never looked up again; least recently +/// used, they are evicted. +const FRAME_ENTRY_VERSION: &[u8] = b"frame-entry-files-2"; + /// Disk key of one viewer frame. pub fn frame_key(content: u128, frame: i64, scale_milli: u32, view: u64) -> u128 { let mut h = Hash128::default(); + h.write(FRAME_ENTRY_VERSION); h.write(&content.to_le_bytes()); h.write_u64(frame as u64); h.write_u64(scale_milli as u64); @@ -1231,18 +1565,18 @@ mod tests { fn link_frame_serves_a_frame_under_a_new_key() { let dc = DiskCache::open(tmpdir("link"), 1 << 30).unwrap(); let rgba: Vec = (0..16).collect(); - dc.put_frame_now(1, 2, 2, &rgba); + dc.put_frame_now(1, 2, 2, &rgba, &ReadFiles::default()); assert!(dc.link_frame(1, 2)); - assert_eq!(dc.get_frame(2).map(|f| f.rgba), Some(rgba.clone())); + assert_eq!(dc.get_frame(2, None).map(|f| f.0).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]); + dc.put_frame_now(5, 2, 2, &[7; 16], &ReadFiles::default()); assert!(!dc.link_frame(5, 2), "an existing entry stays"); - assert_eq!(dc.get_frame(2).map(|f| f.rgba), Some(rgba.clone())); + assert_eq!(dc.get_frame(2, None).map(|f| f.0).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"); + assert_eq!(dc.get_frame(2, None).map(|f| f.0).map(|f| f.rgba), Some(rgba), "the link outlives the original"); } fn buf(seed: u32, w: u32, h: u32) -> Buf { @@ -1277,11 +1611,21 @@ mod tests { #[test] fn frame_entries_round_trip_and_names_parse() { let rgba: Vec = (0..4 * 6 * 3).map(|i| (i * 7 % 256) as u8).collect(); - let e = frame_entry(6, 3, &rgba); - assert_eq!(read_frame_entry(&e), Some(Frame8 { width: 6, height: 3, rgba: rgba.clone() })); + let files: ReadFiles = [crate::reads::ReadFile::new("/v.png", crate::reads::FileStamp::Virtual)].into_iter().collect(); + let (one, two) = (|_: &str| Some((4, 1)), |_: &str| Some((4, 2))); + assert!(frame_entry(6, 3, &rgba, &files, None).is_none(), "a file from memory needs its identity"); + assert!(frame_entry(6, 3, &rgba[1..], &files, Some(&one)).is_none(), "a wrong size"); + let e = frame_entry(6, 3, &rgba, &files, Some(&one)).unwrap(); + assert_eq!(read_frame_entry(&e, Some(&one)), Some(Frame8 { width: 6, height: 3, rgba: rgba.clone() })); + assert!(read_frame_entry(&e, Some(&two)).is_none(), "the file changed"); + let mut incomplete = files.clone(); + incomplete.mark_incomplete(); + assert!(frame_entry(6, 3, &rgba, &incomplete, Some(&one)).is_none(), "an incomplete list is not stored"); let mut bad = e.clone(); bad[20] ^= 1; - assert!(read_frame_entry(&bad).is_none()); + assert!(read_frame_entry(&bad, Some(&one)).is_none()); + // An entry of the format before #3184 (pixels without files) is never read. + assert!(read_frame_entry(&seal_format(Kind::Frame, FORMAT_RGBA8, &encode_frame(6, 3, &rgba)), Some(&one)).is_none()); let key = 0x0123_4567_89ab_cdef_0011_2233_4455_6677u128; assert_eq!(parse_entry_name(&entry_name(key)), Some(key)); assert_eq!(parse_entry_name("abc.ecc"), None); @@ -1298,27 +1642,27 @@ mod tests { let (w, h) = (7u32, 5u32); // 4.0, 16.0, -0.25, 65504 and a spread of bit patterns. let rgba: Vec = (0..w * h * 4).map(|i| [0x4400, 0x4c00, 0xb400, 0x7bff][i as usize % 4] ^ ((i * 37) as u16 & 0x03ff)).collect(); - dc.put_frame_f16_now(1, w, h, &rgba); - assert_eq!(dc.get_frame_f16(1), Some(FrameF16 { width: w, height: h, rgba: rgba.clone() })); - assert!(dc.get_frame(1).is_none(), "not an 8-bit frame"); - dc.put_frame_now(2, 2, 2, &[9; 16]); - assert!(dc.get_frame_f16(2).is_none() && dc.contains(Kind::Frame, 2), "an 8-bit entry stays valid"); - assert_eq!(dc.get_frame(2).unwrap().rgba, vec![9; 16]); + dc.put_frame_f16_now(1, w, h, &rgba, &ReadFiles::default()); + assert_eq!(dc.get_frame_f16(1, None).map(|f| f.0), Some(FrameF16 { width: w, height: h, rgba: rgba.clone() })); + assert!(dc.get_frame(1, None).map(|f| f.0).is_none(), "not an 8-bit frame"); + dc.put_frame_now(2, 2, 2, &[9; 16], &ReadFiles::default()); + assert!(dc.get_frame_f16(2, None).map(|f| f.0).is_none() && dc.contains(Kind::Frame, 2), "an 8-bit entry stays valid"); + assert_eq!(dc.get_frame(2, None).map(|f| f.0).unwrap().rgba, vec![9; 16]); assert!(dc.stats().bytes > 0); // Reopened, still there (format byte persisted). drop(dc); let dc = DiskCache::open(&dir, 1 << 30).unwrap(); - assert_eq!(dc.get_frame_f16(1).unwrap().rgba, rgba); + assert_eq!(dc.get_frame_f16(1, None).map(|f| f.0).unwrap().rgba, rgba); // A sealed entry with a payload of the wrong size: deleted on read. let p = dc.path(Kind::Frame, 3); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); - std::fs::write(&p, seal_format(Kind::Frame, FORMAT_F16, &encode_frame_f16(w, h + 1, &rgba[..])[..])).unwrap(); + std::fs::write(&p, seal_format(Kind::Frame, FORMAT_F16_FILES, &with_files(&EntryFiles::default(), encode_frame_f16(w, h + 1, &rgba[..])))).unwrap(); dc.rescan(); assert!(dc.contains(Kind::Frame, 3)); - assert!(dc.get_frame_f16(3).is_none()); + assert!(dc.get_frame_f16(3, None).map(|f| f.0).is_none()); assert!(!dc.contains(Kind::Frame, 3) && !p.exists()); // Wrong lengths are not written. - dc.put_frame_f16(4, w, h, &rgba[1..]); + dc.put_frame_f16(4, w, h, &rgba[1..], &ReadFiles::default(), None, 0); dc.flush(); assert!(!dc.contains(Kind::Frame, 4)); let _ = std::fs::remove_dir_all(&dir); @@ -1360,26 +1704,27 @@ mod tests { fn hit_miss_and_exact_round_trip() { let dir = tmpdir("hit"); let dc = DiskCache::open(&dir, 1 << 30).unwrap(); - assert!(dc.get_layer(42).is_none()); + assert!(dc.get_layer(42, None).is_none()); let b = buf(1, 33, 17); - dc.put_layer(42, &b); + dc.put_layer(42, &b, &EntryFiles::default(), 0); dc.flush(); - let back = dc.get_layer(42).unwrap(); + let (back, files) = dc.get_layer(42, None).unwrap(); + assert!(files.is_empty() && files.is_complete()); assert_eq!(back.img, b.img); assert_eq!((back.offset, back.scale), (b.offset, b.scale)); let rgba: Vec = (0..20 * 10 * 4).map(|i| (i % 251) as u8).collect(); - assert!(dc.put_frame(7, 20, 10, &rgba)); - assert!(!dc.put_frame(8, 2, 2, &[0; 3]), "a wrong-size frame is refused"); + assert!(dc.put_frame(7, 20, 10, &rgba, &ReadFiles::default(), None, 0)); + assert!(!dc.put_frame(8, 2, 2, &[0; 3], &ReadFiles::default(), None, 0), "a wrong-size frame is refused"); dc.flush(); - assert_eq!(dc.get_frame(7).unwrap().rgba, rgba); + assert_eq!(dc.get_frame(7, None).map(|f| f.0).unwrap().rgba, rgba); let st = dc.stats(); assert_eq!((st.entries, st.hits, st.misses, st.writes), (2, 2, 1, 2)); // Wrong kind is a miss. - assert!(dc.get_frame(42).is_none()); + assert!(dc.get_frame(42, None).map(|f| f.0).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. + /// hiding a clip does. Importing music keeps them too. #[test] fn music_keeps_the_disk_keys() { use effectcraft_project::{Comp, Footage, FootageKind, ItemKind, LayerSource, Project, Solid, build}; @@ -1391,14 +1736,14 @@ mod tests { 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 key = comp_content_key(&p, cid); 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"); + assert_eq!(comp_content_key(&p, cid), key, "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"); @@ -1406,6 +1751,28 @@ mod tests { assert_ne!(comp_content_key(&p, cid), key, "a hidden clip"); } + /// An expression that reads a 3D model layer's source (`thisComp.layer("Model").source.name`) + /// gives a new key when the model item is renamed: the frames on disk hold the old name. + #[test] + fn renaming_a_model_an_expression_reads_changes_the_disk_key() { + use effectcraft_project::{Comp, Expression, 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 model = p.add_item("chair.glb", Default::default(), None, ItemKind::Footage(Footage { kind: FootageKind::Model, ..Default::default() })); + 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 mut m = build::layer(&mut p, &comp, "Model", LayerSource::Model { item: model }, (64, 64), None); + m.switches.video = false; + let mut s = build::layer(&mut p, &comp, "Reader", LayerSource::Solid { item: sid }, (64, 64), None); + s.props.prop_mut("transform/opacity").unwrap().expr = + Some(Expression { text: "thisComp.layer(\"Model\").source.name.length * 10".into(), enabled: true }); + p.comp_mut(cid).unwrap().layers.extend([s, m]); + let key = comp_content_key(&p, cid); + p.item_mut(model).unwrap().name = "table.glb".into(); + assert_ne!(comp_content_key(&p, cid), key, "the expression reads the model's name"); + } + /// 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] @@ -1413,11 +1780,11 @@ mod tests { 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)); + dc.put_layer(10_000 + k, &buf(1, 64, 64), &EntryFiles::default(), 0); } let rgba = vec![7u8; 32 * 32 * 4]; for k in 0..300 { - dc.put_frame(k, 32, 32, &rgba); + dc.put_frame(k, 32, 32, &rgba, &ReadFiles::default(), None, 0); } dc.flush(); assert!((0..300).all(|k| dc.contains(Kind::Frame, k)), "every frame written"); @@ -1432,17 +1799,17 @@ 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-5"), 0, 1000, 0); + let previous_key = frame_key(comp_content_key_for_renderer(&project, id, "effectcraft-render-5"), 0, 1000, 0); let dir = tmpdir("previous-renderer"); { let cache = DiskCache::open(&dir, 1 << 30).unwrap(); - cache.put_frame_now(previous_key, 2, 2, &[9; 16]); + cache.put_frame_now(previous_key, 2, 2, &[9; 16], &ReadFiles::default()); } let cache = DiskCache::open(&dir, 1 << 30).unwrap(); - assert_eq!(cache.get_frame(previous_key).unwrap().rgba, vec![9; 16]); - assert!(cache.get_frame(key).is_none()); - cache.put_frame_now(key, 2, 2, &[17; 16]); - assert_eq!(cache.get_frame(key).unwrap().rgba, vec![17; 16]); + assert_eq!(cache.get_frame(previous_key, None).map(|f| f.0).unwrap().rgba, vec![9; 16]); + assert!(cache.get_frame(key, None).map(|f| f.0).is_none()); + cache.put_frame_now(key, 2, 2, &[17; 16], &ReadFiles::default()); + assert_eq!(cache.get_frame(key, None).map(|f| f.0).unwrap().rgba, vec![17; 16]); } #[test] @@ -1450,16 +1817,16 @@ mod tests { let dir = tmpdir("persist"); { let dc = DiskCache::open(&dir, 1 << 30).unwrap(); - dc.put_layer_now(1, &buf(1, 8, 8)); - dc.put_frame_now(2, 2, 2, &[9; 16]); + dc.put_layer_now(1, &buf(1, 8, 8), &ReadFiles::default()); + dc.put_frame_now(2, 2, 2, &[9; 16], &ReadFiles::default()); } let dc = DiskCache::open(&dir, 1 << 30).unwrap(); assert_eq!(dc.stats().entries, 2); assert!(dc.contains(Kind::Layer, 1) && dc.contains(Kind::Frame, 2)); - assert_eq!(dc.get_frame(2).unwrap().rgba, vec![9; 16]); + assert_eq!(dc.get_frame(2, None).map(|f| f.0).unwrap().rgba, vec![9; 16]); dc.clear(); assert_eq!(dc.stats().entries, 0); - assert!(dc.get_frame(2).is_none()); + assert!(dc.get_frame(2, None).map(|f| f.0).is_none()); let again = DiskCache::open(&dir, 1 << 30).unwrap(); assert_eq!(again.stats().entries, 0); } @@ -1469,11 +1836,11 @@ mod tests { let dir = tmpdir("lru"); let dc = DiskCache::open(&dir, u64::MAX).unwrap(); for k in 0..6u128 { - dc.put_layer_now(k, &buf(k as u32, 64, 64)); + dc.put_layer_now(k, &buf(k as u32, 64, 64), &ReadFiles::default()); } let per = dc.stats().bytes / 6; // Touch 0 so it is the most recently used. - assert!(dc.get_layer(0).is_some()); + assert!(dc.get_layer(0, None).is_some()); dc.set_max_bytes(per * 3 + per * 3 / 4); let st = dc.stats(); assert!(st.bytes <= per * 3 + per * 3 / 4, "{st:?}"); @@ -1485,7 +1852,7 @@ mod tests { let files = walk(&dir); assert_eq!(files, 3); // New writes keep evicting. - dc.put_layer_now(99, &buf(9, 64, 64)); + dc.put_layer_now(99, &buf(9, 64, 64), &ReadFiles::default()); assert!(dc.stats().bytes <= per * 3 + per * 3 / 4); } @@ -1510,7 +1877,7 @@ mod tests { let dir = tmpdir("space"); let dc = DiskCache::open(&dir, 1 << 30).unwrap(); for k in 0..4u128 { - dc.put_layer_now(k, &buf(k as u32, 64, 64)); + dc.put_layer_now(k, &buf(k as u32, 64, 64), &ReadFiles::default()); } let used = dc.stats().bytes; // Unknown space: the setting alone. @@ -1519,17 +1886,18 @@ mod tests { // A disk reported as 0 bytes is unknown. dc.set_disk_space(Some((0, 0))); assert_eq!(dc.stats().limit_bytes, 1 << 30); - // 100 GB disk, 5 GB reserved, exactly 5 GB free: the cache may not grow. + // 100 GB disk, 5 GB reserved (5%, between the 2 GB floor and the 10 GB cap), exactly + // 5 GB free: the cache may not grow. dc.set_disk_space(Some((100 << 30, 5 << 30))); let st = dc.stats(); assert_eq!((st.max_bytes, st.limit_bytes), (1 << 30, used)); - dc.put_layer_now(9, &buf(9, 64, 64)); + dc.put_layer_now(9, &buf(9, 64, 64), &ReadFiles::default()); let st = dc.stats(); assert!(st.bytes <= st.limit_bytes && st.bytes <= used - used / 10 && st.evictions > 0, "{st:?}"); assert!(dc.contains(Kind::Layer, 9), "the new entry is the most recently used"); // Below the reserve the cache gives its space back, and skips writes that can't fit. dc.set_disk_space(Some((100 << 30, 4 << 30))); - dc.put_layer_now(10, &buf(10, 64, 64)); + dc.put_layer_now(10, &buf(10, 64, 64), &ReadFiles::default()); let st = dc.stats(); assert_eq!((st.limit_bytes, st.entries, st.bytes), (0, 0, 0), "{st:?}"); // Plenty of space again: back to the setting. @@ -1538,14 +1906,86 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// The reserve is 5% of the disk, at least 2 GB and at most 10 GB: a large disk with plenty + /// free keeps the user's size. (A 4 TB disk with 190 GB free reserved 200 GB, held a 20 GB + /// cache at 0, and every write was refused.) + #[test] + fn a_large_disk_reserves_at_most_ten_gb() { + const GB: u64 = 1 << 30; + assert_eq!(disk_reserve(4096 * GB), 10 * GB); + assert_eq!(disk_reserve(100 * GB), 5 * GB); + assert_eq!(disk_reserve(32 * GB), 2 * GB); + assert_eq!(disk_reserve(1), 2 * GB); + let dir = tmpdir("space-large"); + let dc = DiskCache::open(&dir, 20 * GB).unwrap(); + dc.set_disk_space(Some((4096 * GB, 190 * GB))); + let st = dc.stats(); + assert_eq!((st.max_bytes, st.limit_bytes), (20 * GB, 20 * GB), "{st:?}"); + assert!(dc.accepts_writes()); + assert_eq!(dc.reserve_bytes(), Some(10 * GB)); + // Just above the 10 GB reserve: the cache may use what is left above it. + dc.set_disk_space(Some((4096 * GB, 15 * GB))); + assert_eq!(dc.stats().limit_bytes, 5 * GB); + // A small disk still keeps 2 GB free: 3 GB free leaves 1 GB for the cache. + dc.set_disk_space(Some((32 * GB, 3 * GB))); + assert_eq!(dc.stats().limit_bytes, GB); + // Below the floor: nothing. + dc.set_disk_space(Some((32 * GB, GB))); + assert_eq!(dc.stats().limit_bytes, 0); + assert!(!dc.accepts_writes()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A write the free disk space refuses is refused before its pixels are compressed: the + /// frame workers spent about 8 ms per frame and 14 ms per layer encoding entries that were + /// then refused (`limit_bytes=0`). + #[test] + fn no_encode_while_the_disk_has_no_room() { + const GB: u64 = 1 << 30; + let dir = tmpdir("space-no-encode"); + let dc = DiskCache::open(&dir, 20 * GB).unwrap(); + // 4 TB disk, 1 GB free: under the 10 GB reserve, the limit is 0. + dc.set_disk_space(Some((4096 * GB, GB))); + assert_eq!(dc.stats().limit_bytes, 0); + let rgba = vec![7u8; 16 * 16 * 4]; + assert!(!dc.put_frame(1, 16, 16, &rgba, &ReadFiles::default(), None, 0), "refused"); + assert!(!dc.put_frame_f16(2, 16, 16, &[0x3c00u16; 16 * 16 * 4], &ReadFiles::default(), None, 0), "refused"); + dc.put_layer(3, &buf(3, 16, 16), &EntryFiles::default(), 0); + dc.flush(); + assert_eq!(dc.encodes(), 0, "nothing compressed"); + assert_eq!(dc.stats().entries, 0); + // 32 MB of room above the reserve: a few frames would fill it, the rest would be + // compressed only to be refused. Not worth encoding either. + dc.set_disk_space(Some((4096 * GB, 10 * GB + (32 << 20)))); + assert_eq!(dc.stats().limit_bytes, 32 << 20); + assert!(!dc.accepts_writes()); + assert!(!dc.put_frame(1, 16, 16, &rgba, &ReadFiles::default(), None, 0), "refused"); + dc.put_layer(3, &buf(3, 16, 16), &EntryFiles::default(), 0); + assert_eq!(dc.encodes(), 0, "nothing compressed"); + // A small limit the setting asks for (not free space) is used. + dc.set_disk_space(None); + dc.set_max_bytes(32 << 20); + assert!(dc.accepts_writes()); + dc.set_max_bytes(20 * GB); + // Room again: the same writes are compressed and stored. + dc.set_disk_space(Some((4096 * GB, 500 * GB))); + assert!(dc.put_frame(1, 16, 16, &rgba, &ReadFiles::default(), None, 0)); + assert!(dc.put_frame_f16(2, 16, 16, &[0x3c00u16; 16 * 16 * 4], &ReadFiles::default(), None, 0)); + dc.put_layer(3, &buf(3, 16, 16), &EntryFiles::default(), 0); + dc.flush(); + assert_eq!(dc.encodes(), 3); + assert_eq!(dc.stats().entries, 3); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn failed_writes_are_counted() { let dir = tmpdir("failed"); let dc = DiskCache::open(&dir, 1 << 30).unwrap(); // A file where the layers folder should be: every layer write fails. std::fs::write(dir.join(VERSION).join(Kind::Layer.dir()), b"x").unwrap(); - dc.put_layer_now(1, &buf(1, 8, 8)); - dc.put_layer_now(2, &buf(2, 8, 8)); + dc.put_layer_now(1, &buf(1, 8, 8), &ReadFiles::default()); + dc.put_layer_now(2, &buf(2, 8, 8), &ReadFiles::default()); let st = dc.stats(); assert_eq!((st.failed, st.writes, st.entries), (2, 0, 0)); let _ = std::fs::remove_dir_all(&dir); @@ -1570,13 +2010,13 @@ mod tests { fn damaged_files_are_misses() { let dir = tmpdir("damaged"); let dc = DiskCache::open(&dir, 1 << 30).unwrap(); - dc.put_frame_now(5, 4, 4, &[1; 64]); + dc.put_frame_now(5, 4, 4, &[1; 64], &ReadFiles::default()); let p = dc.path(Kind::Frame, 5); let mut d = std::fs::read(&p).unwrap(); let n = d.len(); d[n / 2] ^= 0xff; std::fs::write(&p, d).unwrap(); - assert!(dc.get_frame(5).is_none()); + assert!(dc.get_frame(5, None).map(|f| f.0).is_none()); assert!(!dc.contains(Kind::Frame, 5)); assert!(!p.exists()); } @@ -1594,8 +2034,8 @@ mod tests { for i in 0..20u128 { let k = i % 5; let w = if t % 2 == 0 { &a } else { &b }; - w.put_layer_now(k, &buf(k as u32, 16, 16)); - if let Some(x) = w.get_layer(k) { + w.put_layer_now(k, &buf(k as u32, 16, 16), &ReadFiles::default()); + if let Some((x, _)) = w.get_layer(k, None) { assert_eq!(x.img, buf(k as u32, 16, 16).img); } } @@ -1604,39 +2044,16 @@ mod tests { }); b.rescan(); for k in 0..5u128 { - assert_eq!(b.get_layer(k).unwrap().img, buf(k as u32, 16, 16).img); + assert_eq!(b.get_layer(k, None).unwrap().0.img, buf(k as u32, 16, 16).img); } } - /// 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. + /// Replaced bytes of a path with no native metadata (the browser file table) change neither + /// the comp key nor a layer's disk key (both from memory): the frame and layer entries made + /// of them check the hook's size and content hash when read and are misses, deleted (#1253, + /// #3184). #[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() { + fn virtual_file_bytes_are_checked_by_the_entries_not_the_keys() { use effectcraft_project::build; use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project}; use effectcraft_time::{FrameRate, Tick}; @@ -1650,12 +2067,335 @@ mod tests { let id = |mark: u64| move |path: &str| (path == "/virtual.mov").then_some((8, mark)); let (one, two) = (id(1), id(2)); - let a = footage_salt_with(&p, Some(&one)); - let b = footage_salt_with(&p, Some(&two)); - let ka = comp_content_key_with(&p, cid, Some(&one)); - let kb = comp_content_key_with(&p, cid, Some(&two)); - assert_ne!(a, b, "replaced virtual media must not reuse the salt"); - assert_ne!(ka, kb, "replaced virtual media must not reuse the frame key"); - assert_eq!(comp_content_key(&p, cid), comp_content_key_with(&p, cid, None)); + let dc = DiskCache::open(tmpdir("virtual"), 1 << 30).unwrap(); + let k = frame_key(comp_content_key(&p, cid), 0, 1000, 0); + let files: ReadFiles = [crate::reads::ReadFile::new("/virtual.mov", crate::reads::FileStamp::Virtual)].into_iter().collect(); + assert!(dc.put_frame(k, 1, 1, &[1, 2, 3, 4], &files, Some(&one), 0)); + dc.flush(); + assert!(dc.get_frame(k, Some(&one)).is_some_and(|(_, f)| f == files)); + assert!(dc.get_frame(k, Some(&two)).is_none() && !dc.contains(Kind::Frame, k), "replaced: a miss, deleted"); + // A layer buffer made of it. + let lk = layer_disk_key(7); + dc.put_layer(lk, &buf(1, 4, 4), &EntryFiles::of(&files, Some(&one)).unwrap(), 0); + dc.flush(); + assert!(dc.get_layer(lk, Some(&one)).is_some_and(|(b, f)| b.img == buf(1, 4, 4).img && f == files && f.is_complete())); + assert!(dc.get_layer(lk, None).is_none() && !dc.contains(Kind::Layer, lk), "no identity: a miss, deleted"); + dc.put_layer(lk, &buf(1, 4, 4), &EntryFiles::of(&files, Some(&one)).unwrap(), 0); + dc.flush(); + assert!(dc.get_layer(lk, Some(&two)).is_none() && !dc.contains(Kind::Layer, lk), "replaced: a miss, deleted"); + // The browser's sealed layer entries carry the same list. + let e = layer_entry(&buf(2, 3, 2), &EntryFiles::of(&files, Some(&one)).unwrap()); + let (b, list) = read_layer_entry(&e).unwrap(); + assert!(b.img == buf(2, 3, 2).img && list.unchanged(Some(&one)) && !list.unchanged(Some(&two))); + assert!(read_layer_entry(&seal_format(Kind::Layer, FORMAT_RGBA8, &encode_layer(&buf(2, 3, 2)))).is_none(), "the format before #3184"); + let mut bad = e.clone(); + bad[12] ^= 1; + assert!(read_layer_entry(&bad).is_none(), "damaged"); + } + + /// Layer disk keys come from the layer cache's key alone (no file, no project-wide salt) and + /// differ per key. + #[test] + fn layer_disk_keys_need_no_files() { + assert_eq!(layer_disk_key(1), layer_disk_key(1)); + assert_ne!(layer_disk_key(1), layer_disk_key(2)); + // Not the layout of the salted keys before #3184 (`salt << 64 | key`). + assert_ne!(layer_disk_key(1) as u64, 1); + } + + /// A layer entry is used only while the files it lists are what the buffer was made of: a + /// new size, a new time, a missing file that is back, a file replaced by a folder, a damaged + /// files section and the format before #3184 are misses, and the entry is deleted so the + /// buffer is rendered and stored again. A buffer whose files can't all be named is not + /// stored. Only the listed files are looked at. + #[test] + fn layer_entries_check_their_files() { + use crate::reads::{FileStamp, ReadFile}; + let dir = tmpdir("layer-check"); + let src = dir.join("src"); + std::fs::create_dir_all(&src).unwrap(); + let file = |name: &str, bytes: &[u8]| { + let p = src.join(name); + std::fs::write(&p, bytes).unwrap(); + p.to_string_lossy().to_string() + }; + let (a, b) = (file("a.png", b"aa"), file("b.png", b"bb")); + let gone = src.join("gone.png").to_string_lossy().to_string(); + let read = |paths: &[&str]| -> ReadFiles { paths.iter().map(|p| ReadFile::new(*p, FileStamp::native(p).unwrap_or(FileStamp::Missing))).collect() }; + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + dc.put_layer_now(1, &buf(1, 4, 4), &read(&[&a])); + dc.put_layer_now(2, &buf(2, 4, 4), &read(&[&b, &gone])); + dc.put_layer_now(3, &buf(3, 4, 4), &read(&[&b])); + let (one, files) = dc.get_layer(1, None).unwrap(); + assert!(one.img == buf(1, 4, 4).img && files == read(&[&a]) && files.is_complete()); + assert_eq!(dc.file_checks(), 1, "only the file of that buffer"); + // a.png gets a new size: entry 1 is a miss and deleted, the others stay. + std::fs::write(&a, b"aaa").unwrap(); + assert!(dc.get_layer(1, None).is_none() && !dc.contains(Kind::Layer, 1)); + assert!(dc.get_layer(2, None).is_some() && dc.get_layer(3, None).is_some()); + // b.png gets a new time only. + let t = std::fs::metadata(&b).unwrap().modified().unwrap() + std::time::Duration::from_secs(7); + std::fs::File::options().append(true).open(&b).unwrap().set_modified(t).unwrap(); + assert!(dc.get_layer(3, None).is_none() && !dc.contains(Kind::Layer, 3)); + assert!(dc.get_layer(2, None).is_none(), "b.png is listed there too"); + // A missing file that is back. + dc.put_layer_now(4, &buf(4, 4, 4), &read(&[&gone])); + assert!(dc.get_layer(4, None).is_some(), "missing, still missing"); + file("gone.png", b"g"); + assert!(dc.get_layer(4, None).is_none() && !dc.contains(Kind::Layer, 4), "back"); + // A file replaced by a folder (unreadable). + let c = file("c.png", b"cc"); + dc.put_layer_now(5, &buf(5, 4, 4), &read(&[&c])); + std::fs::remove_file(&c).unwrap(); + std::fs::create_dir(&c).unwrap(); + assert!(dc.get_layer(5, None).is_none() && !dc.contains(Kind::Layer, 5)); + // Written again after the miss: a hit with the new identity. + dc.put_layer_now(1, &buf(9, 4, 4), &read(&[&a])); + assert_eq!(dc.get_layer(1, None).map(|l| l.0.img), Some(buf(9, 4, 4).img)); + // The format before #3184 (no files): a miss, deleted. + dc.write_format(Kind::Layer, 6, FORMAT_RGBA8, &encode_layer(&buf(6, 4, 4))); + assert!(dc.contains(Kind::Layer, 6)); + assert!(dc.get_layer(6, None).is_none() && !dc.contains(Kind::Layer, 6)); + // A valid checksum over a damaged files section, or over a cut buffer: misses, deleted. + let mut payload = vec![]; + payload.extend_from_slice(&3u32.to_le_bytes()); + payload.extend_from_slice(&encode_layer(&buf(7, 4, 4))); + dc.write_format(Kind::Layer, 7, FORMAT_LAYER_FILES, &payload); + assert!(dc.get_layer(7, None).is_none() && !dc.contains(Kind::Layer, 7)); + let mut cut = vec![]; + EntryFiles::default().encode(&mut cut); + cut.extend_from_slice(&encode_layer(&buf(8, 4, 4))[..40]); + dc.write_format(Kind::Layer, 8, FORMAT_LAYER_FILES, &cut); + assert!(dc.get_layer(8, None).is_none() && !dc.contains(Kind::Layer, 8)); + // A damaged file on disk. + dc.put_layer_now(9, &buf(9, 4, 4), &ReadFiles::default()); + let p = dc.path(Kind::Layer, 9); + let mut d = std::fs::read(&p).unwrap(); + let n = d.len(); + d[n / 2] ^= 0xff; + std::fs::write(&p, d).unwrap(); + assert!(dc.get_layer(9, None).is_none() && !dc.contains(Kind::Layer, 9) && !p.exists()); + // Buffers whose files can't all be named are not stored. + let mut incomplete = read(&[&a]); + incomplete.mark_incomplete(); + dc.put_layer_now(10, &buf(10, 4, 4), &incomplete); + assert!(!dc.contains(Kind::Layer, 10)); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A frame entry is used only while the files it lists are what it was made of: a new size, + /// a new time, a missing file that is back, a file replaced by a folder are misses, and the + /// entry is deleted so the frame is rendered and stored again. Only the listed files are + /// looked at. Entries of the format before #3184 are misses and deleted. + #[test] + fn frame_entries_check_their_files() { + use crate::reads::{FileStamp, ReadFile}; + let dir = tmpdir("check"); + let src = dir.join("src"); + std::fs::create_dir_all(&src).unwrap(); + let file = |name: &str, bytes: &[u8]| { + let p = src.join(name); + std::fs::write(&p, bytes).unwrap(); + p.to_string_lossy().to_string() + }; + let (a, b) = (file("a.png", b"aa"), file("b.png", b"bb")); + let gone = src.join("gone.png").to_string_lossy().to_string(); + let read = |paths: &[&str]| -> ReadFiles { paths.iter().map(|p| ReadFile::new(*p, FileStamp::native(p).unwrap_or(FileStamp::Missing))).collect() }; + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + dc.put_frame_now(1, 1, 1, &[1; 4], &read(&[&a])); + dc.put_frame_now(2, 1, 1, &[2; 4], &read(&[&b, &gone])); + dc.put_frame_now(3, 1, 1, &[3; 4], &read(&[&b])); + assert_eq!(dc.get_frame(1, None).map(|f| f.1), Some(read(&[&a]))); + assert_eq!(dc.file_checks(), 1, "only the file of that frame"); + // a.png gets a new size: frame 1 is a miss and deleted, the others stay. + std::fs::write(&a, b"aaa").unwrap(); + assert!(dc.get_frame(1, None).is_none() && !dc.contains(Kind::Frame, 1)); + assert!(dc.get_frame(2, None).is_some() && dc.get_frame(3, None).is_some()); + // b.png gets a new time only. + let t = std::fs::metadata(&b).unwrap().modified().unwrap() + std::time::Duration::from_secs(7); + std::fs::File::options().append(true).open(&b).unwrap().set_modified(t).unwrap(); + assert!(dc.get_frame(3, None).is_none() && !dc.contains(Kind::Frame, 3)); + assert!(dc.get_frame(2, None).is_none(), "b.png is listed there too"); + // A missing file that is back. + dc.put_frame_now(4, 1, 1, &[4; 4], &read(&[&gone])); + assert!(dc.get_frame(4, None).is_some(), "missing, still missing"); + file("gone.png", b"g"); + assert!(dc.get_frame(4, None).is_none() && !dc.contains(Kind::Frame, 4), "back"); + // A file replaced by a folder (unreadable). + let c = file("c.png", b"cc"); + dc.put_frame_now(5, 1, 1, &[5; 4], &read(&[&c])); + std::fs::remove_file(&c).unwrap(); + std::fs::create_dir(&c).unwrap(); + assert!(dc.get_frame(5, None).is_none() && !dc.contains(Kind::Frame, 5)); + // Written again after the miss: a hit with the new identity. + let fresh = file("d.png", b"dd"); + dc.put_frame_now(1, 1, 1, &[9; 4], &read(&[&fresh])); + assert_eq!(dc.get_frame(1, None).map(|f| f.0.rgba), Some(vec![9; 4])); + // The format before #3184 (no files): a miss, deleted. + dc.write_format(Kind::Frame, 6, FORMAT_RGBA8, &encode_frame(1, 1, &[6; 4])); + assert!(dc.contains(Kind::Frame, 6)); + assert!(dc.get_frame(6, None).is_none() && !dc.contains(Kind::Frame, 6)); + // A valid checksum over a damaged files section: a miss, deleted. + let mut payload = vec![]; + payload.extend_from_slice(&3u32.to_le_bytes()); + payload.extend_from_slice(&encode_frame(1, 1, &[7; 4])); + dc.write_format(Kind::Frame, 7, FORMAT_RGBA8_FILES, &payload); + assert!(dc.get_frame(7, None).is_none() && !dc.contains(Kind::Frame, 7)); + // Frames whose files can't all be named are not stored. + let mut incomplete = read(&[&fresh]); + incomplete.mark_incomplete(); + assert!(!dc.put_frame(8, 1, 1, &[8; 4], &incomplete, None, 0)); + dc.flush(); + assert!(!dc.contains(Kind::Frame, 8)); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Reload Footage drops the entries made of the reloaded files (#3184): a file replaced by + /// one with the same size and modification time passes the check on hit, so only the drop + /// makes the next use a miss. Entries of other files stay; an entry written after the drop + /// is used; a layer entry and one written while the drop waits in the queue are dropped too; + /// the drop survives a restart (the files are gone). + #[test] + fn dropped_files_take_their_entries_with_them() { + use crate::reads::{FileStamp, ReadFile}; + let dir = tmpdir("drop"); + let src = dir.join("src"); + std::fs::create_dir_all(&src).unwrap(); + let file = |name: &str, bytes: &[u8]| { + let p = src.join(name); + std::fs::write(&p, bytes).unwrap(); + p.to_string_lossy().to_string() + }; + let (a, b) = (file("a.png", b"aa"), file("b.png", b"bb")); + let read = |paths: &[&str]| -> ReadFiles { paths.iter().map(|p| ReadFile::new(*p, FileStamp::native(p).unwrap())).collect() }; + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + dc.put_frame_now(1, 1, 1, &[1; 4], &read(&[&a])); + dc.put_frame_now(2, 1, 1, &[2; 4], &read(&[&b])); + dc.put_frame_now(3, 1, 1, &[3; 4], &read(&[&a, &b])); + dc.put_layer_now(4, &buf(4, 2, 2), &read(&[&a])); + // a.png gets other bytes of the same size and its old time back: the check can't tell. + let t = std::fs::metadata(&a).unwrap().modified().unwrap(); + std::fs::write(&a, b"AA").unwrap(); + std::fs::File::options().append(true).open(&a).unwrap().set_modified(t).unwrap(); + assert!(dc.get_frame(1, None).is_some(), "same size and time: still a hit"); + // A frame queued (not written yet) when the drop comes is dropped too. + assert!(dc.put_frame(5, 1, 1, &[5; 4], &read(&[&a]), None, 0)); + dc.drop_files([a.clone()]); + dc.flush(); + assert!(!dc.is_dropping()); + for (kind, key) in [(Kind::Frame, 1), (Kind::Frame, 3), (Kind::Layer, 4), (Kind::Frame, 5)] { + assert!(!dc.contains(kind, key), "{kind:?} {key} made of a.png is gone"); + } + assert!(dc.get_frame(1, None).is_none() && dc.get_layer(4, None).is_none()); + assert_eq!(dc.get_frame(2, None).map(|f| f.0.rgba), Some(vec![2; 4]), "b.png's frame stays"); + // Written again from the reloaded file: used. + dc.put_frame_now(1, 1, 1, &[9; 4], &read(&[&a])); + assert_eq!(dc.get_frame(1, None).map(|f| f.0.rgba), Some(vec![9; 4])); + // The drop is for good: a new session on the folder finds what is left. + drop(dc); + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + assert_eq!(dc.keys(Kind::Frame).into_iter().collect::>(), [1, 2].into()); + assert!(dc.keys(Kind::Layer).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A render under way when Reload Footage drops its files offers its entries after the drop + /// (#3184), when the writer has already deleted the old ones: made of the files as they were, + /// they are refused, frames and layer buffers alike. What the same render made of other files + /// is stored, and so is what a render that started after the drop makes. + #[test] + fn entries_made_before_a_drop_are_refused_after_it() { + use crate::reads::{FileStamp, ReadFile}; + let dir = tmpdir("drop-in-flight"); + std::fs::create_dir_all(&dir).unwrap(); + let file = |name: &str| { + let p = dir.join(name); + std::fs::write(&p, name.as_bytes()).unwrap(); + p.to_string_lossy().to_string() + }; + let (a, b) = (file("a.png"), file("b.png")); + let read = |paths: &[&str]| -> ReadFiles { paths.iter().map(|p| ReadFile::new(*p, FileStamp::native(p).unwrap())).collect() }; + let dc = DiskCache::open(dir.join("cache"), 1 << 30).unwrap(); + // The render starts (and reads a.png and b.png), then a.png is reloaded. + let before = dc.drop_generation(); + dc.drop_files([a.clone()]); + dc.flush(); + assert!(!dc.is_dropping()); + assert!(dc.dropped_since(&read(&[&a]), before) && !dc.dropped_since(&read(&[&b]), before)); + assert!(!dc.put_frame(1, 1, 1, &[1; 4], &read(&[&a, &b]), None, before), "a frame from before the drop"); + assert!(!dc.put_frame_f16(2, 1, 1, &[1; 4], &read(&[&a]), None, before)); + dc.put_layer(3, &buf(3, 2, 2), &EntryFiles::of(&read(&[&a]), None).unwrap(), before); + assert!(dc.put_frame(4, 1, 1, &[4; 4], &read(&[&b]), None, before), "b.png wasn't dropped"); + dc.flush(); + assert!(!dc.contains(Kind::Frame, 1) && !dc.contains(Kind::Frame, 2) && !dc.contains(Kind::Layer, 3)); + assert!(dc.get_frame(1, None).is_none() && dc.get_layer(3, None).is_none()); + assert_eq!(dc.get_frame(4, None).map(|f| f.0.rgba), Some(vec![4; 4])); + // A render that starts after the drop reads the reloaded file: stored and served. + let after = dc.drop_generation(); + assert!(after > before && !dc.dropped_since(&read(&[&a]), after)); + assert!(dc.put_frame(5, 1, 1, &[5; 4], &read(&[&a]), None, after)); + dc.put_layer(6, &buf(6, 2, 2), &EntryFiles::of(&read(&[&a]), None).unwrap(), after); + dc.flush(); + assert_eq!(dc.get_frame(5, None).map(|f| f.0.rgba), Some(vec![5; 4])); + assert!(dc.get_layer(6, None).is_some()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The record of dropped files is bounded: past it, everything made before the drop that + /// forgot the record is refused, and what is made after it is stored. + #[test] + fn the_record_of_dropped_files_is_bounded() { + let mut d = Drops::default(); + let a: Arc = "/a.png".into(); + d.record(std::slice::from_ref(&a)); + assert!(d.stale([&a].into_iter(), 0) && !d.stale([&a].into_iter(), 1)); + let many: Vec> = (0..=MAX_DROPPED_PATHS).map(|i| format!("/f{i}").into()).collect(); + d.record(&many); + assert!(d.last.is_empty(), "forgotten, not grown"); + let other: Arc = "/other.png".into(); + assert!(d.stale([&other].into_iter(), 1), "made before the drop that forgot the record"); + assert!(!d.stale([&a].into_iter(), 2), "made after it"); + } + + /// While a drop waits, an entry made of a dropped file is a miss (and deleted), and frames + /// aren't linked to new keys. + #[test] + fn entries_of_a_pending_drop_are_misses() { + use crate::reads::{FileStamp, ReadFile}; + let dir = tmpdir("drop-pending"); + std::fs::create_dir_all(&dir).unwrap(); + let a = dir.join("a.png"); + std::fs::write(&a, b"aa").unwrap(); + let a = a.to_string_lossy().to_string(); + let files: ReadFiles = [ReadFile::new(a.as_str(), FileStamp::native(&a).unwrap())].into_iter().collect(); + let dc = DiskCache::open(dir.join("cache"), 1 << 30).unwrap(); + dc.put_frame_now(1, 1, 1, &[1; 4], &files); + dc.dropping.lock().unwrap().insert(a.as_str().into(), 1); + assert!(!dc.link_frame(1, 2)); + assert!(dc.get_frame(1, None).is_none() && !dc.contains(Kind::Frame, 1)); + let _ = std::fs::remove_dir_all(&dir); + } + + /// An entry read while its file's drop was pending is a miss even when the writer ends the + /// sweep between the read and the check (it deleted the file, but its bytes were read), and + /// so is one read before a drop that starts meanwhile. Found under load: a stale hit. + #[test] + fn a_sweep_ending_during_a_read_never_makes_a_hit() { + use crate::reads::{FileStamp, ReadFile}; + let dir = tmpdir("drop-race"); + let dc = DiskCache::open(dir.join("cache"), 1 << 30).unwrap(); + let files = EntryFiles::of(&[ReadFile::new("/a.png", FileStamp::Missing)].into_iter().collect(), None).unwrap(); + // Read while the drop is pending; the sweep ends before the check. + dc.dropping.lock().unwrap().insert("/a.png".into(), 1); + let drops = dc.drops_now(); + dc.dropping.lock().unwrap().clear(); + assert!(dc.made_of_dropped(&files, &drops)); + // Read before a drop that starts (and ends) before the check. + let drops = dc.drops_now(); + dc.drop_files(["/a.png"]); + dc.flush(); + assert!(dc.made_of_dropped(&files, &drops)); + assert!(!dc.made_of_dropped(&files, &dc.drops_now()), "read after the drop: used"); + let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/render/src/disk_cache/entry_files.rs b/crates/render/src/disk_cache/entry_files.rs new file mode 100644 index 000000000..a36ca1033 --- /dev/null +++ b/crates/render/src/disk_cache/entry_files.rs @@ -0,0 +1,276 @@ +//! The source files a frame entry on disk was made of (#3184): stored with the pixels, checked +//! when the entry is used. Only the files that frame read are looked at, never every file of the +//! project. +//! +//! Layout (little endian), before the frame's pixel payload: +//! `u32 count`, then per file `u32 path length`, the path (UTF-8), a tag byte and its fields: +//! `0` a file on disk (`u64 size`, `u8 has time`, `i128 time`: nanoseconds since the Unix epoch, +//! negative before it), `1` bytes from the browser's file table (`u64 size`, `u64 content +//! hash`), `2` a file that could not be read. + +use std::sync::Arc; + +use super::VirtualFileId; +use crate::reads::{FileStamp, MAX_READ_FILES, ReadFile, ReadFiles}; + +/// Longest path an entry stores (bytes): a longer one is not a path this app opened. +const MAX_PATH_BYTES: usize = 1 << 16; + +/// A file's identity as a frame entry keeps it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EntryStamp { + /// Size and modification time (nanoseconds since the Unix epoch, negative before it) on + /// disk. + Native { len: u64, modified: Option }, + /// Size and content hash from the [`VirtualFileId`] hook (the browser's file table). + Virtual { len: u64, hash: u64 }, + /// It could not be read when the frame was made: it still must not be readable. + Missing, +} + +/// One file of a frame entry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EntryFile { + pub path: Arc, + pub stamp: EntryStamp, +} + +/// Every file a frame entry was made of. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct EntryFiles(Vec); + +impl EntryFiles { + /// The list to store for a frame that read `files`: `None` when the frame can't be checked + /// against its files later (the list is incomplete, or a file from memory has no identity), + /// so the frame must not be stored. Native stamps are the ones the files were read with, + /// never a fresh look at the disk (a file changed during the render keeps the old one). + pub fn of(files: &ReadFiles, virtual_id: Option>) -> Option { + if !files.is_complete() { + return None; + } + let mut out = Vec::with_capacity(files.len()); + for f in files.files() { + let stamp = match f.stamp { + FileStamp::Native { len, modified } => EntryStamp::Native { len, modified }, + FileStamp::Virtual => { + let (len, hash) = virtual_id.and_then(|id| id(&f.path))?; + EntryStamp::Virtual { len, hash } + } + FileStamp::Missing => EntryStamp::Missing, + }; + out.push(EntryFile { path: f.path.clone(), stamp }); + } + Some(EntryFiles(out)) + } + + /// The listed files. + pub fn files(&self) -> &[EntryFile] { + &self.0 + } + + /// The number of listed files. + pub fn len(&self) -> usize { + self.0.len() + } + + /// Whether no files are listed. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// The files as a render reports them (to keep with the frame in memory). + pub fn to_read_files(&self) -> ReadFiles { + self.0 + .iter() + .map(|f| { + let stamp = match f.stamp { + EntryStamp::Native { len, modified } => FileStamp::Native { len, modified }, + EntryStamp::Virtual { .. } => FileStamp::Virtual, + EntryStamp::Missing => FileStamp::Missing, + }; + ReadFile::new(f.path.clone(), stamp) + }) + .collect() + } + + /// Whether every file still is what the frame was made of: one `metadata` call per file + /// (and the hook for files from memory). + pub fn unchanged(&self, virtual_id: Option>) -> bool { + self.0.iter().all(|f| file_unchanged(f, virtual_id)) + } + + /// Appends the list to `out` in little-endian binary form. + pub(super) fn encode(&self, out: &mut Vec) { + out.extend_from_slice(&(self.0.len() as u32).to_le_bytes()); + for f in &self.0 { + out.extend_from_slice(&(f.path.len() as u32).to_le_bytes()); + out.extend_from_slice(f.path.as_bytes()); + match f.stamp { + EntryStamp::Native { len, modified } => { + out.push(0); + out.extend_from_slice(&len.to_le_bytes()); + out.push(u8::from(modified.is_some())); + out.extend_from_slice(&modified.unwrap_or(0).to_le_bytes()); + } + EntryStamp::Virtual { len, hash } => { + out.push(1); + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(&hash.to_le_bytes()); + } + EntryStamp::Missing => out.push(2), + } + } + } + + /// The list at the start of `p` and what follows it; `None` when it is damaged. + pub(super) fn decode(p: &[u8]) -> Option<(EntryFiles, &[u8])> { + let mut r = Reader(p); + let n = r.u32()? as usize; + if n > MAX_READ_FILES { + return None; + } + let mut out = Vec::with_capacity(n); + for _ in 0..n { + let len = r.u32()? as usize; + if len > MAX_PATH_BYTES { + return None; + } + let path: Arc = std::str::from_utf8(r.take(len)?).ok()?.into(); + let stamp = match r.u8()? { + 0 => { + let len = r.u64()?; + let has = r.u8()?; + let t = r.i128()?; + match has { + 0 => EntryStamp::Native { len, modified: None }, + 1 => EntryStamp::Native { len, modified: Some(t) }, + _ => return None, + } + } + 1 => EntryStamp::Virtual { len: r.u64()?, hash: r.u64()? }, + 2 => EntryStamp::Missing, + _ => return None, + }; + out.push(EntryFile { path, stamp }); + } + Some((EntryFiles(out), r.0)) + } +} + +/// Whether the file still matches its stored stamp, by its native stamp or by its in-memory identity. +fn file_unchanged(f: &EntryFile, virtual_id: Option>) -> bool { + let from_memory = || virtual_id.and_then(|id| id(&f.path)); + match f.stamp { + EntryStamp::Native { len, modified } => FileStamp::native(&f.path) == Some(FileStamp::Native { len, modified }), + EntryStamp::Virtual { len, hash } => from_memory() == Some((len, hash)), + // (a folder at the path is still not a file to read) + EntryStamp::Missing => !std::fs::metadata(&*f.path).is_ok_and(|m| m.is_file()) && from_memory().is_none(), + } +} + +/// Reads little-endian fields off the front of a byte slice. +struct Reader<'a>(&'a [u8]); + +impl<'a> Reader<'a> { + /// Takes the next `n` bytes, or `None` when fewer remain. + fn take(&mut self, n: usize) -> Option<&'a [u8]> { + let head = self.0.get(..n)?; + self.0 = self.0.get(n..)?; + Some(head) + } + /// Reads one byte. + fn u8(&mut self) -> Option { + self.take(1)?.first().copied() + } + /// Reads a little-endian `u32`. + fn u32(&mut self) -> Option { + Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?)) + } + /// Reads a little-endian `u64`. + fn u64(&mut self) -> Option { + Some(u64::from_le_bytes(self.take(8)?.try_into().ok()?)) + } + /// Reads a little-endian `i128`. + fn i128(&mut self) -> Option { + Some(i128::from_le_bytes(self.take(16)?.try_into().ok()?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A sample list of native, missing and virtual files for the tests. + fn files() -> ReadFiles { + [ + ReadFile::new("/a.png", FileStamp::Native { len: 3, modified: Some(9) }), + ReadFile::new("/b.png", FileStamp::Native { len: 4, modified: None }), + // Before 1970, and past where `u64` nanoseconds end (2554). + ReadFile::new("/d.png", FileStamp::Native { len: 5, modified: Some(-86_400_000_000_000) }), + ReadFile::new("/e.png", FileStamp::Native { len: 6, modified: Some(i128::from(u64::MAX) + 1) }), + ReadFile::new("/c.png", FileStamp::Missing), + ReadFile::new("/v.mov", FileStamp::Virtual), + ] + .into_iter() + .collect() + } + + #[test] + fn lists_round_trip_and_damage_is_caught() { + let id = |p: &str| (p == "/v.mov").then_some((8, 77)); + let list = EntryFiles::of(&files(), Some(&id)).unwrap(); + assert_eq!(list.files()[5].stamp, EntryStamp::Virtual { len: 8, hash: 77 }); + let mut bytes = vec![]; + list.encode(&mut bytes); + bytes.extend_from_slice(b"rest"); + let (back, rest) = EntryFiles::decode(&bytes).unwrap(); + assert_eq!((back, rest), (list.clone(), &b"rest"[..])); + assert_eq!(list.to_read_files(), files()); + // Every truncation is caught (never a panic, never a shorter list). + let mut plain = vec![]; + list.encode(&mut plain); + for n in 0..plain.len() { + assert!(EntryFiles::decode(&plain[..n]).is_none(), "cut at {n}"); + } + // Bad tags and counts. + let mut bad = plain.clone(); + bad[0..4].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(EntryFiles::decode(&bad).is_none()); + } + + #[test] + fn frames_that_cant_be_checked_are_not_stored() { + let mut incomplete = files(); + incomplete.mark_incomplete(); + let id = |_: &str| Some((1, 1)); + assert!(EntryFiles::of(&incomplete, Some(&id)).is_none(), "an incomplete list"); + assert!(EntryFiles::of(&files(), None).is_none(), "a file from memory without the hook"); + assert!(EntryFiles::of(&ReadFiles::default(), None).is_some_and(|l| l.is_empty()), "a frame that reads no file"); + } + + #[test] + fn the_check_looks_at_the_listed_files() { + let dir = std::env::temp_dir().join(format!("effectcraft-entry-files-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("f.bin"); + std::fs::write(&p, b"abc").unwrap(); + let path = p.to_string_lossy().to_string(); + let gone = dir.join("gone.bin").to_string_lossy().to_string(); + let stamp = FileStamp::native(&path).unwrap(); + let read: ReadFiles = [ReadFile::new(path.as_str(), stamp), ReadFile::new(gone.as_str(), FileStamp::Missing)].into_iter().collect(); + let list = EntryFiles::of(&read, None).unwrap(); + assert!(list.unchanged(None)); + std::fs::write(&p, b"abcd").unwrap(); + assert!(!list.unchanged(None), "a new size"); + std::fs::write(&gone, b"x").unwrap(); + let list = EntryFiles::of(&[ReadFile::new(gone.as_str(), FileStamp::Missing)].into_iter().collect(), None).unwrap(); + assert!(!list.unchanged(None), "a missing file that is back"); + let v: ReadFiles = [ReadFile::new("/v", FileStamp::Virtual)].into_iter().collect(); + let one = |_: &str| Some((1, 1)); + let two = |_: &str| Some((1, 2)); + let list = EntryFiles::of(&v, Some(&one)).unwrap(); + assert!(list.unchanged(Some(&one)) && !list.unchanged(Some(&two)) && !list.unchanged(None)); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 0a10afa45..41e6132d3 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -44,11 +44,15 @@ pub struct EvalCtx<'a> { /// Media Replacement values overridden by the Essential Properties of a collapsed precomp /// instance. pub media_overrides: &'a [crate::MediaOverride], + /// Where the source files of what expressions render (`sampleImage`) are noted: the + /// renderer's [`Renderer::reads`](crate::Renderer::reads) while rendering. + pub reads: Option<&'a crate::reads::ReadLog>, } impl<'a> EvalCtx<'a> { + /// A context for `comp` at `time` with no expressions, footage, overrides or read log. pub fn new(project: &'a Project, comp_id: ItemId, comp: &'a Comp, time: Tick) -> EvalCtx<'a> { - EvalCtx { project, comp_id, comp, time, expr: None, footage: None, property_overrides: &[], media_overrides: &[] } + EvalCtx { project, comp_id, comp, time, expr: None, footage: None, property_overrides: &[], media_overrides: &[], reads: None } } pub fn at(&self, time: Tick) -> EvalCtx<'a> { EvalCtx { time, ..*self } diff --git a/crates/render/src/footage.rs b/crates/render/src/footage.rs index 4c90967d3..0e17f152d 100644 --- a/crates/render/src/footage.rs +++ b/crates/render/src/footage.rs @@ -1,4 +1,5 @@ use super::{Arc, AtomicBool, Footage, Image, ItemId, ItemKind, Ordering, Tick}; +use crate::reads::ReadLog; /// How a [`FootageSource`] hands over float footage (OpenEXR, float TIFF), whose values are /// linear light: encoded with a curve, as the compositor takes footage, or as stored. Other @@ -92,6 +93,15 @@ pub trait FootageSource: Send + Sync { fn called_off(&self) -> bool { false } + /// Run `f`, which reads from this source on this thread, noting in `reads` every source file + /// those reads use, with its identity as read (see [`crate::reads`]): the file a frame was + /// decoded from, also when it comes from the source's own memory cache (without reading the + /// file again). Sources that can't tell which files they read keep the default, which marks + /// `reads` incomplete. + fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) { + reads.mark_incomplete(); + f() + } /// Why `footage` shows no picture although its file is there: a read of it failed because /// no decoder handles its codec, profile, chroma format or bit depth, or the stream is damaged /// (e.g. "H.264 (High 4:2:2) isn't supported by the built-in decoder: ..."). `None` while it @@ -194,6 +204,10 @@ impl FootageSource for CancellableFootage<'_> { fn called_off(&self) -> bool { self.cancel.load(Ordering::Relaxed) || self.inner.called_off() } + /// Runs `f` with the inner source, collecting the files it reads into `reads`. + fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) { + self.inner.recording(reads, f) + } fn problem(&self, footage: &Footage) -> Option { self.inner.problem(footage) } @@ -211,7 +225,12 @@ pub fn is_vector_footage(f: &Footage) -> bool { pub struct NoFootage; impl FootageSource for NoFootage { + /// Always `None`: there is no footage. fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { None } + /// Reads no files. + fn recording(&self, _: &ReadLog, f: &mut dyn FnMut()) { + f() + } } diff --git a/crates/render/src/fx_host.rs b/crates/render/src/fx_host.rs index 65bafe002..3a6f1a34c 100644 --- a/crates/render/src/fx_host.rs +++ b/crates/render/src/fx_host.rs @@ -95,6 +95,7 @@ impl EffectHost for FxHost<'_, '_, '_> { Some(self.pixels(other, buf, false)) } + /// The audio of footage layer `id` from `start`, or `None` when the layer is not footage. fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option> { let other = self.ctx.layer(effectcraft_project::LayerId(id))?; let LayerSource::Footage { item } = &other.source else { return None }; @@ -104,7 +105,7 @@ impl EffectHost for FxHost<'_, '_, '_> { return None; } let st = other.layer_time(Tick::from_seconds_f64(start)); - self.r.footage.audio(item, f, st, frames, rate) + self.r.read(|| self.r.footage.audio(item, f, st, frames, rate)) } fn self_at(&self, layer_time: f64, effects: usize) -> Option { diff --git a/crates/render/src/layers.rs b/crates/render/src/layers.rs index d105c6f13..df976a857 100644 --- a/crates/render/src/layers.rs +++ b/crates/render/src/layers.rs @@ -34,21 +34,43 @@ impl Shutter { } } +/// `files` (a content's) with the files of the buffers made of it that the layer cache kept: +/// `files` itself when they are all its own. +fn with_kept_files(files: Arc, kept: &[Arc]) -> Arc { + if kept.iter().all(|f| Arc::ptr_eq(f, &files)) { + return files; + } + let mut all = (*files).clone(); + kept.iter().for_each(|f| all.extend(f)); + Arc::new(all) +} + impl<'a> Renderer<'a> { /// The layer with its styles: exterior passes and body (cached per pass). pub(crate) fn styled(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option { - let (content, ckey) = self.content_buf_timed(ctx, layer, timing.as_deref_mut())?; + let (content, ckey, files) = self.content_buf_timed(ctx, layer, timing.as_deref_mut())?; if !styles::active(ctx, layer) { - return Some(StyledLayer { passes: vec![], body: content.clone(), content, key: ckey, ckey, plain: true }); + return Some(StyledLayer { passes: vec![], body: content.clone(), content, key: ckey, ckey, plain: true, files }); } let modes = styles::pass_modes(ctx, layer); let key = ckey.map(|k| cache::styles_key(ctx, layer, k)); - if let (Some(c), Some(k)) = (self.cache, key) - && let Some(body) = c.get(k) - { - let passes: Option> = modes.iter().enumerate().map(|(i, m)| c.get(cache::derive(k, i as u64 + 1)).map(|b| (b, *m))).collect(); + // (a cached body or pass may have been made of other files than the content: another + // worker's, or one from disk; the styled layer lists the files of every buffer it holds) + if let Some((body, body_files)) = self.cached(key) { + let mut kept_files = vec![body_files]; + let passes: Option> = modes + .iter() + .enumerate() + .map(|(i, m)| { + self.cached(key.map(|k| cache::derive(k, i as u64 + 1))).map(|(b, f)| { + kept_files.push(f); + (b, *m) + }) + }) + .collect(); if let Some(passes) = passes { - return Some(StyledLayer { passes, body, content, key, ckey, plain: false }); + let files = with_kept_files(files, &kept_files); + return Some(StyledLayer { passes, body, content, key, ckey, plain: false, files }); } } let t0 = web_time::Instant::now(); @@ -59,16 +81,23 @@ impl<'a> Renderer<'a> { t.cached = false; t.effects.push(("layerStyles".into(), t0.elapsed().as_secs_f64() * 1e3)); } - let mut body = Arc::new(st.body); - let mut passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect(); - // The buffers the cache keeps (another worker's, if it kept them first). - if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { - body = c.insert(k, body); - for (i, (b, _)) in passes.iter_mut().enumerate() { - *b = c.insert(cache::derive(k, i as u64 + 1), b.clone()); - } - } - Some(StyledLayer { passes, body, content, key, ckey, plain: false }) + // The buffers the cache keeps (another worker's, if it kept them first), made of the + // content's files; the styled layer then lists the files of every buffer it holds. + let mut kept_files = vec![]; + let (body, f) = self.keep(key, Arc::new(st.body), &files); + kept_files.push(f); + let passes: Vec<_> = st + .passes + .into_iter() + .enumerate() + .map(|(i, (b, m))| { + let (b, f) = self.keep(key.map(|k| cache::derive(k, i as u64 + 1)), Arc::new(b), &files); + kept_files.push(f); + (b, m) + }) + .collect(); + let files = with_kept_files(files, &kept_files); + Some(StyledLayer { passes, body, content, key, ckey, plain: false, files }) } /// The styled layer's buffers in the blending space. @@ -76,9 +105,10 @@ impl<'a> Renderer<'a> { if self.pipe.to_blend().is_none() { return st; } - let body = self.to_blend(st.body, st.key); - let content = if st.plain { body.clone() } else { self.to_blend(st.content, st.ckey) }; - let passes = st.passes.into_iter().enumerate().map(|(i, (b, m))| (self.to_blend(b, st.key.map(|k| cache::derive(k, i as u64 + 1))), m)).collect(); + let body = self.to_blend(st.body, st.key, &st.files); + let content = if st.plain { body.clone() } else { self.to_blend(st.content, st.ckey, &st.files) }; + let passes = + st.passes.into_iter().enumerate().map(|(i, (b, m))| (self.to_blend(b, st.key.map(|k| cache::derive(k, i as u64 + 1)), &st.files), m)).collect(); StyledLayer { passes, body, content, ..st } } @@ -236,7 +266,11 @@ impl<'a> Renderer<'a> { /// A processed-layer cache key that also covers content motion blur (the averaged source /// depends on the content at every sub-sample). pub(crate) fn content_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option) -> Option { - let k = key?; + let k = self.mask_gate_key(ctx, layer, key)?; + // A layer that reads other layers through its effects' layer parameters gets them drawn + // by this renderer: the switches it passes down (Switches Affect Nested Comps, the + // precomp layers above, motion blur on) change those pixels, not just the layers' own. + let k = if cache::reads_layers(ctx, layer) { cache::derive(k, self.nested_look()) } else { k }; let Some(times) = self.content_times(ctx, layer) else { return Some(k) }; times.iter().try_fold(cache::derive(k, 0x6d62_6c72), |d, t| Some(cache::derive(d, cache::layer_key(&ctx.at(*t), layer, 1.0, false, false)?))) } @@ -257,6 +291,15 @@ impl<'a> Renderer<'a> { } } + /// A cache key of a masked layer's buffer, told apart when mask motion blur can't run at all + /// in this render ([`Self::mask_blur`]'s gate: the render options, the precomp layers above). + /// The key has the layer's own motion blur state, but a mask set to On blurs without it. + pub(crate) fn mask_gate_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option) -> Option { + let k = key?; + let masked = layer.masks().is_some_and(|m| !m.children.is_empty()); + Some(if masked && !self.mask_blur(ctx, layer).allowed { cache::derive(k, 0x6d62_6f66) } else { k }) + } + /// Motion-blur sub-samples for a layer buffer (1 = no motion blur). As in After Effects, a 2D /// layer gets about one sample per output pixel its corners travel during the shutter, /// at least Samples Per Frame and at most the comp's Adaptive Sample Limit; a layer that @@ -582,6 +625,8 @@ pub struct StyledLayer { ckey: Option, /// No layer styles: composite `body` the usual way. pub plain: bool, + /// The source files the layer's buffers were made of (see [`crate::reads`]). + pub(crate) files: Arc, } /// Where a layer buffer lands (see [`Renderer::placement`]). diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index dbd948320..e09794de2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -17,6 +17,7 @@ use color::Region; pub mod eval; pub mod masks; pub mod passes; +pub mod reads; pub mod shapes; pub mod styles; pub mod text; @@ -69,6 +70,10 @@ pub struct Renderer<'a> { pub opts: RenderOpts, /// Processed-layer cache shared across frames (see [`cache`]). `None` renders everything. pub cache: Option<&'a LayerCache>, + /// Where the source files this render reads are noted (see [`reads`]): the frame's own, + /// from the footage source and from the buffers the layer cache serves. `None` records + /// nothing for the frame (buffers kept in the layer cache still record theirs). + pub reads: Option<&'a reads::ReadLog>, /// Optional per-layer timing sink (`frame --bench` / perf readouts). pub profile: Option<&'a std::sync::Mutex>>, /// GPU backend used when [`RenderOpts::backend`] asks for it. diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index e84348fdf..6c79ba7bb 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -3,6 +3,7 @@ use super::footage::{FootageSource, is_vector_footage}; use super::frames::{blend_frames, cineon_frame, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside}; use super::fx_host::{FxHost, essential_overrides}; use super::opts::{Collapse3d, LayerTiming, MAX_THROUGH, RenderOpts, Through}; +use super::reads::{ReadFiles, ReadLog}; use super::{ Arc, AutoKey, AutoPick, BlendMode, Buf, Comp, EffectCtx, EffectEnv, EvalCtx, Footage, FootageKind, FrameBlend, FrameRate, GroupKind, Image, ItemId, ItemKind, Layer, LayerCache, LayerSource, Params, Project, Quality, Region, Renderer, Tick, cache, color, masks, shapes, styles, text, three_d, vec2, @@ -101,6 +102,7 @@ fn collapsed_instance(ctx: &EvalCtx, layer: &Layer, item: ItemId) -> Option<(Vec } impl<'a> Renderer<'a> { + /// A renderer over `project` and `footage` with the given options. pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> { Renderer { project, @@ -108,6 +110,7 @@ impl<'a> Renderer<'a> { expr: None, opts, cache: None, + reads: None, profile: None, accel: None, depth: 0, @@ -129,6 +132,67 @@ impl<'a> Renderer<'a> { self.cache.filter(|_| !self.footage.called_off()) } + /// `read`, a read of this render's footage source, with the files it uses noted in + /// [`Renderer::reads`] (see [`FootageSource::recording`]). + pub(crate) fn read(&self, read: impl FnOnce() -> Option) -> Option { + let Some(log) = self.reads else { return read() }; + let (mut read, mut out) = (Some(read), None); + self.footage.recording(log, &mut || out = read.take().and_then(|f| f())); + out + } + + /// `ctx` noting what it reads (fonts, `sampleImage`) in this render's [`Renderer::reads`]: + /// a context made by an outer render carries that render's log, not the one of the buffer + /// [`Renderer::recorded`] makes. + pub(crate) fn with_reads<'c>(&'c self, ctx: &EvalCtx<'c>) -> EvalCtx<'c> { + EvalCtx { reads: self.reads, ..*ctx } + } + + /// The buffer the layer cache keeps under `key`, with the files it was made of noted in + /// [`Renderer::reads`]. + pub(crate) fn cached(&self, key: Option) -> Option<(Arc, Arc)> { + let (buf, files) = self.cache?.get(key?)?; + if let Some(log) = self.reads { + log.note_all(&files); + } + Some((buf, files)) + } + + /// Keep `buf`, made of `files`, under `key` (see [`Renderer::recorded`]): the buffer the cache + /// keeps (another worker's, if it kept one first), or `buf` when nothing is kept, with the + /// files that buffer was made of. Another worker's files are noted in [`Renderer::reads`] + /// too: its pixels are the ones this render uses (a file that changed between the two reads + /// then has two identities, and the list is incomplete). + pub(crate) fn keep(&self, key: Option, buf: Arc, files: &Arc) -> (Arc, Arc) { + let (Some(c), Some(k)) = (self.cache_to_keep(), key) else { return (buf, files.clone()) }; + let (kept, kept_files) = c.insert(k, buf, files.clone()); + if !Arc::ptr_eq(&kept_files, files) + && let Some(log) = self.reads + { + log.note_all(&kept_files); + } + (kept, kept_files) + } + + /// Run `make`, which makes a buffer to keep under `key` in the layer cache, on this renderer + /// with its reads noted apart: what `make` returns and the files it read (from the footage + /// source, or the files of cached buffers it used), which go to [`Renderer::reads`] too. A + /// cached buffer then reports the files it was made of whichever render uses it. Without a + /// cache or a key, `make` runs on this renderer as it is (no files: nothing is kept). + pub(crate) fn recorded(&self, key: Option, make: impl FnOnce(&Renderer<'_>) -> T) -> (T, Arc) { + if key.is_none() || self.cache.is_none() { + return (make(self), Arc::default()); + } + // (the generation before any read: a file dropped while `make` runs makes it stale) + let log = self.cache.and_then(LayerCache::drop_generation).map_or_else(ReadLog::new, ReadLog::starting_at); + let out = make(&Renderer { reads: Some(&log), ..*self }); + let files = log.into_files(); + if let Some(own) = self.reads { + own.note_all(&files); + } + (out, Arc::new(files)) + } + /// A layer's Quality switch, limited by the precomp layers above it when /// [`RenderOpts::nested_switches`] is on. pub fn quality(&self, layer: &Layer) -> Quality { @@ -143,6 +207,24 @@ impl<'a> Renderer<'a> { } } + /// What this renderer passes down to the layers it draws beyond their own switches + /// ([`RenderOpts::nested_switches`], the switches inherited from the precomp layers above, + /// motion blur on): part of the cache key of a buffer that reads other layers. + pub(crate) fn nested_look(&self) -> u64 { + let inherited = match self.inherited { + None => 0, + Some((q, mb)) => { + let q = match q { + Quality::Best => 0, + Quality::Draft => 1, + Quality::Wireframe => 2, + }; + 1 + q * 2 + u64::from(mb) + } + }; + 0x6e65_7374_0000_0000 | inherited << 2 | u64::from(self.opts.nested_switches) << 1 | u64::from(self.opts.motion_blur) + } + /// A layer's Motion Blur switch, limited by the precomp layers above it. pub(crate) fn layer_motion_blur(&self, layer: &Layer) -> bool { layer.switches.motion_blur && self.inherited.is_none_or(|(_, mb)| mb) @@ -366,8 +448,19 @@ impl<'a> Renderer<'a> { } } + /// The evaluation context of `comp` at `t`, carrying this renderer's expressions, footage and read log. pub(crate) fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> { - EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage), property_overrides: &[], media_overrides: &[] } + EvalCtx { + project: self.project, + comp_id, + comp, + time: t, + expr: self.expr, + footage: Some(self.footage), + property_overrides: &[], + media_overrides: &[], + reads: self.reads, + } } /// This renderer with another accelerator (or none): the passes of @@ -760,7 +853,7 @@ impl<'a> Renderer<'a> { // Continuously rasterised vector footage: drawn at the on-screen scale. if layer.switches.collapse && is_vector_footage(f) { let k = self.raster_scale(ctx, layer); - if let Some(img) = self.footage.vector_frame(item, f, k) { + if let Some(img) = self.read(|| self.footage.vector_frame(item, f, k)) { let mut buf = Buf { img: (*img).clone(), offset: [0.0; 2], scale: k }; if let Some(c) = self.pipe.media_in(f) { color::convert(&mut buf.img, &c); @@ -776,7 +869,20 @@ impl<'a> Renderer<'a> { let img = self.footage_frame(ctx, layer, item, pf, lt, size)?; Some(self.footage_buf(&img, pf, f.width, f.height)) } - LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))), + // (the font files go to this render's log, which `ctx` may not carry: see + // [`Renderer::with_reads`]) + LayerSource::Text => { + // An Expression Selector evaluates per character: its results at other characters + // than the first are not in the layer's cache key (an amount reading a footage + // item's size by name, `textIndex == 1 ? 0 : footage("ref.png").width`), so the + // buffer is kept in memory only, never on disk (the frame's key covers them). + if cache::per_character_expressions(layer) + && let Some(log) = self.reads + { + log.mark_unkeyed(); + } + Some(self.authored(text::render(&self.with_reads(ctx), layer, self.raster_scale(ctx, layer)))) + } LayerSource::Shape => layer.props.sub("contents").map(|c| self.authored(shapes::render(ctx, layer, c, self.raster_scale(ctx, layer)))), _ => None, } @@ -831,7 +937,7 @@ impl<'a> Renderer<'a> { if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) { let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den); let i = field_rate.frame_at(t).max(0); - let img = cineon_frame(self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?, f); + let img = cineon_frame(self.read(|| self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f)))?, f); let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst; // Upper field = even lines (0, 2, …). let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 }; @@ -844,6 +950,8 @@ impl<'a> Renderer<'a> { if per_pixel { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) } } + /// The footage frame at source time `t` after frame blending: the average of the source frames one comp frame spans, else a blend of the two neighbouring frames, else the plain frame. + /// Log scans are converted first, and blended frames are cached. fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { let mode = frame_blend_mode(ctx, layer); let float = self.pipe.float_curve(f); @@ -855,6 +963,9 @@ impl<'a> Renderer<'a> { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); (0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).hash(&mut h); + // Every file of a sequence: the key must tell two projects apart whose items + // share an id and a first frame (on disk, no project-wide salt does, #3184). + f.sequence.hash(&mut h); (f.alpha as u8).hash(&mut h); if f.alpha == effectcraft_project::AlphaMode::Premultiplied { f.premul_color.map(f32::to_bits).hash(&mut h); @@ -865,24 +976,22 @@ impl<'a> Renderer<'a> { } h.finish() }); - if let (Some(c), Some(k)) = (self.cache, key) - && let Some(b) = c.get(k) - { + if let Some((b, _)) = self.cached(key) { return Some(Arc::new(b.img.clone())); } // Log scans are converted before they blend: the conversion is not linear. - let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float).map(|a| cineon_frame(a, f)); - let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float).map(|b| cineon_frame(b, f)); + let ((a, b), files) = self.recorded(key, |r| { + let frame = |i| r.read(|| r.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float)).map(|a| cineon_frame(a, f)); + (frame(i), frame(i + 1)) + }); if let (Some(a), Some(b)) = (a, b) { let img = blend_frames(mode, &a, &b, w as f32); - if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { - c.insert(k, Arc::new(Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 })); - } + let _kept = self.keep(key, Arc::new(Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }), &files); return Some(Arc::new(img)); } } } - self.footage.frame_with(item, f, t, size, float).map(|img| cineon_frame(img, f)) + self.read(|| self.footage.frame_with(item, f, t, size, float)).map(|img| cineon_frame(img, f)) } /// Source → masks, clamped/quantised to the project depth. @@ -902,52 +1011,41 @@ impl<'a> Renderer<'a> { /// Fully processed layer buffer (source → masks → effects → layer styles, flattened). /// Served from the layer cache when the layer's content key matches. pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { - self.layer_buf_keyed(ctx, layer).map(|(b, _)| b) + self.layer_buf_keyed(ctx, layer).map(|(b, _, _)| b) } /// [`Self::layer_buf`] in the blending space (linear with Blend Colors Using 1.0 Gamma). pub fn blend_layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { - let (b, k) = self.layer_buf_keyed(ctx, layer)?; - Some(self.to_blend(b, k)) + let (b, k, files) = self.layer_buf_keyed(ctx, layer)?; + Some(self.to_blend(b, k, &files)) } - /// A buffer converted to the blending space (cached under a key derived from `key`). - pub(crate) fn to_blend(&self, b: Arc, key: Option) -> Arc { + /// A buffer converted to the blending space (cached under a key derived from `key`, with + /// `files`, the source files `b` was made of). + pub(crate) fn to_blend(&self, b: Arc, key: Option, files: &Arc) -> Arc { let Some(c) = self.pipe.to_blend() else { return b }; let key = key.map(|k| cache::derive(k, 0x1ea7)); - if let (Some(cache), Some(k)) = (self.cache, key) - && let Some(x) = cache.get(k) - { + if let Some((x, _)) = self.cached(key) { return x; } let mut nb = (*b).clone(); color::convert(&mut nb.img, &c); - let nb = Arc::new(nb); - // The buffer the cache keeps (another worker's, if it kept one first). - match (self.cache_to_keep(), key) { - (Some(cache), Some(k)) => cache.insert(k, nb), - _ => nb, - } + self.keep(key, Arc::new(nb), files).0 } - fn layer_buf_keyed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Arc, Option)> { + /// The flattened layer buffer, its cache key and the source files it was made of. + fn layer_buf_keyed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Arc, Option, Arc)> { let st = self.styled(ctx, layer, None)?; if st.passes.is_empty() { - return Some((st.body, st.key)); + return Some((st.body, st.key, st.files)); } let key = st.key.map(|k| cache::derive(k, 0xf1a7)); - if let (Some(c), Some(k)) = (self.cache, key) - && let Some(b) = c.get(k) - { - return Some((b, key)); + if let Some((b, files)) = self.cached(key) { + return Some((b, key, files)); } let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten()); - // The buffer the cache keeps (another worker's, if it kept one first). - let flat = match (self.cache_to_keep(), key) { - (Some(c), Some(k)) => c.insert(k, flat), - _ => flat, - }; - Some((flat, key)) + let (flat, files) = self.keep(key, flat, &st.files); + Some((flat, key, files)) } /// A layer's source pixels at the context time, before masks, effects and the transform @@ -983,28 +1081,32 @@ impl<'a> Renderer<'a> { /// Layer pixels after source → masks → effects (no layer styles). pub fn content_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { - self.content_buf_timed(ctx, layer, None).map(|(b, _)| b) + self.content_buf_timed(ctx, layer, None).map(|(b, _, _)| b) } - pub(crate) fn content_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<(Arc, Option)> { + /// [`Self::content_buf`], its cache key and the source files it was made of. + pub(crate) fn content_buf_timed( + &self, + ctx: &EvalCtx, + layer: &Layer, + mut timing: Option<&mut LayerTiming>, + ) -> Option<(Arc, Option, Arc)> { let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer))); let key = self.content_key(ctx, layer, key); - if let (Some(c), Some(k)) = (self.cache, key) - && let Some(b) = c.get(k) - { + if let Some((b, files)) = self.cached(key) { if let Some(t) = timing.as_deref_mut() { t.cached = true; } - return Some((b, key)); + return Some((b, key, files)); } - let buf = self.masked_source(ctx, layer)?; - let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects))); - // The buffer the cache keeps (another worker's, if it kept one first). - let buf = match (self.cache_to_keep(), key) { - (Some(c), Some(k)) => c.insert(k, buf), - _ => buf, - }; - Some((buf, key)) + let (buf, files) = self.recorded(key, |r| { + // (what expressions read while it is made, at shutter times too, goes to its files) + let ctx = &r.with_reads(ctx); + let buf = r.masked_source(ctx, layer)?; + Some(r.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects))) + }); + let (buf, files) = self.keep(key, Arc::new(buf?), &files); + Some((buf, key, files)) } /// A layer's input at the context time: source → masks → its first `effects` effects @@ -1014,23 +1116,19 @@ impl<'a> Renderer<'a> { pub fn layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option> { let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer), effects)); let key = self.content_key(ctx, layer, key); - if let (Some(c), Some(k)) = (self.cache, key) - && let Some(b) = c.get(k) - { + if let Some((b, _)) = self.cached(key) { return Some(b); } - let mut buf = self.masked_source(ctx, layer)?; - let origin = ctx.effect_bounds(layer).1; - buf.rebase(origin); - if effects > 0 { - buf = self.effect_space_stack(ctx, layer, buf, EffectStack { adjustment: false, limit: effects, origin }, None); - } - let buf = Arc::new(buf); - // The buffer the cache keeps (another worker's, if it kept one first). - let buf = match (self.cache_to_keep(), key) { - (Some(c), Some(k)) => c.insert(k, buf), - _ => buf, - }; - Some(buf) + let (buf, files) = self.recorded(key, |r| { + let ctx = &r.with_reads(ctx); + let mut buf = r.masked_source(ctx, layer)?; + let origin = ctx.effect_bounds(layer).1; + buf.rebase(origin); + if effects > 0 { + buf = r.effect_space_stack(ctx, layer, buf, EffectStack { adjustment: false, limit: effects, origin }, None); + } + Some(buf) + }); + Some(self.keep(key, Arc::new(buf?), &files).0) } } diff --git a/crates/render/src/reads.rs b/crates/render/src/reads.rs new file mode 100644 index 000000000..ed97e50fd --- /dev/null +++ b/crates/render/src/reads.rs @@ -0,0 +1,348 @@ +//! The source files a render read: which footage files its pixels came from, each with its +//! identity (size and modification time) as it was when it was read. +//! +//! A [`FootageSource`](crate::FootageSource) that knows which files it opens notes them while +//! [`FootageSource::recording`](crate::FootageSource::recording) runs; the renderer collects them +//! in a [`ReadLog`] ([`Renderer::reads`](crate::Renderer::reads)). Buffers in the layer cache keep +//! the files they were made of, so a frame served from memory reports the same files as the +//! render that made it, without touching the disk. +//! +//! The list is what a cache entry on disk has to check before it is used: a handful of files per +//! frame, never every file of every footage item. + +use std::sync::{Arc, Mutex, PoisonError}; + +/// The identity of a file as a render read it (frame and layer entries on disk store it, see +/// [`EntryFiles`](crate::disk_cache::EntryFiles)). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum FileStamp { + /// A file on disk: its size in bytes and its modification time (nanoseconds since the Unix + /// epoch, negative before it; `None` only where the file system has none). + Native { len: u64, modified: Option }, + /// Bytes given from memory instead of the file system (the browser's file table): their + /// identity is the size and content hash a + /// [`VirtualFileId`](crate::disk_cache::VirtualFileId) gives for the path. + Virtual, + /// The file could not be read (missing, not a regular file, no permission). What was made + /// without it changes once it can be read again. + Missing, +} + +impl FileStamp { + /// The identity of a file from metadata the caller already has (of an open file). + pub fn of_metadata(md: &std::fs::Metadata) -> FileStamp { + FileStamp::Native { len: md.len(), modified: md.modified().ok().map(epoch_nanos) } + } + + /// The identity of the file at `path` now: one `metadata` call. `None` when the file system + /// can't describe it (missing, or a path served from memory). + pub fn native(path: &str) -> Option { + std::fs::metadata(path).ok().map(|md| FileStamp::of_metadata(&md)) + } +} + +/// `t` in nanoseconds since the Unix epoch, negative before it: every time a file system can +/// store fits (`i128` nanoseconds span far more than any `SystemTime`), saturating otherwise. +pub fn epoch_nanos(t: std::time::SystemTime) -> i128 { + let nanos = |d: std::time::Duration| i128::try_from(d.as_nanos()).unwrap_or(i128::MAX); + match t.duration_since(std::time::UNIX_EPOCH) { + Ok(after) => nanos(after), + Err(before) => nanos(before.duration()).saturating_neg(), + } +} + +/// One file a render read. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ReadFile { + pub path: Arc, + pub stamp: FileStamp, +} + +impl ReadFile { + /// A file read with the given path and stamp. + pub fn new(path: impl Into>, stamp: FileStamp) -> ReadFile { + ReadFile { path: path.into(), stamp } + } +} + +/// 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; + +/// The files a render (or one cached buffer) read, each once, sorted by path. +/// +/// Incomplete when it can't say for sure: a footage source that doesn't tell what it reads, a +/// buffer from a store that doesn't keep its files, more than [`MAX_READ_FILES`] files, or one +/// file read with two identities (it changed during the render). A cache entry whose list is +/// incomplete can't be checked against the files, so it must not outlive the session. +/// +/// It also carries where its reads come from, which equality and hashing ignore: the drop +/// generation the earliest of them started at ([`ReadFiles::since`]), and whether a layer cache +/// key can't capture what it was made of ([`ReadFiles::is_unkeyed`]). +#[derive(Clone, Debug, Default)] +pub struct ReadFiles { + files: Vec, + incomplete: bool, + /// The disk cache's drop generation ([`DiskCache::drop_generation`]) when the earliest read + /// behind the list started; `None` when nothing says (a list built by hand). + /// + /// [`DiskCache::drop_generation`]: crate::disk_cache::DiskCache::drop_generation + since: Option, + /// Made of values a layer's cache key doesn't capture (a text Expression Selector evaluates + /// per character): such a buffer is not stored on disk. + unkeyed: bool, +} + +impl PartialEq for ReadFiles { + /// The same files with the same identities (where the reads come from doesn't count). + fn eq(&self, other: &Self) -> bool { + self.files == other.files && self.incomplete == other.incomplete + } +} + +impl Eq for ReadFiles {} + +impl std::hash::Hash for ReadFiles { + /// Hashes what [`PartialEq`] compares. + fn hash(&self, state: &mut H) { + self.files.hash(state); + self.incomplete.hash(state); + } +} + +impl ReadFiles { + /// Note a file read (once per path). + pub fn add(&mut self, file: ReadFile) { + match self.files.binary_search_by(|f| f.path.cmp(&file.path)) { + Ok(i) => { + if self.files.get(i).is_some_and(|f| f.stamp != file.stamp) { + self.incomplete = true; + } + } + Err(i) => { + if self.files.len() >= MAX_READ_FILES { + self.incomplete = true; + } else { + self.files.insert(i, file); + } + } + } + } + + /// Note every file of `other` (whether it is incomplete or unkeyed, and its generation). + pub fn extend(&mut self, other: &ReadFiles) { + self.incomplete |= other.incomplete; + self.unkeyed |= other.unkeyed; + if let Some(g) = other.since { + self.note_since(g); + } + for f in &other.files { + self.add(f.clone()); + } + } + + /// Some files read may be missing from the list (see [`ReadFiles`]). + pub fn mark_incomplete(&mut self) { + self.incomplete = true; + } + + /// Whether the list names every file read. + pub fn is_complete(&self) -> bool { + !self.incomplete + } + + /// The drop generation the earliest read behind the list started at (`None`: unknown). A + /// buffer made of a file dropped after it holds that file as it was before the drop. + pub fn since(&self) -> Option { + self.since + } + + /// A read behind the list started at drop generation `generation` (the earliest one counts). + pub fn note_since(&mut self, generation: u64) { + self.since = Some(self.since.map_or(generation, |s| s.min(generation))); + } + + /// Made of values a layer cache key doesn't capture (see [`ReadFiles::is_unkeyed`]). + pub fn mark_unkeyed(&mut self) { + self.unkeyed = true; + } + + /// Made of values a layer cache key doesn't capture: kept in memory, never stored on disk as + /// a layer buffer. (A frame's key captures them: frame entries are not affected.) + pub fn is_unkeyed(&self) -> bool { + self.unkeyed + } + + /// The files, sorted by path. + pub fn files(&self) -> &[ReadFile] { + &self.files + } + + /// The identity `path` was read with. + pub fn get(&self, path: &str) -> Option { + let i = self.files.binary_search_by(|f| (*f.path).cmp(path)).ok()?; + self.files.get(i).map(|f| f.stamp) + } + + /// The number of files in the list. + pub fn len(&self) -> usize { + self.files.len() + } + + /// Whether the list holds no files. + pub fn is_empty(&self) -> bool { + self.files.is_empty() + } +} + +impl FromIterator for ReadFiles { + /// Collects files into a list, adding each in turn. + fn from_iter>(iter: I) -> Self { + let mut out = ReadFiles::default(); + for f in iter { + out.add(f); + } + out + } +} + +/// Collects the files a render reads, from any thread (the layers of a frame render in +/// parallel). +#[derive(Debug, Default)] +pub struct ReadLog { + files: Mutex, +} + +impl ReadLog { + /// An empty read log. + pub fn new() -> ReadLog { + ReadLog::default() + } + + /// An empty read log for reads that start at drop generation `generation` (see + /// [`ReadFiles::since`]). + pub fn starting_at(generation: u64) -> ReadLog { + let mut files = ReadFiles::default(); + files.note_since(generation); + ReadLog { files: Mutex::new(files) } + } + + /// Locks the file list, recovering from a poisoned lock. + fn lock(&self) -> std::sync::MutexGuard<'_, ReadFiles> { + self.files.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Note a file read. + pub fn note(&self, file: ReadFile) { + self.lock().add(file); + } + + /// Note every file of `files` (and whether it is incomplete). + pub fn note_all(&self, files: &ReadFiles) { + if files.is_empty() && files.is_complete() && files.since.is_none() && !files.unkeyed { + return; + } + self.lock().extend(files); + } + + /// Some files read can't be listed (see [`ReadFiles`]). + pub fn mark_incomplete(&self) { + self.lock().mark_incomplete(); + } + + /// What is read is made of values a layer cache key doesn't capture + /// ([`ReadFiles::is_unkeyed`]). + pub fn mark_unkeyed(&self) { + self.lock().mark_unkeyed(); + } + + /// The files noted so far. + pub fn files(&self) -> ReadFiles { + self.lock().clone() + } + + /// The files noted. + pub fn into_files(self) -> ReadFiles { + self.files.into_inner().unwrap_or_else(PoisonError::into_inner) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A native file with the given length for the tests. + fn file(path: &str, len: u64) -> ReadFile { + ReadFile::new(path, FileStamp::Native { len, modified: Some(7) }) + } + + #[test] + fn files_are_listed_once_sorted_by_path() { + let files: ReadFiles = [file("/b", 1), file("/a", 2), file("/b", 1)].into_iter().collect(); + assert_eq!(files.files().iter().map(|f| &*f.path).collect::>(), ["/a", "/b"]); + assert!(files.is_complete()); + assert_eq!(files.get("/a"), Some(FileStamp::Native { len: 2, modified: Some(7) })); + assert_eq!(files.get("/c"), None); + } + + /// A file read twice with different identities changed during the render: the list can't + /// vouch for what was made of it. + #[test] + fn a_file_read_with_two_identities_makes_the_list_incomplete() { + let files: ReadFiles = [file("/a", 1), file("/a", 2)].into_iter().collect(); + assert_eq!(files.len(), 1); + assert!(!files.is_complete()); + } + + /// Modification times keep their full value: before 1970 (not "no time") and past 2554 + /// (where `u64` nanoseconds end), so two such files never look alike. + #[test] + fn modification_times_are_kept_whole() { + use std::time::{Duration, UNIX_EPOCH}; + let day = Duration::from_secs(86_400); + assert_eq!(epoch_nanos(UNIX_EPOCH), 0); + assert_eq!(epoch_nanos(UNIX_EPOCH - day), -86_400_000_000_000); + let far = 600 * 365 * day; + if let Some(t) = UNIX_EPOCH.checked_add(far) { + assert_eq!(epoch_nanos(t), i128::try_from(far.as_nanos()).unwrap()); + } + let dir = std::env::temp_dir().join(format!("effectcraft-stamp-1969-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (a, b) = (dir.join("a.bin"), dir.join("b.bin")); + let mut stamps = vec![]; + for (p, back) in [(&a, day), (&b, 2 * day)] { + std::fs::write(p, b"abc").unwrap(); + let f = std::fs::File::options().write(true).open(p).unwrap(); + if f.set_modified(UNIX_EPOCH - back).is_err() { + // (a file system that can't store times before 1970) + let _ = std::fs::remove_dir_all(&dir); + return; + } + drop(f); + stamps.push(FileStamp::native(&p.to_string_lossy()).unwrap()); + } + assert!(matches!(stamps[0], FileStamp::Native { modified: Some(t), .. } if t < 0), "{:?}", stamps[0]); + assert_ne!(stamps[0], stamps[1], "two files from before 1970 with the same size"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_list_is_bounded() { + let files: ReadFiles = (0..MAX_READ_FILES + 3).map(|i| file(&format!("/f{i:06}"), 1)).collect(); + assert_eq!(files.len(), MAX_READ_FILES); + assert!(!files.is_complete(), "files past the bound make the list incomplete, never silently short"); + } + + #[test] + fn incompleteness_carries_over() { + let mut part = ReadFiles::default(); + part.mark_incomplete(); + let log = ReadLog::new(); + log.note(file("/a", 1)); + log.note_all(&part); + let all = log.into_files(); + assert_eq!(all.len(), 1); + assert!(!all.is_complete()); + } +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index c77fc2d94..80b1aa12d 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -482,6 +482,145 @@ impl crate::eval::ExprHost for ControllerRate { } } +/// An expression that reads a file (as `sampleImage` does, into [`EvalCtx::reads`]) and keeps +/// the value. +/// +/// [`EvalCtx::reads`]: crate::eval::EvalCtx::reads +struct ReadsAFile; + +impl crate::eval::ExprHost for ReadsAFile { + fn eval(&self, ctx: &crate::eval::EvalCtx, _: &effectcraft_project::Layer, _: &effectcraft_project::Property, value: &Value) -> Result { + if let Some(log) = ctx.reads { + log.note(crate::reads::ReadFile::new("/sampled.png", crate::reads::FileStamp::Native { len: 3, modified: Some(1) })); + } + Ok(value.clone()) + } +} + +/// What expressions read while a cached buffer is made (a mask expression, which mask motion +/// blur also runs at shutter times the cache key doesn't see) goes to that buffer's files, not +/// only to the frame's (#3184): a later render served the buffer lists the file too. Content, +/// a Time effect's input and an adjustment layer's footprint. +#[test] +fn what_expressions_read_goes_to_the_files_of_the_buffer_they_make() { + use crate::reads::ReadLog; + use crate::{NoFootage, RenderOpts, Renderer}; + let (mut p, cid, comp) = setup(); + for adjustment in [false, true] { + let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100); + l.switches.adjustment = adjustment; + let mut next = p.next_id; + let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([50.0, 50.0], 40.0, 40.0), MaskMode::Add, [255, 255, 0]); + p.next_id = next; + l.props.sub_mut("masks").unwrap().children.push(m.into()); + l.props.prop_mut("masks/#1/path").unwrap().expr = Some(effectcraft_project::Expression { text: "sampled".into(), enabled: true }); + p.comp_mut(cid).unwrap().layers.push(l); + } + let cache = crate::LayerCache::default(); + let c = p.comp(cid).unwrap(); + let (content, adjustment) = (&c.layers[0], &c.layers[1]); + // `make`, rendered once with the expressions (the files go to `log`) and then served from + // the cache to a render without them: the files that render lists. + let files_served = |what: &str, make: &dyn Fn(&Renderer, &crate::eval::EvalCtx) -> bool| { + let log = ReadLog::new(); + let mut r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + r.expr = Some(&ReadsAFile); + r.cache = Some(&cache); + r.reads = Some(&log); + assert!(make(&r, &r.ctx(cid, c, Tick::ZERO)), "{what}"); + assert!(log.files().get("/sampled.png").is_some(), "{what}: the frame lists the file"); + let hits = cache.stats().hits; + let served = ReadLog::new(); + r.expr = None; + r.reads = Some(&served); + assert!(make(&r, &r.ctx(cid, c, Tick::ZERO)), "{what}"); + assert!(cache.stats().hits > hits, "{what}: served from the cache"); + served.into_files() + }; + let files = files_served("content", &|r, ctx| r.content_buf(ctx, content).is_some()); + assert!(files.get("/sampled.png").is_some(), "content: {files:?}"); + let files = files_served("input", &|r, ctx| r.layer_input(ctx, content, 0).is_some()); + assert!(files.get("/sampled.png").is_some(), "input: {files:?}"); + let files = files_served("footprint", &|r, ctx| r.adjustment_footprint(ctx, adjustment).is_some()); + assert!(files.get("/sampled.png").is_some(), "footprint: {files:?}"); +} + +/// A text Expression Selector whose Amount is 0 for the first character and the width of the +/// footage item named `ref.png` for the others (`textIndex == 1 ? 0 : footage("ref.png").width`). +struct WidthOfRef; + +impl crate::eval::ExprHost for WidthOfRef { + fn eval(&self, _: &crate::eval::EvalCtx, _: &effectcraft_project::Layer, _: &effectcraft_project::Property, value: &Value) -> Result { + Ok(value.clone()) + } + fn eval_text_selector( + &self, + ctx: &crate::eval::EvalCtx, + _: &effectcraft_project::Layer, + _: &effectcraft_project::Property, + index: usize, + _: usize, + _: [f64; 3], + ) -> Result<[f64; 3], String> { + let width = ctx.project.items.values().find(|i| i.name == "ref.png").and_then(|i| i.dimensions()).map_or(0.0, |d| d.0 as f64); + Ok([if index == 1 { 0.0 } else { width }; 3]) + } +} + +/// A text layer whose Expression Selector reads a footage item's size for every character but +/// the first: the layer's key holds the first character's amount only, so its buffer is not +/// stored on disk. Reopened over the same disk cache after the footage's width changed (16 to +/// 64), the frame matches one rendered without a cache. +#[test] +fn a_text_expression_selector_is_not_served_from_the_disk_cache() { + use crate::disk_cache::{DiskCache, Kind}; + use crate::{LayerCache, NoFootage, RenderOpts, Renderer}; + let project = |width: u32| { + let (mut p, cid, comp) = setup(); + let f = effectcraft_project::Footage { has_video: true, width, height: 16, ..Default::default() }; + p.add_item("ref.png", Label::None, None, ItemKind::Footage(f)); + let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HH".into(), size: 60.0, ..Default::default() })); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let mut props = build::text_anim_props(&mut ids, "opacity", false); + for pr in &mut props { + pr.value = Value::Scalar(0.0); + } + let mut animator = build::text_animator(&mut ids, "Animator 1", props); + let sel = build::expression_selector(&mut ids, "Expression Selector 1"); + p.next_id = next; + animator.sub_mut("selectors").unwrap().children.push(sel.into()); + l.props.group_mut("text/animators").unwrap().children.push(animator.into()); + p.comp_mut(cid).unwrap().layers.push(l); + (p, cid) + }; + let render = |p: &Project, cid: ItemId, cache: Option<&LayerCache>| { + let mut r = Renderer::new(p, &NoFootage, RenderOpts::default()); + r.expr = Some(&WidthOfRef); + r.cache = cache; + r.comp_frame(cid, Tick::ZERO) + }; + let dir = tempfile::tempdir().unwrap(); + let dc = DiskCache::open(dir.path(), 1 << 30).unwrap(); + dc.set_min_layer_ms(0); + // First session: the 16 px wide footage. + let (p16, cid) = project(16); + let cache = LayerCache::default(); + cache.set_disk(Some(dc.clone())); + let first = render(&p16, cid, Some(&cache)); + dc.flush(); + assert!(first.data.iter().any(|px| px[3] > 0.5), "the text renders"); + // Reopened with a fresh memory cache, the footage now 64 px wide. + let (p64, cid) = project(64); + let uncached = render(&p64, cid, None); + assert_ne!(uncached.data, first.data, "the second character's opacity follows the width"); + let reopened = LayerCache::default(); + reopened.set_disk(Some(dc.clone())); + assert_eq!(render(&p64, cid, Some(&reopened)).data, uncached.data, "not the buffer from before the change"); + assert!(dc.keys(Kind::Layer).is_empty(), "the text layer's buffer is not on disk"); +} + /// A Birth Rate expression reading another layer: changing that layer changes the history but /// not the current rate (0) nor this layer's properties, which is all the layer cache key sees. /// CC Particle World layers with expressions therefore aren't layer cached. @@ -820,6 +959,41 @@ fn effects_read_layer_params() { assert!(px[2] > 0.99 && px[0] < 0.01, "{px:?}"); } +/// A cached layer whose effect reads a precomp layer: how the precomp's layers render depends on +/// Switches Affect Nested Comps (the precomp layer's Draft quality passes down) and on the +/// switches the reading layer's own comp inherits, so the key must tell them apart (a buffer +/// made under the other setting, from memory or disk, is stale). +#[test] +fn a_cached_layer_reading_a_precomp_is_keyed_by_the_nested_switches() { + let (mut p, cid, comp) = setup(); + let mut inner = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + // A rotated square off the pixel grid: Draft (nearest) and Best (bilinear) sampling differ. + let mut dot = solid(&mut p, &inner, [1.0, 1.0, 1.0], 40, 40); + dot.props.prop_mut("transform/position").unwrap().value = Value::Vec3([100.3, 50.6, 0.0]); + dot.props.prop_mut("transform/rotation").unwrap().value = Value::Scalar(30.0); + inner.layers.push(dot); + let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut pre = build::layer(&mut p, &comp, "Inner", LayerSource::Comp { item: iid }, (200, 100), None); + pre.switches.video = false; + pre.switches.quality = effectcraft_project::Quality::Draft; + let mut top = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); + add_effect_200(&mut p, &mut top, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(pre.id.0)))]); + let c = p.comp_mut(cid).unwrap(); + c.layers.push(top); + c.layers.push(pre); + let frame = |nested_switches: bool, cache: Option<&crate::LayerCache>| { + let mut r = crate::Renderer::new(&p, &crate::NoFootage, crate::RenderOpts { nested_switches, ..Default::default() }); + r.cache = cache; + r.comp_frame(cid, Tick::ZERO).data + }; + let (on, off) = (frame(true, None), frame(false, None)); + assert!(on != off, "the precomp layer's Draft quality passes down"); + let cache = crate::LayerCache::default(); + assert!(frame(true, Some(&cache)) == on); + assert!(frame(false, Some(&cache)) == off, "not the buffer made with switches affecting nested comps"); + assert!(frame(true, Some(&cache)) == on, "and back"); +} + /// Records whether the GPU compositor was asked to draw a frame. struct ProbeAccel(std::sync::atomic::AtomicBool); impl crate::Accelerator for ProbeAccel { diff --git a/crates/render/src/tests/collapse.rs b/crates/render/src/tests/collapse.rs index 30d2163c1..4104e0b38 100644 --- a/crates/render/src/tests/collapse.rs +++ b/crates/render/src/tests/collapse.rs @@ -161,8 +161,17 @@ fn referenced_layer_overrides_change_the_owner_cache_key() { let opacity = comp.layer(aid).unwrap().props.prop("transform/opacity").unwrap().uid; let key = |value: f64| { let overrides = [crate::PropertyOverride { layer: aid, prop: opacity, value: Value::Scalar(value), font: false }]; - let ctx = - EvalCtx { project: &p, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &overrides, media_overrides: &[] }; + let ctx = EvalCtx { + project: &p, + comp_id: cid, + comp, + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &overrides, + media_overrides: &[], + reads: None, + }; crate::cache::layer_key(&ctx, b, 1.0, false, false) }; let (opaque, half) = (key(100.0), key(50.0)); diff --git a/crates/render/src/tests/mask_blur.rs b/crates/render/src/tests/mask_blur.rs index 07bc1e95f..5f3ff4e2a 100644 --- a/crates/render/src/tests/mask_blur.rs +++ b/crates/render/src/tests/mask_blur.rs @@ -75,3 +75,32 @@ fn mask_motion_blur_follows_the_render_and_the_comp() { let (p, cid) = project(false); assert!(alpha_at_25(&p, cid, true) < 0.01, "the comp's switch off"); } + +/// Whether mask motion blur may run at all (the render options, Switches Affect Nested Comps with +/// a precomp layer's switch off) is part of the layer cache key, also for a layer whose own +/// switch is off: a buffer made with the gate open must not serve a render with it closed. +#[test] +fn the_mask_motion_blur_gate_keys_the_layer_cache() { + let (mut p, inner) = project(true); + let mut outer = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + outer.enable_motion_blur = true; + let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: inner }, (100, 100), None); + pre.switches.motion_blur = false; + outer.layers.push(pre); + let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.into())); + let half = Tick::from_seconds_f64(0.5 / 30.0); + let cache = crate::LayerCache::default(); + let render = |cid: ItemId, opts: RenderOpts, cached: bool| { + let mut r = Renderer::new(&p, &NoFootage, opts); + r.cache = cached.then_some(&cache); + r.comp_frame(cid, half).data + }; + let on = RenderOpts { motion_blur: true, nested_switches: true, ..Default::default() }; + let off = RenderOpts { nested_switches: false, ..on }; + assert!(render(oid, on, false) != render(oid, off, false), "the precomp layer's switch closes the gate"); + assert!(render(oid, off, true) == render(oid, off, false)); + assert!(render(oid, on, true) == render(oid, on, false), "not the buffer blurred with the gate open"); + let still = RenderOpts { motion_blur: false, ..on }; + assert!(render(inner, on, true) == render(inner, on, false)); + assert!(render(inner, still, true) == render(inner, still, false), "Render Settings ▸ Motion Blur off"); +} diff --git a/crates/render/src/tests/styles.rs b/crates/render/src/tests/styles.rs index 2450fcf64..a9de6fb52 100644 --- a/crates/render/src/tests/styles.rs +++ b/crates/render/src/tests/styles.rs @@ -507,3 +507,34 @@ fn workers_styling_a_layer_at_once_share_its_buffers() { assert!(st.passes.iter().zip(&first.passes).all(|(a, b)| std::sync::Arc::ptr_eq(&a.0, &b.0)), "one set of passes"); } } + +/// A styled layer served from the cache lists the files of the body and passes it was served, +/// not only its content's (#3184): one made of other files (another worker's, or from disk, +/// whose list can't vouch for them) passes that on to the buffers made of it (flattened, in the +/// blending space), which are then not kept on disk as complete. +#[test] +fn a_cached_styled_layer_lists_the_files_of_its_cached_buffers() { + let (p, cid) = scene([1.0; 3], None, |p, c, l| add(p, c, l, "dropShadow")); + let layer = &p.comp(cid).unwrap().layers[0]; + let styled = |cache: &crate::LayerCache| { + let mut r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + r.cache = Some(cache); + let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); + r.styled_layer(&ctx, layer).unwrap() + }; + let first = styled(&crate::LayerCache::default()); + assert!(first.files.is_complete() && !first.passes.is_empty()); + let key = first.key.unwrap(); + // Body and passes kept with a list that can't vouch for them; the content renders again. + let cache = crate::LayerCache::default(); + let mut unsure = crate::reads::ReadFiles::default(); + unsure.mark_incomplete(); + let unsure = std::sync::Arc::new(unsure); + cache.insert(key, first.body.clone(), unsure.clone()); + for (i, (b, _)) in first.passes.iter().enumerate() { + cache.insert(crate::cache::derive(key, i as u64 + 1), b.clone(), unsure.clone()); + } + let hit = styled(&cache); + assert!(std::sync::Arc::ptr_eq(&hit.body, &first.body), "the cached body"); + assert!(!hit.files.is_complete(), "{:?}", hit.files); +} diff --git a/crates/render/src/tests/time.rs b/crates/render/src/tests/time.rs index e9599f70b..715c37ef0 100644 --- a/crates/render/src/tests/time.rs +++ b/crates/render/src/tests/time.rs @@ -143,6 +143,7 @@ fn input_keys_ignore_later_effects() { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let l = &comp.layers[0]; (crate::cache::input_key(&ctx, l, 1.0, false, false, 1), crate::cache::input_key(&ctx, l, 1.0, false, false, 2)) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 683da82ee..ed369afa5 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -243,6 +243,13 @@ pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: & } "expressionSelector" => { let Some(prop) = sel.get("amount") else { continue }; + // Evaluated per character (`textIndex`): the layer's cache key holds the amount + // for the first character only, so the buffer must not outlive the session. + if prop.has_expression() + && let Some(log) = ctx.reads + { + log.mark_unkeyed(); + } let stat = ctx.v3(layer, sel, "amount", [100.0; 3]); Box::new(move |i, n, prev| { let pv = prev.map(|x| x * 100.0); @@ -638,11 +645,73 @@ fn align_index(j: Justify) -> u8 { } } +/// Note the font files `lay` is drawn with in [`EvalCtx::reads`] (#3184), each with its identity +/// as its bytes were read, so a cache entry of the text is checked against them. What the +/// files can't vouch for makes the list incomplete: a family that is not installed (installing +/// it changes the text), a character drawn with a fallback face or with none (another installed +/// font could take it), a font from memory. +fn note_fonts(ctx: &EvalCtx, lay: &TextLayout) { + note_layout_fonts(ctx, &lay.styles, &lay.layout); +} + +/// A character put in by Character Offset or Character Value (`ch`, in style `st`), outside a +/// relayout: its outline and advance, with the font it is drawn with noted as [`note_fonts`] does +/// (the run's face may not have it: a fallback face then draws it). +fn substituted_glyph(ctx: &EvalCtx, st: &effectcraft_keyframe::CharStyle, ch: char) -> (BezPath, f64) { + if ctx.reads.is_some() { + // (the layout `char_glyph_style` makes, served from the layout cache there) + let lay = effectcraft_text::layout_text(&ch.to_string(), &effectcraft_text::text_style(st), &effectcraft_text::ParagraphStyle::default()); + note_layout_fonts(ctx, std::slice::from_ref(st), &lay); + } + char_glyph_style(st, ch) +} + +/// [`note_fonts`] for `layout`, whose glyphs' runs index `styles`. +fn note_layout_fonts(ctx: &EvalCtx, styles: &[effectcraft_keyframe::CharStyle], layout: &effectcraft_text::Layout) { + use crate::reads::{FileStamp, ReadFile}; + use effectcraft_text::fonts; + let Some(log) = ctx.reads else { return }; + let resolved: Vec = styles.iter().map(|s| fonts::resolve(&s.font, &s.style)).collect(); + if resolved.iter().any(|r| r.missing) { + log.mark_incomplete(); + } + // Each run's face, glyphs or not: an empty paragraph's face sets its line's metrics (in a + // box, the first baseline that places every later line). + let mut faces: Vec = Vec::new(); + for r in &resolved { + if !faces.contains(&r.face) { + faces.push(r.face); + } + } + for g in &layout.glyphs { + if g.id == 0 || resolved.get(g.run).is_none_or(|r| r.face != g.face) { + log.mark_incomplete(); + } + if !faces.contains(&g.face) { + faces.push(g.face); + } + } + for id in faces { + let face = fonts::face(id); + match &face.info.data { + fonts::FaceData::Static(_) => {} + fonts::FaceData::Shared(_) => log.mark_incomplete(), + fonts::FaceData::File(_) => { + if let Some((path, meta)) = face.file_read() { + let stamp = meta.map_or(FileStamp::Missing, FileStamp::of_metadata); + log.note(ReadFile::new(path.to_string_lossy().as_ref(), stamp)); + } + } + } + } +} + /// Lay out and animate a text layer. pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { let doc = source_text(ctx, layer)?; let text = layer.props.sub("text")?; let lay = layout_doc(&doc); + note_fonts(ctx, &lay); let xfs = char_transforms(ctx, layer, text, &lay); let three = per_char_3d(ctx, layer); let more = text.sub("moreOptions"); @@ -665,6 +734,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { // Same character count: the style runs still line up. let d2 = TextDoc { text: chars.into_iter().collect(), ..doc.clone() }; let l2 = layout_doc(&d2); + note_fonts(ctx, &l2); let by_char: std::collections::HashMap = lay.glyphs.iter().zip(&xfs).map(|(g, x)| (g.char_index, *x)).collect(); let x2: Vec = l2.glyphs.iter().map(|g| by_char.get(&g.char_index).copied().unwrap_or_default()).collect(); subs = vec![None; l2.glyphs.len()]; @@ -789,7 +859,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { // Outline (substituted characters aligned in the original slot). let (outline, adv) = match subs[gi] { Some(c) => { - let (p, a) = char_glyph_style(st, c); + let (p, a) = substituted_glyph(ctx, st, c); let dx = match x.char_align { 0 => 0.0, 2 => g.advance - a, diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 0280115a5..9b52d3098 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -715,3 +715,90 @@ fn range_bar_drag_values() { assert_eq!(range_value_of(3.6 / 7.0, 0.0, true, 7), 4.0); assert_eq!(range_value_of(3.6 / 7.0, 2.0 / 7.0, true, 7), 2.0, "less a 2-character Offset"); } + +/// Text names the font files it is drawn with (#3184), with their identity as they were read, so +/// a cache entry of it is checked against them; what the files can't vouch for (a family that +/// isn't installed) leaves the list incomplete. +#[test] +fn text_notes_the_font_files_it_draws_with() { + use crate::reads::{FileStamp, ReadLog}; + use effectcraft_text::fonts::{self, FaceData, FaceInfo, FontSource}; + struct OneFile(std::path::PathBuf); + impl FontSource for OneFile { + fn faces(&self) -> Vec { + vec![FaceInfo { + family: "Reads Test Sans".into(), + style: "Regular".into(), + weight: 400, + italic: false, + index: 0, + data: FaceData::File(self.0.clone()), + origin: "system", + native_family: None, + full_name: "Reads Test Sans Regular".into(), + postscript: None, + variable: false, + instances: vec![], + }] + } + } + // (a fresh directory, removed when the test ends, panicking or not) + let dir = tempfile::tempdir().unwrap(); + let font = dir.path().join("reads-test-sans.ttf"); + std::fs::write(&font, fonts::INTER_REGULAR).unwrap(); + fonts::add_source(&OneFile(font.clone())); + let stamp = FileStamp::native(&font.to_string_lossy()).unwrap(); + // (`value`: a Character Value animator puts that character in for every one) + let files_with = |family: &str, value: Option| { + let (mut p, cid, comp) = setup(200, 80); + let mut l = + text_layer(&mut p, &comp, TextDoc { text: "Hi there\rtwo".into(), font: family.into(), style: "Regular".into(), size: 30.0, ..Default::default() }); + if let Some(c) = value { + animator(&mut p, &mut l, &["characterValue"], &["range"]); + set(&mut l, "text/animators/#1/properties/characterValue", Value::Scalar(c as u32 as f64)); + } + p.comp_mut(cid).unwrap().layers.push(l); + let log = ReadLog::new(); + let c = EvalCtx { reads: Some(&log), ..ctx(&p, cid, 0.0) }; + assert!(text_geom(&c, &p.comp(cid).unwrap().layers[0]).is_some()); + log.into_files() + }; + let files_of = |family: &str| files_with(family, None); + let files = files_of("Reads Test Sans"); + assert!(files.is_complete(), "{files:?}"); + assert_eq!(files.files().len(), 1, "{files:?}"); + assert_eq!(files.get(&font.to_string_lossy()), Some(stamp)); + // The bundled family reads no file. + let files = files_of(effectcraft_text::fonts::DEFAULT_FAMILY); + assert!(files.is_complete() && files.is_empty(), "{files:?}"); + // A family that isn't installed falls back: installing it would change the text. + assert!(!files_of("No Such Family 3184").is_complete()); + // A substituted character the face has is drawn with it: the same one file. + let files = files_with("Reads Test Sans", Some('B')); + assert!(files.is_complete(), "{files:?}"); + assert_eq!(files.get(&font.to_string_lossy()), Some(stamp)); + // One it lacks (Inter has no CJK) is drawn with a fallback face, or none: another installed + // font could take it, so the list can't vouch for the text. + assert!(!files_with("Reads Test Sans", Some('\u{4e2d}')).is_complete()); + // An empty first paragraph in the face draws no glyph, but in a box its face sets the first + // baseline that places the lines after it: the file is read all the same. + let (mut p, cid, comp) = setup(200, 80); + let base = TextDoc { + text: "\rHi".into(), + font: fonts::DEFAULT_FAMILY.into(), + style: "Regular".into(), + size: 30.0, + box_size: Some([180.0, 60.0]), + ..Default::default() + }; + let first = effectcraft_keyframe::CharStyle { font: "Reads Test Sans".into(), ..base.base_style() }; + let runs = vec![effectcraft_keyframe::StyleRun { len: 1, style: first }, effectcraft_keyframe::StyleRun { len: 2, style: base.base_style() }]; + let l = text_layer(&mut p, &comp, TextDoc { runs, ..base }); + p.comp_mut(cid).unwrap().layers.push(l); + let log = ReadLog::new(); + let c = EvalCtx { reads: Some(&log), ..ctx(&p, cid, 0.0) }; + assert!(text_geom(&c, &p.comp(cid).unwrap().layers[0]).is_some()); + let files = log.into_files(); + assert!(files.is_complete(), "{files:?}"); + assert_eq!(files.get(&font.to_string_lossy()), Some(stamp), "{files:?}"); +} diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 22507069e..6d2b071d5 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -477,6 +477,7 @@ fn clip_start(ctx: &EvalCtx, layer: &Layer, prop: &Property, lt: Tick) -> Tick { start } +/// Adds a model layer to the Advanced 3D scene with its material options, opacity and world transform. fn model_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { let lin = b.s.linear_io; let (opts, prim_mat) = model_options(ctx, layer, lin); @@ -487,7 +488,7 @@ fn model_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { LayerSource::Model { item } => { let Some(it) = r.project.item(*item) else { return }; let ItemKind::Footage(f) = &it.kind else { return }; - let Some(m) = r.footage.model(*item, f) else { return }; + let Some(m) = r.read(|| r.footage.model(*item, f)) else { return }; let unit = geo.map_or(1.0, |g| ctx.f(layer, g, "unitScale", 1.0)); let clip = geo.map_or(0, |g| ctx.e(layer, g, "animation")) as usize; let clip = clip.checked_sub(1).filter(|c| *c < m.animations.len()); @@ -651,12 +652,13 @@ pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::Extrude out } +/// Adds an extruded layer to the Advanced 3D scene with its card material, opacity and world transform. fn extruded_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams, b: &mut Builder) { let base = card_material(ctx, layer); let opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0); let world = b.outer * ctx.world_matrix(layer); let lin = b.s.linear_io; - for (mesh, c, local) in extruded_meshes(ctx, layer, p) { + for (mesh, c, local) in extruded_meshes(&r.with_reads(ctx), layer, p) { let l = |v: f32| if lin { v } else { srgb_to_linear(v) }; let m = b.material(Material { base: [l(c[0]), l(c[1]), l(c[2]), c[3]], @@ -694,13 +696,14 @@ fn card(b: &mut Builder, buf: &Buf, world: Mat4, mut mat: Material) { b.emit(&p, m, &|_| world); } +/// Adds a flat card layer to the Advanced 3D scene with its material, opacity and world transform. fn card_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { let mut mat = card_material(ctx, layer); mat.opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0); let world = b.outer * ctx.world_matrix(layer); if crate::text::per_char_3d(ctx, layer) { - for (buf, m) in crate::text::per_char_planes(ctx, layer, r.opts.scale) { - let buf = r.to_blend(Arc::new(buf), None); + for (buf, m) in crate::text::per_char_planes(&r.with_reads(ctx), layer, r.opts.scale) { + let buf = r.to_blend(Arc::new(buf), None, &Arc::default()); card(b, &buf, world * m, mat); } return; diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index b2612de98..88e805882 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -822,6 +822,7 @@ fn extruded_text_renders_as_a_mesh() { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); @@ -870,6 +871,7 @@ fn shape_surface_items_colour_the_extruded_surfaces() { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); @@ -915,6 +917,7 @@ fn text_animators_colour_the_extruded_surfaces() { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); @@ -1122,6 +1125,7 @@ fn extruded_strokes_render_as_bevelled_meshes() { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index a5a7c170e..ff08eb05f 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -203,9 +203,11 @@ pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, ord /// Prepare a layer as planes: one per character for per-character 3D text, else one. pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec> { if crate::text::per_char_3d(ctx, layer) { - return crate::text::per_char_planes(ctx, layer, r.opts.scale) + return crate::text::per_char_planes(&r.with_reads(ctx), layer, r.opts.scale) .into_iter() - .filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf: r.to_blend(Arc::new(buf), None), local: m })) + .filter_map(|(buf, m)| { + prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf: r.to_blend(Arc::new(buf), None, &Arc::default()), local: m }) + }) .collect(); } prepare(r, ctx, layer, order, out, in_run).into_iter().collect() diff --git a/crates/script/src/tests/ui_more.rs b/crates/script/src/tests/ui_more.rs index 483d686ef..c15aee0ae 100644 --- a/crates/script/src/tests/ui_more.rs +++ b/crates/script/src/tests/ui_more.rs @@ -231,6 +231,7 @@ fn position_now(s: &Session, layer: u64) -> [f64; 2] { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let v = ctx.value(l, l.props.prop("transform/position").unwrap()).components(); [v[0], v[1]] diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 5388629cf..d199bf0ff 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -158,11 +158,24 @@ fn info_from(n: FaceNames, data: FaceData, origin: &'static str) -> FaceInfo { } } +/// A font file's bytes and its metadata from the same open file (so the metadata describes the +/// bytes, not a later version of the file). +fn read_with_metadata(path: &std::path::Path) -> std::io::Result<(Vec, std::fs::Metadata)> { + use std::io::Read; + let mut f = std::fs::File::open(path)?; + let meta = f.metadata()?; + let mut bytes = Vec::new(); + f.read_to_end(&mut bytes)?; + Ok((bytes, meta)) +} + /// A loaded (or loadable) face. pub struct Face { pub id: FaceId, pub info: FaceInfo, bytes: OnceLock>, + /// The file's metadata as its bytes were read (a [`FaceData::File`] face, once loaded). + file_meta: OnceLock, shaper: OnceLock, } @@ -195,16 +208,29 @@ pub struct VMetrics { } impl Face { + /// The font file bytes, loaded once on first use, or `None` when they cannot be read. fn bytes(&self) -> Option<&[u8]> { self.bytes .get_or_init(|| match &self.info.data { FaceData::Static(b) => Some(FaceBytes::Static(b)), FaceData::Shared(b) => Some(FaceBytes::Shared(b.clone())), - FaceData::File(p) => std::fs::read(p).ok().map(|v| FaceBytes::Shared(Arc::new(v))), + FaceData::File(p) => { + let (bytes, meta) = read_with_metadata(p).ok()?; + let _ = self.file_meta.set(meta); + Some(FaceBytes::Shared(Arc::new(bytes))) + } }) .as_ref() .map(FaceBytes::get) } + /// The file a [`FaceData::File`] face is drawn from, with its metadata as its bytes were read + /// (`None` when it could not be read): what text drawn with the face was made of. Loads the + /// face. `None` for bundled faces and faces from memory. + pub fn file_read(&self) -> Option<(&std::path::Path, Option<&std::fs::Metadata>)> { + let FaceData::File(p) = &self.info.data else { return None }; + let _ = self.bytes(); + Some((p, self.file_meta.get())) + } /// The parsed font (None if the file is missing or unreadable). pub fn font(&self) -> Option> { FontRef::from_index(self.bytes()?, self.info.index).ok() @@ -314,7 +340,7 @@ fn bundled_default_face() -> Arc { variable: false, instances: Vec::new(), }); - Arc::new(Face { id: 0, info, bytes: OnceLock::new(), shaper: OnceLock::new() }) + Arc::new(Face { id: 0, info, bytes: OnceLock::new(), file_meta: OnceLock::new(), shaper: OnceLock::new() }) } fn db() -> &'static RwLock { @@ -331,9 +357,10 @@ fn db() -> &'static RwLock { }) } +/// Adds a face to the database and returns its id. fn push(db: &mut Db, info: FaceInfo) -> FaceId { let id = db.faces.len(); - db.faces.push(Arc::new(Face { id, info, bytes: OnceLock::new(), shaper: OnceLock::new() })); + db.faces.push(Arc::new(Face { id, info, bytes: OnceLock::new(), file_meta: OnceLock::new(), shaper: OnceLock::new() })); id } diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index e77f07840..8ebea08fc 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -170,10 +170,6 @@ impl EffectcraftApp { self.session.poll_jobs(); ctx.request_repaint_after(std::time::Duration::from_millis(100)); } - // The disk cache's footage salt, taken off the UI thread after footage changed. - if self.session.poll_disk_salt() { - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } // Analyses running in a worker (the web app). if !self.session.offloaded.is_empty() { self.session.poll_offload(); diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index 4af91782f..50638b708 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -27,6 +27,14 @@ fn startup_frames(ahead: i64, window: i64, budget: usize, frame_bytes: usize) -> ahead.min(window).min(fit).max(1) } +/// Whether frame `f` comes after the current frame `cur` and before the sound's frame `target` in +/// play order. A looping preview whose sound is at a lower frame than the picture has wrapped +/// to the loop's start, so the frames up to the end and then from the start count; otherwise +/// (and always without a loop) only the frames strictly between them do. +fn between_picture_and_sound(cur: i64, f: i64, target: i64, looping: bool) -> bool { + if target >= cur || !looping { cur < f && f < target } else { f > cur || f < target } +} + impl EffectcraftApp { /// RAM preview: advance at the preview's frame rate when frames are cached; otherwise hold /// and keep rendering ahead (the green cache bar fills, then playback runs in real time). @@ -42,11 +50,15 @@ impl EffectcraftApp { let video = plan.is_none_or(|p| p.video); // Prefetch ahead. let cur = fr.frame_at(self.session.time()); + // While the sound drives a video preview, frames are rendered and kept ahead of what it + // plays rather than of the picture, which drops frames behind it when rendering can't + // keep up (#2473). + let lead = if video { self.sound_frame(fr, wa, wb, plan.map_or(1, |p| p.step)).unwrap_or(cur) } else { cur }; let ahead = self.prefetch_ahead(ctx, now); let queued = self.frames.inflight(); let series = self.frame_series(cid, scale); // The preview's position orders prefetch and eviction (#595). - let playhead = plan.filter(|p| p.video).map(|p| frames::Playhead { series, cur, start: p.start, end: p.end, step: p.step, looping: p.looping }); + let playhead = plan.filter(|p| p.video).map(|p| frames::Playhead { series, cur: lead, start: p.start, end: p.end, step: p.step, looping: p.looping }); self.frames.set_playhead(playhead); let pass = PlaybackPass { ctx, @@ -69,7 +81,7 @@ impl EffectcraftApp { } let step = plan.map_or(1, |p| p.step); let fit_ahead = self.frames_that_fit(&c, scale) - 1; - self.prefetch_playback(&pass, fit_ahead); + self.prefetch_playback(&PlaybackPass { cur: lead, ..pass }, fit_ahead); self.pump_meters(ctx, now); if !self.playback.playing { self.cache_when_idle(ctx, &c, &series, now, input_active, (wa, wb)); @@ -238,8 +250,8 @@ impl EffectcraftApp { } self.playback.starting = false; self.playback.waiting = false; - // Not on a frame that isn't cached: the stall below would stop the sound at once. The - // preview goes on as a stalled one does, and the sound starts once frames are cached. + // Not on a frame that isn't cached: the picture couldn't show where the sound starts. The + // preview goes on silently, and the sound starts once frames are cached. if need.is_some() { self.playback.audio_held = false; self.start_audio(fr.tick_of(cur)); @@ -247,16 +259,17 @@ impl EffectcraftApp { false } - /// Held sound starts once the frames ahead are cached again after a stall (see - /// `Playback::audio_held`): the prefetch window when the silent preview keeps real time, else - /// the rest of the preview. + /// Held sound starts once the frames ahead are cached (see `Playback::audio_held`): the + /// prefetch's `ahead` when the silent preview keeps real time, else the rest of the preview. fn start_held_audio(&mut self, pass: &PlaybackPass<'_>, fit: i64) { let PlaybackPass { now, plan, series, cur, ahead, frame_rate: fr, .. } = *pass; if !self.playback.audio_held { return; } let Some(p) = plan else { return }; - if (self.playback.keeps_up(now) && self.cached_ahead(&p, &series, cur, ahead.min(fit))) || self.cached_ahead(&p, &series, cur, fit) { + // No more than the prefetch requests: offsets below `fit - 1` (`advance_playback`). + let need = ahead.min(fit - 1).max(1); + if (self.playback.keeps_up(now) && self.cached_ahead(&p, &series, cur, need)) || self.cached_ahead(&p, &series, cur, fit) { self.playback.audio_held = false; self.playback.start_wall = now; self.playback.start_frame = cur; @@ -290,20 +303,48 @@ impl EffectcraftApp { self.playback.frame_shown(now); } } else { - // Rendering can't keep up: rather than skip frames, the sound stops and every - // frame shows as it renders until the frames ahead are cached again. - self.audio = None; - self.playback.audio_held = true; + // Rendering can't keep up: the sound plays on and the picture drops frames, showing + // the latest one rendered up to the sound, while `advance_playback` renders ahead of + // the sound (#2473). Stopping the sound instead and starting it again once frames were + // cached chopped it into bursts that played as distortion (#3260). self.playback.waiting = true; - // The rate measured before the stall no longer says it keeps up. - self.playback.shown_at.clear(); - self.playback.start_wall = now; - self.playback.start_frame = cur; + if let Some(f) = self.latest_cached_before(pass, target).filter(|&f| f != cur) { + self.session.set_time(fr.tick_of(f)); + self.playback.frame_shown(now); + } } ctx.request_repaint(); true } + /// The frame under the audible sample while the sound drives the preview, kept in the + /// preview's range and on its frame step; `None` without sound. + fn sound_frame(&self, fr: effectcraft_engine::time::FrameRate, wa: i64, wb: i64, step: i64) -> Option { + let a = self.audio.as_ref()?; + let step = step.max(1); + Some(wa + (audio::frame_of_sample(a.clock(), a.rate, fr).clamp(wa, wb) - wa).div_euclid(step) * step) + } + + /// The last cached frame after the current one and before `target`, in play order: what a + /// preview behind its sound shows (`None` when no such frame is cached). The walk stops at + /// the first frame that isn't before the sound's, which it can step over without meeting + /// (a picture off the plan's frame step) or never reach (a sound behind the picture). + fn latest_cached_before(&self, pass: &PlaybackPass<'_>, target: i64) -> Option { + let PlaybackPass { plan, series, cur, window, looping, .. } = *pass; + let cached = self.frames.cached_frames(&series); + let mut best = None; + for k in 1..=(window.last - window.first + 1).max(1) { + let Some(f) = plan.map_or(Some(cur + k), |p| p.frame_after(cur, k)) else { break }; + if !between_picture_and_sound(cur, f, target, looping) { + break; + } + if cached.binary_search(&f).is_ok() { + best = Some(f); + } + } + best + } + /// The wall clock advances the preview when no audio device drives it, and shows the target /// frame (or holds when it is not cached yet). fn advance_frame_clock(&mut self, pass: &PlaybackPass<'_>, step: i64) { @@ -464,7 +505,28 @@ impl EffectcraftApp { #[cfg(test)] mod startup_tests { - use super::startup_frames; + use super::{between_picture_and_sound, startup_frames}; + + /// The frames a preview behind its sound may show are only those before the sound's frame in + /// play order, so the picture is never ahead of the sound (#2473). + #[test] + fn a_dropped_frame_preview_never_shows_past_the_sound() { + // Sound ahead of the picture: only the frames between them. + assert!(between_picture_and_sound(10, 12, 14, false)); + assert!(!between_picture_and_sound(10, 14, 14, false), "not the sound's own frame"); + assert!(!between_picture_and_sound(10, 15, 14, true), "nor one past it, stepped over off the grid"); + assert!(!between_picture_and_sound(10, 10, 14, true), "nor the current frame"); + // A looping preview whose sound wrapped to the start: the end of the loop, then its start. + assert!(between_picture_and_sound(90, 95, 3, true)); + assert!(between_picture_and_sound(90, 1, 3, true)); + assert!(!between_picture_and_sound(90, 3, 3, true)); + assert!(!between_picture_and_sound(90, 50, 3, true), "the wrapped walk stops before reaching the picture again"); + // Without a loop, a sound behind the picture allows nothing ahead of it. + assert!(!between_picture_and_sound(90, 95, 3, false)); + assert!(!between_picture_and_sound(90, 1, 3, false)); + // The sound on the picture's frame: nothing. + assert!(!between_picture_and_sound(10, 11, 10, true)); + } /// The frames a preview with sound waits for are always requested and fit together (#947). #[test] diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 001790116..968b2d791 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -15,9 +15,12 @@ //! egui frames, within a time budget. //! //! With the disk cache on (Settings ▸ Media & Disk Cache), every CPU-rendered frame is also -//! written to disk under a content key (the comp's content hash, frame, scale, view and render -//! options), and a job first looks there: frames survive restarts and project reopenings. The -//! timeline shows disk-only frames as a blue cache bar. +//! written to disk under a content key (the comp's content hash from the project in memory, +//! frame, scale, view and render options) with the source files it read, and a job first looks +//! there: frames survive restarts and project reopenings. An entry is used only while the files +//! it lists are unchanged, checked by the frame worker when it reads the entry (a frame whose +//! files can't all be named is shown but not stored). The timeline shows disk-only frames as a +//! blue cache bar (present entries; their files are checked only when a frame is used). //! //! With Mercury GPU Acceleration (a [`Gpu`] on egui-wgpu's device and the project's renderer set //! to the GPU) frames stay on the GPU: the compositor writes an RGBA8 texture that the viewer @@ -29,11 +32,12 @@ //! cache keeps them as half-float entries. use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; -use effectcraft_engine::project::{Comp, Heard, ItemId, ItemKind, LayerSource, Node, Project, PropGroup}; +use effectcraft_engine::project::{Comp, Heard, ItemId, ItemKind, Node, Project, PropGroup}; use effectcraft_engine::render::disk_cache::{self, DiskCache}; +use effectcraft_engine::render::reads::{ReadFiles, ReadLog}; use effectcraft_engine::render::{CancellableFootage, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer}; use effectcraft_engine::time::Tick; use effectcraft_gpu::{DisplayFrame, FrameError, Gpu}; @@ -156,8 +160,41 @@ impl Playhead { } } +/// A frame in the RAM preview and the source files it was made of (`None`: unknown, a frame +/// rendered elsewhere or kept on the GPU), so it can be written to the disk cache later +/// ([`Frames::persist_missing`]). +struct Cached { + img: FrameImage, + /// (the browser's frames are stored by its frame workers, not from here) + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + files: Option>, + /// The disk cache its render started with and that cache's drop generation then: the frame + /// is written to that cache only, and only while none of its files was dropped since + /// (`None`: rendered without a disk cache, or elsewhere; never written from here). + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + since: Option, +} + +/// A disk cache and its drop generation ([`DiskCache::drop_generation`]) when a render started. +#[derive(Clone)] +struct DiskSince(std::sync::Weak, u64); + +impl DiskSince { + /// A marker for the drop generation of `dc` at the time of a render. + fn of(dc: &Arc, generation: u64) -> DiskSince { + DiskSince(Arc::downgrade(dc), generation) + } + + /// The generation, when `dc` is the cache it was taken from (the weak reference keeps that + /// cache's address from being reused by another). + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + fn for_cache(&self, dc: &Arc) -> Option { + std::ptr::eq(self.0.as_ptr(), Arc::as_ptr(dc)).then_some(self.1) + } +} + struct Cache { - map: HashMap, + map: HashMap, order: VecDeque, bytes: usize, /// Bytes of GPU frames (also counted in `bytes`). @@ -174,10 +211,17 @@ struct Cache { } impl Cache { + /// A frame without its source files (tests). + #[cfg(test)] fn insert(&mut self, k: FrameKey, img: FrameImage) { + self.insert_with(k, img, None, None); + } + /// [`Cache::insert`] with the source files the frame was made of and the disk cache's drop + /// generation when its render started. + fn insert_with(&mut self, k: FrameKey, img: FrameImage, files: Option>, since: Option) { let (sz, gpu) = (img.bytes(), img.is_gpu()); - if let Some(old) = self.map.insert(k, img) { - self.forget(&old); + if let Some(old) = self.map.insert(k, Cached { img, files, since }) { + self.forget(&old.img); } else { self.order.push_back(k); } @@ -215,7 +259,7 @@ impl Cache { /// than the one on screen first (a zoom or resolution change). fn evict_gpu(&mut self) { while self.gpu_bytes > self.gpu_budget { - let is_gpu = |k: &FrameKey| self.map.get(k).is_some_and(FrameImage::is_gpu); + let is_gpu = |k: &FrameKey| self.map.get(k).is_some_and(|c| c.img.is_gpu()); let other_scale = self.order.iter().position(|k| Some(k.scale) != self.scale && is_gpu(k)); let Some(i) = other_scale.or_else(|| self.order.iter().position(is_gpu)) else { break }; if let Some(old) = self.order.remove(i) { @@ -251,9 +295,10 @@ impl Cache { self.gpu_bytes = self.gpu_bytes.saturating_sub(img.bytes()); } } + /// Removes the entry for `k` and releases its image. fn remove(&mut self, k: &FrameKey) { if let Some(i) = self.map.remove(k) { - self.forget(&i); + self.forget(&i.img); } } /// Shown again: last in line for eviction. @@ -316,10 +361,10 @@ pub struct RenderSource { /// ([`Frames::identity`]). type Identity = (u64, Option>); -/// Content keys of (project revision, comp, virtual-file generation). The generation is one -/// counter bumped when a browser file is replaced, so a lookup stays a map read. Hashing the -/// new bytes happens only when that generation misses. -type ContentKeys = Arc>>; +/// Disk-cache content keys ([`disk_cache::comp_content_key`]) of (project revision, comp). They +/// come from the project in memory alone: the files a frame read are checked by its entry +/// when it is read (#3184). +type ContentKeys = Arc>>; #[cfg(not(target_arch = "wasm32"))] #[derive(Default)] @@ -328,16 +373,17 @@ struct ContentKeyJobs { pending: VecDeque<(Arc, u64, u64)>, } -fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64, epoch: u64, virtual_id: Option>) -> u128 { - if let Some(k) = keys.lock().ok().and_then(|m| m.get(&(revision, comp, epoch)).copied()) { +/// The content key of `comp` at `revision`, memoised per revision. +fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64) -> u128 { + if let Some(k) = keys.lock().ok().and_then(|m| m.get(&(revision, comp)).copied()) { return k; } - let k = disk_cache::comp_content_key_with(project, ItemId(comp), virtual_id); + let k = disk_cache::comp_content_key(project, ItemId(comp)); if let Ok(mut m) = keys.lock() { if m.len() > 256 { m.clear(); } - m.insert((revision, comp, epoch), k); + m.insert((revision, comp), k); } k } @@ -388,7 +434,8 @@ fn comp_content_with<'a>( let Some(ItemKind::Comp(c)) = item(id).map(|i| &i.kind) else { continue }; let c = version(id, c); for l in &c.layers { - if let LayerSource::Comp { item } | LayerSource::Footage { item } | LayerSource::Solid { item } = l.source { + // (a 3D model's item too) + if let Some(item) = l.source.item() { stack.push(item); } expressions = expressions || (everything && has_expression(&l.props)); @@ -423,18 +470,30 @@ pub fn opts_hash(o: &RenderOpts) -> u64 { } /// Disk key of a viewer frame (render options included: region of interest, draft…). -fn frame_disk_key( - keys: &ContentKeys, - project: &Project, - key: &FrameKey, - opts_hash: u64, - epoch: u64, - virtual_id: Option>, -) -> u128 { - let content = content_key(keys, project, key.revision, key.comp, epoch, virtual_id); +fn frame_disk_key(keys: &ContentKeys, project: &Project, key: &FrameKey, opts_hash: u64) -> u128 { + let content = content_key(keys, project, key.revision, key.comp); disk_cache::frame_key(content, key.frame, key.scale, key.view ^ opts_hash) } +/// Queue `img` for the disk cache under `dk` with the source files it was made of. `false` when +/// refused: a GPU frame (no CPU pixels), files that can't be checked when the entry is read +/// (an incomplete list, #3184: the frame is shown, not stored), a full queue, a file dropped +/// (File ▸ Reload Footage) after `since`, the drop generation its render started at; no room on +/// the disk (checked first: the conversion and compression would be wasted). +fn put_disk_frame(dc: &DiskCache, dk: u128, img: &FrameImage, files: &ReadFiles, virtual_id: Option>, since: u64) -> bool { + if !files.is_complete() || !dc.accepts_writes() { + return false; + } + match img { + FrameImage::Cpu(img) => { + let rgba: Vec = img.pixels.iter().flat_map(|c| c.to_array()).collect(); + dc.put_frame(dk, img.size[0] as u32, img.size[1] as u32, &rgba, files, virtual_id, since) + } + FrameImage::CpuF16(f) => dc.put_frame_f16(dk, f.width, f.height, f.bits(), files, virtual_id, since), + FrameImage::Gpu(_) => false, + } +} + /// A frame for a [`RemoteFrames`] renderer. pub struct RemoteJob { pub project: Arc, @@ -512,9 +571,8 @@ pub struct Frames { /// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout. pub last_ms: Arc>, content_keys: ContentKeys, - /// Bumped when a virtual file is replaced. Lookups key on it instead of statting footage. - virtual_epoch: Arc, - /// Size and content hash for paths with no native metadata. Called only on a cache miss. + /// Size and content hash for paths with no native metadata (the browser's file table): to + /// store and check the files of disk entries. virtual_id: Option Option<(u64, u64)> + Send + Sync>>, #[cfg(not(target_arch = "wasm32"))] content_key_jobs: Arc>, @@ -602,7 +660,6 @@ impl Default for Frames { ctx: None, last_ms: Arc::new(Mutex::new(0.0)), content_keys: Arc::default(), - virtual_epoch: Arc::new(AtomicU64::new(0)), virtual_id: None, #[cfg(not(target_arch = "wasm32"))] content_key_jobs: Arc::default(), @@ -754,7 +811,7 @@ impl Frames { pub fn retire_gpu(&self) { self.gpu_retired.store(true, Ordering::Release); if let Ok(mut cache) = self.cache.lock() { - let keys: Vec<_> = cache.map.iter().filter_map(|(key, image)| image.is_gpu().then_some(*key)).collect(); + let keys: Vec<_> = cache.map.iter().filter_map(|(key, c)| c.img.is_gpu().then_some(*key)).collect(); for key in keys { cache.remove(&key); } @@ -789,7 +846,7 @@ impl Frames { /// The cached frame, which becomes the most recently used. pub fn get(&self, k: &FrameKey) -> Option { let mut c = self.cache.lock().ok()?; - let img = c.map.get(k).cloned()?; + let img = c.map.get(k).map(|c| c.img.clone())?; c.touch(k); Some(img) } @@ -825,7 +882,7 @@ impl Frames { }) .max_by_key(|k| k.revision) .copied()?; - let img = c.map.get(&newest).cloned()?; + let img = c.map.get(&newest).map(|c| c.img.clone())?; c.touch(&newest); Some((newest, img)) } @@ -968,7 +1025,6 @@ impl Frames { if src.disk.is_none() && remote.is_none() { return vec![]; } - 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)); @@ -976,7 +1032,7 @@ impl Frames { } #[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 known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp)).copied(); let Some(content) = known else { self.request_content_key(src.project.clone(), base.revision, base.comp); return vec![]; @@ -984,7 +1040,7 @@ impl Frames { content }; #[cfg(target_arch = "wasm32")] - let content = content_key(&self.content_keys, &src.project, base.revision, base.comp, epoch, self.virtual_id.as_deref()); + let content = content_key(&self.content_keys, &src.project, base.revision, base.comp); let ram: HashSet = self.cached_frames(base).into_iter().collect(); let oh = opts_hash(opts); (start..end).filter(|f| !ram.contains(f)).filter(|f| contains(disk_cache::frame_key(content, *f, base.scale, base.view ^ oh))).collect() @@ -995,20 +1051,27 @@ impl Frames { /// 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). + /// writes them (`base.opts` also carries the Roto Brush model stamp). Only frames that know + /// every source file they were made of are written (an entry is checked against them). #[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(); + // Free disk space leaves the cache (next to) no room: nothing would be stored, so nothing + // is converted (a refusal doesn't count as a try, so every idle pass would convert them + // all again). A later idle pass writes them once there is room (`prefs_live::memory_watch` + // keeps frames coming meanwhile). + if !dc.accepts_writes() { + return false; + } + let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp)).copied(); let Some(content) = known else { self.request_content_key(src.project.clone(), base.revision, base.comp); return true; }; - let (missing, converting): (Vec<(u128, FrameImage)>, bool) = { + let (missing, converting): (Vec<(u128, FrameImage, Arc, u64)>, 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(); @@ -1021,14 +1084,17 @@ impl Frames { .map .iter() .filter(|(k, _)| k.same_series(base)) - .filter_map(|(k, img)| { - // (GPU frames have no CPU pixels to write) - if img.is_gpu() { - return None; - } + .filter_map(|(k, c)| { + // (GPU frames have no CPU pixels to write; a frame that can't name its files + // can't be checked when read) + let files = c.files.as_ref().filter(|f| f.is_complete() && !c.img.is_gpu())?; + // (only with the generation its render started at, for this cache: a frame + // made of a file Reload Footage dropped since holds the pixels from before) + let since = c.since.as_ref().and_then(|s| s.for_cache(&dc)).filter(|s| !dc.dropped_since(files, *s))?; 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())) + (tries < 2 && !dc.contains(disk_cache::Kind::Frame, dk) && !dc.frame_pending(dk) && queued.insert(dk)) + .then(|| (dk, c.img.clone(), files.clone(), since)) }) .collect(); (missing, !queued.is_empty()) @@ -1036,19 +1102,12 @@ impl Frames { if missing.is_empty() { return converting; } - let (persisted, written) = (self.persisted.clone(), self.written.clone()); + let (persisted, written, virtual_id) = (self.persisted.clone(), self.written.clone(), self.virtual_id.clone()); let work = move || { - for (dk, img) in missing { + for (dk, img, files, since) in missing { // (accepted: the disk cache tracks it until written, and it counts as a try; // refused by a full queue: retried, without counting) - let accepted = match &img { - FrameImage::Cpu(img) => { - let rgba: Vec = img.pixels.iter().flat_map(|c| c.to_array()).collect(); - dc.put_frame(dk, img.size[0] as u32, img.size[1] as u32, &rgba) - } - FrameImage::CpuF16(f) => dc.put_frame_f16(dk, f.width, f.height, f.bits()), - FrameImage::Gpu(_) => false, - }; + let accepted = put_disk_frame(&dc, dk, &img, &files, virtual_id.as_deref(), since); if accepted && let Ok(mut w) = written.lock() { let n = w.1.entry(dk).or_default(); *n = n.saturating_add(1); @@ -1091,7 +1150,6 @@ impl Frames { 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 { @@ -1111,7 +1169,7 @@ impl Frames { 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 key = |p: &Project| disk_cache::comp_content_key(p, comp); let (a, b) = (key(&c.from), key(&c.to)); if a == b { return false; @@ -1137,6 +1195,7 @@ impl Frames { } } + /// Queues a background hash of `comp` at `revision`, coalescing rapid edits so only a bounded number of snapshots are kept. #[cfg(not(target_arch = "wasm32"))] fn request_content_key(&self, project: Arc, revision: u64, comp: u64) { let mut jobs = self.content_key_jobs.lock().unwrap_or_else(std::sync::PoisonError::into_inner); @@ -1153,7 +1212,6 @@ impl Frames { jobs.running = true; drop(jobs); let (jobs, keys, ctx) = (self.content_key_jobs.clone(), self.content_keys.clone(), self.ctx.clone()); - let (epoch, id) = (self.virtual_epoch.clone(), self.virtual_id.clone()); let work = move || loop { let next = { let mut jobs = jobs.lock().unwrap_or_else(std::sync::PoisonError::into_inner); @@ -1164,9 +1222,7 @@ impl Frames { next }; let Some((project, revision, comp)) = next else { return }; - let epoch = epoch.load(Ordering::Relaxed); - let id = id.clone(); - if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| content_key(&keys, &project, revision, comp, epoch, id.as_deref()))).is_err() { + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| content_key(&keys, &project, revision, comp))).is_err() { log::error!("could not compute a preview disk-cache key; the cache indicator will retry"); } if let Some(ctx) = &ctx { @@ -1192,6 +1248,7 @@ impl Frames { self.cache.lock().map(|c| c.budget).unwrap_or(0) } + /// Drops every cached frame and purges the remote server, if there is one. pub fn clear(&self) { if let Some(r) = &self.remote { r.purge(); @@ -1205,9 +1262,8 @@ impl Frames { c.gpu_pressure = None; c.gpu_adjusted = None; } - // Disk keys hash the footage files' size and time, but are remembered per revision; a - // file reloaded from disk without a new revision (a re-rendered image sequence) must be - // keyed again, or the disk cache serves the frames from before. + // (the disk keys of a revision don't depend on files: a file changed on disk is caught + // when its frames' entries are read; they are forgotten with the frames all the same) if let Ok(mut k) = self.content_keys.lock() { k.clear(); } @@ -1386,6 +1442,7 @@ impl Frames { } } + /// A render worker sharing this cache's queue, cache and in-flight set. fn worker(&self) -> Worker { Worker { queue: self.queue.clone(), @@ -1394,20 +1451,15 @@ impl Frames { ctx: self.ctx.clone(), last: self.last_ms.clone(), content_keys: self.content_keys.clone(), - virtual_epoch: self.virtual_epoch.clone(), virtual_id: self.virtual_id.clone(), gpu_retired: self.gpu_retired.clone(), } } - /// Browser file table: `epoch` bumps on each replacement, and `id` is the size and content - /// hash used when a content key is rebuilt. Neither runs on a cache hit. - pub fn set_virtual_media(&mut self, epoch: Arc, id: Arc Option<(u64, u64)> + Send + Sync>) { - self.virtual_epoch = epoch; + /// Browser file table: `id` gives the size and content hash of a path served from memory, + /// stored with the disk entries of the frames that read it and checked when they are read. + pub fn set_virtual_id(&mut self, id: Arc Option<(u64, u64)> + Send + Sync>) { self.virtual_id = Some(id); - if let Ok(mut keys) = self.content_keys.lock() { - keys.clear(); - } } /// Render frames with `remote` from now on (`None`: on this thread again). @@ -1465,8 +1517,10 @@ impl Frames { let gpu_here = job.src.gpu.is_some() && job.src.gpu_display && !(remote.gpu() && comp_has_effects(&job.src.project, job.comp)); if gpu_here { let t0 = web_time::Instant::now(); - if let Some(img) = w.render_gpu(&job) { - w.finish(&job, img, t0); + // (A frame kept on the GPU is not written to the disk cache: its files are not + // needed.) + if let Some((img, _files)) = w.render_gpu(&job) { + w.finish(&job, img, None, None, t0); continue; } } @@ -1478,8 +1532,7 @@ impl Frames { let fw = self.worker(); let busy = self.remote_busy.clone(); let t0 = web_time::Instant::now(); - let epoch = w.virtual_epoch.load(Ordering::Relaxed); - let disk_key = remote.disk().then(|| frame_disk_key(&w.content_keys, &job.src.project, &key, opts_hash(&job.opts), epoch, w.virtual_id.as_deref())); + let disk_key = remote.disk().then(|| frame_disk_key(&w.content_keys, &job.src.project, &key, opts_hash(&job.opts))); let rj = RemoteJob { project: job.src.project.clone(), revision: key.revision, comp: job.comp, t: job.t, opts: job.opts, disk_key }; remote.start( rj, @@ -1490,7 +1543,7 @@ impl Frames { match r { Ok(f) => { let img = egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba); - fw.finish_key(key, urgent, FrameImage::Cpu(Arc::new(img)), t0); + fw.finish_key(key, urgent, FrameImage::Cpu(Arc::new(img)), None, None, t0); } Err(e) => { // Not rendered (a stale revision, a lost worker): the viewer asks again. @@ -1536,7 +1589,6 @@ struct Worker { ctx: Option, last: Arc>, content_keys: ContentKeys, - virtual_epoch: Arc, virtual_id: Option Option<(u64, u64)> + Send + Sync>>, gpu_retired: Arc, } @@ -1599,28 +1651,17 @@ impl Worker { job }; let t0 = web_time::Instant::now(); - let epoch = self.virtual_epoch.load(Ordering::Relaxed); - let disk = job - .src - .disk - .as_ref() - .map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts), epoch, self.virtual_id.as_deref()))); - // Half-float frames (32 bpc) have entries of their own (their key has the options). - let from_disk = disk.as_ref().and_then(|(dc, k)| { - if job.opts.skip_hdr_step { - let f = dc.get_frame_f16(*k)?; - let encoding = effectcraft_engine::viewer::FrameEncoding::of(&job.src.project.settings, true); - Some(FrameImage::CpuF16(Arc::new(F16Frame::from_bits(f.width, f.height, f.rgba, encoding)))) - } else { - let f = dc.get_frame(*k)?; - Some(FrameImage::Cpu(Arc::new(egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba)))) - } - }); + // (the drop generation before the frame reads anything: File ▸ Reload Footage meanwhile + // makes what it read from a reloaded file stale) + let disk = + job.src.disk.as_ref().map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts)), dc.drop_generation())); + let from_disk = disk.as_ref().and_then(|(dc, k, _)| self.disk_frame(dc, *k, &job)); let disk_hit = from_disk.is_some(); - let ci = match from_disk { - Some(f) => f, + // `files`: the source files the frame was made of (see `render::reads`). + let (ci, files) = match from_disk { + Some(hit) => hit, None => match self.render_guarded(&job).filter(|_| !job.cancel.load(Ordering::Relaxed)) { - Some(ci) => ci, + Some(rendered) => rendered, // Called off (frames it read may be missing) or failed: not cached, and not // "in progress" forever: the viewer asks for it again. None => { @@ -1629,42 +1670,61 @@ impl Worker { } }, }; + let mut files = files; + if let Some((dc, _, since)) = &disk + && dc.dropped_since(&files, *since) + { + // Made of a file as it was before File ▸ Reload Footage dropped it: shown, but its + // list can't vouch for it, so neither this worker nor `persist_missing` stores it. + files.mark_incomplete(); + } + let files = Arc::new(files); // Publish the shared frame and wake the viewer before optional RGBA conversion // and compression. Persistence still runs on this worker, using the same snapshot. - self.finish(&job, ci.clone(), t0); - if !disk_hit && let Some((dc, k)) = &disk { - let written = match &ci { - FrameImage::Cpu(img) => { - let rgba: Vec = img.pixels.iter().flat_map(|c| c.to_array()).collect(); - dc.put_frame(*k, img.size[0] as u32, img.size[1] as u32, &rgba); - true - } - FrameImage::CpuF16(f) => { - dc.put_frame_f16(*k, f.width, f.height, f.bits()); - true - } - FrameImage::Gpu(_) => false, - }; - if written && let Some(ctx) = &self.ctx { - ctx.request_repaint(); - } + let since = disk.as_ref().map(|(dc, _, since)| DiskSince::of(dc, *since)); + self.finish(&job, ci.clone(), Some(files.clone()), since, t0); + if !disk_hit + && let Some((dc, k, since)) = &disk + && put_disk_frame(dc, *k, &ci, &files, self.virtual_id.as_deref(), *since) + && let Some(ctx) = &self.ctx + { + ctx.request_repaint(); } true } - /// Store a finished frame and release its job. - fn finish(&self, job: &Job, img: FrameImage, t0: web_time::Instant) { - self.finish_key(job.key, job.urgent, img, t0); + /// The frame of `job` from the disk cache (key `k`), if its entry is there and the source + /// files it lists are unchanged (checked here, on the frame worker: a few `metadata` calls + /// for that frame's files). An entry whose files changed is deleted: the frame renders again + /// and its new entry replaces it. + fn disk_frame(&self, dc: &DiskCache, k: u128, job: &Job) -> Option<(FrameImage, ReadFiles)> { + let virtual_id = self.virtual_id.as_deref(); + // Half-float frames (32 bpc) have entries of their own (their key has the options). + if job.opts.skip_hdr_step { + let (f, files) = dc.get_frame_f16(k, virtual_id)?; + let encoding = effectcraft_engine::viewer::FrameEncoding::of(&job.src.project.settings, true); + Some((FrameImage::CpuF16(Arc::new(F16Frame::from_bits(f.width, f.height, f.rgba, encoding))), files)) + } else { + let (f, files) = dc.get_frame(k, virtual_id)?; + Some((FrameImage::Cpu(Arc::new(egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba))), files)) + } } - fn finish_key(&self, key: FrameKey, urgent: bool, img: FrameImage, t0: web_time::Instant) { + /// Store a finished frame (with the source files it was made of, when known) and release its + /// job. + fn finish(&self, job: &Job, img: FrameImage, files: Option>, since: Option, t0: web_time::Instant) { + self.finish_key(job.key, job.urgent, img, files, since, t0); + } + + /// Stores a finished frame in the cache with its read files, records the render time for urgent jobs, then releases the job. + fn finish_key(&self, key: FrameKey, urgent: bool, img: FrameImage, files: Option>, since: Option, t0: web_time::Instant) { if urgent && let Ok(mut l) = self.last.lock() { *l = t0.elapsed().as_secs_f64() * 1000.0; } if let Ok(mut c) = self.cache.lock() && (!img.is_gpu() || !self.gpu_retired.load(Ordering::Acquire)) { - c.insert(key, img); + c.insert_with(key, img, files, since); } self.release(key, urgent); } @@ -1689,9 +1749,13 @@ impl Worker { } /// [`Worker::render_job`], again on the CPU if it panicked (the panic hook has logged it); - /// `None` if that panicked too. - fn render_guarded(&self, job: &Job) -> Option { - let attempt = |gpu: bool| std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.render_job(job, gpu))).ok(); + /// `None` if that panicked too. With the source files the frame was made of. + fn render_guarded(&self, job: &Job) -> Option<(FrameImage, ReadFiles)> { + let attempt = |gpu: bool| { + let files = ReadLog::new(); + let img = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.render_job(job, gpu, &files))).ok()?; + Some((img, files.into_files())) + }; let img = attempt(true).or_else(|| { log::error!("a frame render panicked (frame {})", job.key.frame); job.src.gpu.as_ref().and_then(|_| attempt(false)) @@ -1721,26 +1785,31 @@ impl Worker { } } + /// Tells the cache that a GPU render succeeded. fn gpu_succeeded(&self) { if let Ok(mut c) = self.cache.lock() { c.gpu_succeeded(); } } + /// Whether GPU rendering is available and has not been retired. fn gpu_ready(&self) -> bool { !self.gpu_retired.load(Ordering::Acquire) && self.cache.lock().is_ok_and(|c| c.gpu_ready()) } - /// The frame on the GPU, kept there for the viewer, if the compositor can do it alone. - fn render_gpu(&self, job: &Job) -> Option { + /// The frame on the GPU, kept there for the viewer, if the compositor can do it alone, with + /// the source files it was made of. + fn render_gpu(&self, job: &Job) -> Option<(FrameImage, ReadFiles)> { if !self.gpu_ready() { return None; } let g = job.src.gpu.as_ref()?; let footage = job.footage(); + let files = ReadLog::new(); let mut r = Renderer::new(&job.src.project, &footage, job.opts); r.expr = job.src.expr.as_deref(); r.cache = Some(&job.src.layer_cache); + r.reads = Some(&files); r.accel = Some(g as &dyn effectcraft_engine::render::Accelerator); r.active_accel()?; let f = g.within_memory(|| g.render_display(&r, job.comp, job.t)); @@ -1750,7 +1819,7 @@ impl Worker { if f.is_some() { self.gpu_succeeded(); } - f.map(|f| FrameImage::Gpu(Arc::new(f))) + f.map(|f| (FrameImage::Gpu(Arc::new(f)), files.into_files())) } Err(e) => { self.gpu_attempt_failed(g, &e); @@ -1759,11 +1828,13 @@ impl Worker { } } - /// Render a frame; `gpu: false` keeps it off the GPU compositor. - fn render_job(&self, job: &Job, gpu: bool) -> FrameImage { + /// Render a frame; `gpu: false` keeps it off the GPU compositor. The source files it reads + /// are noted in `files` (a GPU attempt that falls back to the CPU notes both renders'). + fn render_job(&self, job: &Job, gpu: bool, files: &ReadLog) -> FrameImage { let footage = job.footage(); let mut r = Renderer::new(&job.src.project, &footage, job.opts); r.expr = job.src.expr.as_deref(); + r.reads = Some(files); let gpu_allowed = gpu && self.gpu_ready(); // A failed GPU attempt may have populated intermediate entries before its scope // reported failure. The CPU retry must compute its own pixels from the snapshot. @@ -1789,7 +1860,7 @@ impl Worker { // Time the GPU's work, not just its recording. g.wait(); if g.context().check_health().is_err() { - return self.render_job(job, false); + return self.render_job(job, false, files); } p.record(key, true, t0.elapsed().as_secs_f64() * 1000.0); } @@ -1802,7 +1873,7 @@ impl Worker { } Err(e) => { self.gpu_attempt_failed(g, &e); - return self.render_job(job, false); + return self.render_job(job, false, files); } } } @@ -1822,7 +1893,7 @@ impl Worker { } Err(e) => { self.gpu_attempt_failed(g, &e); - self.render_job(job, false) + self.render_job(job, false, files) } }; } diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index df7b3900c..360ddf1b6 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -1,4 +1,5 @@ use super::*; +use effectcraft_engine::project::LayerSource; #[test] fn render_scales_are_the_factors_the_thousandths_stand_for() { @@ -60,7 +61,7 @@ fn disk_fixture(stored_frames: &[i64]) -> (Frames, RenderSource, FrameKey, Rende disk.keys.lock().unwrap().extend(stored_frames.iter().map(|frame| disk_cache::frame_key(content, *frame, base.scale, base.view ^ opts_hash(&opts)))); let mut frames = Frames::default(); frames.set_remote(Some(disk.clone())); - frames.content_keys.lock().unwrap().insert((base.revision, base.comp, 0), content); + frames.content_keys.lock().unwrap().insert((base.revision, base.comp), content); let source = RenderSource { project: session.project.clone(), footage: session.footage.clone(), @@ -126,7 +127,7 @@ fn disk_indicator_does_not_hash_on_ui_thread_and_coalesces_edits() { assert_eq!(frames.content_key_jobs.lock().unwrap().pending.len(), 1); release.send(()).unwrap(); let until = Instant::now() + Duration::from_secs(3); - while !frames.content_keys.lock().unwrap().contains_key(&(base.revision, base.comp, 0)) { + while !frames.content_keys.lock().unwrap().contains_key(&(base.revision, base.comp)) { assert!(Instant::now() < until); std::thread::sleep(Duration::from_millis(1)); } @@ -153,7 +154,7 @@ fn disk_indicator_range_tracks_live_membership() { let mut revised = base; revised.revision += 1; - frames.content_keys.lock().unwrap().insert((revised.revision, revised.comp, 0), 5678); + frames.content_keys.lock().unwrap().insert((revised.revision, revised.comp), 5678); assert!(frames.disk_frames(&src, &revised, 10..13, &opts).is_empty()); let changed = FrameKey { scale: base.scale + 1, ..base }; @@ -211,7 +212,7 @@ fn retirement_reaches_existing_workers_and_keeps_cpu_frames_and_completion_live( let next = key(1, 1, 0); frames.inflight.lock().unwrap().insert(next); frames.queue.lock().unwrap().running.insert(next); - worker.finish_key(next, false, img(), web_time::Instant::now()); + worker.finish_key(next, false, img(), None, None, web_time::Instant::now()); assert!(frames.is_cached(&next), "CPU retry can still publish"); assert_eq!(frames.inflight(), 0); assert!(frames.queue.lock().unwrap().running.is_empty()); @@ -996,7 +997,7 @@ fn the_frames_an_edit_keeps_match_a_fresh_render() { // The work area is a preview and render range: setting it (B, N) keeps every frame, on // disk too. let n = s.project.comp(comp).map(|c| c.frame_rate.frame_at(c.duration)).unwrap(); - let disk = |s: &Session| effectcraft_engine::render::disk_cache::comp_content_key_with(&s.project, comp, None); + let disk = |s: &Session| effectcraft_engine::render::disk_cache::comp_content_key(&s.project, comp); let d0 = disk(&s); let work_area = |start: f64, end: f64| json!({"cmd": "comp.workArea", "params": {"comp": comp.0, "start": start, "end": end}}); assert_eq!(kept(&mut s, comp, work_area(1.0, 4.0)), (0..n).collect::>()); @@ -1593,6 +1594,71 @@ fn half_float_ram_frames_missing_on_disk_are_written() { ram_frames_missing_on_disk_are_written_as(true); } +/// A RAM frame whose render started before Reload Footage dropped one of its files (#3184) +/// holds the pixels from before: writing it later to the disk cache (an idle pass after the +/// drop) would bring them back, also after a restart, since the file kept its size and time. It +/// is written only with the drop generation its render started at, for the cache it started +/// with: so it is not written, nor is a frame rendered without that cache. Frames of other files, +/// and frames rendered after the drop, are written. +#[test] +fn ram_frames_from_before_a_drop_are_not_written() { + use effectcraft_engine::Session; + use effectcraft_engine::render::reads::{FileStamp, ReadFile}; + use serde_json::json; + let dir = tempfile::tempdir().unwrap(); + let disk = DiskCache::open(dir.path().join("cache"), 1 << 30).unwrap(); + let other = DiskCache::open(dir.path().join("other"), 1 << 30).unwrap(); + let mut s = Session::default(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap()); + s.execute("layer.newSolid", json!({})).unwrap(); + let src = RenderSource { + project: s.project.clone(), + footage: Arc::new(effectcraft_engine::render::NoFootage), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: Some(disk.clone()), + }; + let f = Frames::default(); + let series = FrameKey { revision: 1, content: f.content_of(&src.project, 1, cid), comp: cid.0, opts: opts_hash(&RenderOpts::default()), ..key(0, 0, 0) }; + let read = |p: &str| Some(Arc::new([ReadFile::new(p, FileStamp::Native { len: 3, modified: Some(1) })].into_iter().collect::())); + let before = DiskSince::of(&disk, disk.drop_generation()); + { + let mut c = f.cache.lock().unwrap(); + c.insert_with(FrameKey { frame: 0, ..series }, img(), read("/a.png"), Some(before.clone())); + c.insert_with(FrameKey { frame: 1, ..series }, img(), read("/b.png"), Some(before)); + c.insert_with(FrameKey { frame: 2, ..series }, img(), read("/a.png"), None); + c.insert_with(FrameKey { frame: 3, ..series }, img(), read("/a.png"), Some(DiskSince::of(&other, other.drop_generation()))); + } + // Reload Footage of a.png. + disk.drop_files(["/a.png"]); + disk.flush(); + f.cache.lock().unwrap().insert_with(FrameKey { frame: 4, ..series }, img(), read("/a.png"), Some(DiskSince::of(&disk, disk.drop_generation()))); + let on_disk = |fr: i64| { + disk.contains( + disk_cache::Kind::Frame, + frame_disk_key(&f.content_keys, &src.project, &FrameKey { frame: fr, ..series }, opts_hash(&RenderOpts::default())), + ) + }; + // Idle passes until there is nothing left to write. + let mut done = false; + for _ in 0..1000 { + let more = f.persist_missing(&src, &series, &RenderOpts::default()); + disk.flush(); + if !more && f.persisted.lock().unwrap().is_empty() { + done = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(done, "the idle passes end"); + assert!(on_disk(1) && on_disk(4), "another file's frame, and a frame rendered after the drop"); + assert!(!on_disk(0), "made of a.png as it was before the drop"); + assert!(!on_disk(2) && !on_disk(3), "rendered without this disk cache"); + assert_eq!(disk.stats().entries, 2); +} + fn ram_frames_missing_on_disk_are_written_as(half: bool) { use effectcraft_engine::Session; use serde_json::json; @@ -1616,19 +1682,12 @@ fn ram_frames_missing_on_disk_are_written_as(half: bool) { FrameKey { revision: 1, content: f.content_of(&src.project, 1, cid), comp: cid.0, opts: opts_hash(&RenderOpts::default()) ^ 1, ..key(0, 0, 0) }; for fr in 0..10 { let frame = if half { FrameImage::CpuF16(Arc::new(F16Frame::from_image(&effectcraft_engine::render::Image::new(8, 8), SRGB))) } else { img() }; - f.cache.lock().unwrap().insert(FrameKey { frame: fr, ..series }, frame); + f.cache.lock().unwrap().insert_with(FrameKey { frame: fr, ..series }, frame, Some(Arc::default()), Some(DiskSince::of(&disk, disk.drop_generation()))); } let on_disk = |fr: i64| { disk.contains( disk_cache::Kind::Frame, - frame_disk_key( - &f.content_keys, - &src.project, - &FrameKey { frame: fr, ..series }, - opts_hash(&RenderOpts::default()), - f.virtual_epoch.load(Ordering::Relaxed), - None, - ), + frame_disk_key(&f.content_keys, &src.project, &FrameKey { frame: fr, ..series }, opts_hash(&RenderOpts::default())), ) }; let persist = || { @@ -1674,6 +1733,71 @@ fn ram_frames_missing_on_disk_are_written_as(half: bool) { let _ = std::fs::remove_dir_all(&dir); } +/// While free disk space holds the disk cache at 0, frames are not converted or compressed for +/// it (the frame workers spent about 8 ms per frame on writes that were then refused), and idle +/// passes don't keep converting the RAM frames; with room again they are written. +#[test] +fn no_frame_is_encoded_while_the_disk_has_no_room() { + use effectcraft_engine::Session; + use serde_json::json; + const GB: u64 = 1 << 30; + let dir = std::env::temp_dir().join(format!("ec-persist-full-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let disk = DiskCache::open(&dir, 20 * GB).unwrap(); + // 4 TB disk, 1 GB free: under the 10 GB reserve. + disk.set_disk_space(Some((4096 * GB, GB))); + assert!(!disk.accepts_writes()); + // The frame worker's write: refused up front. + assert!(!put_disk_frame(&disk, 1, &img(), &ReadFiles::default(), None, disk.drop_generation())); + let mut s = Session::default(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap()); + s.execute("layer.newSolid", json!({})).unwrap(); + let src = RenderSource { + project: s.project.clone(), + footage: Arc::new(effectcraft_engine::render::NoFootage), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: Some(disk.clone()), + }; + let f = Frames::default(); + let series = + FrameKey { revision: 1, content: f.content_of(&src.project, 1, cid), comp: cid.0, opts: opts_hash(&RenderOpts::default()) ^ 1, ..key(0, 0, 0) }; + for fr in 0..10 { + f.cache.lock().unwrap().insert_with(FrameKey { frame: fr, ..series }, img(), Some(Arc::default()), Some(DiskSince::of(&disk, disk.drop_generation()))); + } + // Idle passes: nothing to do, nothing converted. + for _ in 0..20 { + assert!(!f.persist_missing(&src, &series, &RenderOpts::default()), "no idle passes while there is no room"); + std::thread::sleep(std::time::Duration::from_millis(2)); + } + disk.flush(); + assert!(f.persisted.lock().unwrap().is_empty()); + assert_eq!((disk.encodes(), disk.stats().entries), (0, 0)); + // Room again: the RAM frames are written. + disk.set_disk_space(Some((4096 * GB, 500 * GB))); + let on_disk = |fr: i64| { + disk.contains( + disk_cache::Kind::Frame, + frame_disk_key(&f.content_keys, &src.project, &FrameKey { frame: fr, ..series }, opts_hash(&RenderOpts::default())), + ) + }; + let mut written = false; + for _ in 0..1000 { + f.persist_missing(&src, &series, &RenderOpts::default()); + disk.flush(); + if (0..10).all(on_disk) && f.persisted.lock().unwrap().is_empty() { + written = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(written, "every RAM frame written once there is room"); + assert_eq!(disk.encodes(), 10); + let _ = std::fs::remove_dir_all(&dir); +} + /// Audio changes pixels only where something draws it: a clip's Audio Levels, audio effects and /// Audio switch keep every frame, in RAM and on disk, until a waveform picks the clip (on another /// layer, on itself, or through a precomp of it); then the frames of the reader render again. @@ -1681,7 +1805,7 @@ fn ram_frames_missing_on_disk_are_written_as(half: bool) { fn audio_edits_keep_the_frames_until_something_draws_the_audio() { use effectcraft_engine::Session; use effectcraft_engine::project::{Footage, GroupKind, LayerId, Value, build}; - use effectcraft_engine::render::disk_cache::comp_content_key_with; + use effectcraft_engine::render::disk_cache::comp_content_key; use serde_json::json; let mut s = Session::default(); let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 4, "frameRate": 30})).unwrap()["comp"].as_u64().unwrap()); @@ -1736,7 +1860,7 @@ fn audio_edits_keep_the_frames_until_something_draws_the_audio() { let mut p1 = p0.clone(); edit(Arc::make_mut(&mut p1)); let id1 = f.content_of(&p1, 2, view); - (f.cached_frames(&k(id1, 0)), comp_content_key_with(&p0, view, None) == comp_content_key_with(&p1, view, None)) + (f.cached_frames(&k(id1, 0)), comp_content_key(&p0, view) == comp_content_key(&p1, view)) }; let all: Vec = (0..120).collect(); let edits: [(&str, &dyn Fn(&mut Project)); 3] = [("levels", &levels), ("audio effect", &backwards), ("audio switch", &mute)]; @@ -1933,9 +2057,9 @@ fn disk_frames_outlive_an_edit_that_cant_change_them() { disk: Some(disk.clone()), }; // Every frame of `p0` on disk, none in RAM. - let content0 = disk_cache::comp_content_key_with(&p0, cid, None); + let content0 = disk_cache::comp_content_key(&p0, cid); for f in 0..30 { - disk.put_frame_now(disk_cache::frame_key(content0, f, 1000, oh), 1, 1, &[1, 2, 3, 255]); + disk.put_frame_now(disk_cache::frame_key(content0, f, 1000, oh), 1, 1, &[1, 2, 3, 255], &ReadFiles::default()); } let rename: fn(&mut Project, ItemId, LayerId) = |p, c, l| p.comp_mut(c).unwrap().layer_mut(l).unwrap().name = "Renamed".into(); let trim: fn(&mut Project, ItemId, LayerId) = |p, c, l| { @@ -1982,6 +2106,80 @@ fn disk_frames_outlive_an_edit_that_cant_change_them() { let _ = std::fs::remove_dir_all(&dir); } +/// With expressions the disk key covers what they can read (#3184, #1492): an edit to a comp no +/// expression names keeps every frame on disk under the same key; an edit to the comp one names +/// gives the comp a new key, and the frames outside the layer with that expression are carried to +/// it (the dirty spans of #2795). +#[test] +fn disk_frames_outlive_edits_their_expressions_cant_read() { + use effectcraft_engine::Session; + use effectcraft_engine::project::{Expression, LayerId, Value}; + use serde_json::json; + let dir = std::env::temp_dir().join(format!("ec-disk-carry-expr-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let disk = DiskCache::open(&dir, 1 << 30).unwrap(); + let mut s = Session::default(); + let comp = |s: &mut Session, name: &str| { + ItemId(s.execute("comp.new", json!({"name": name, "width": 8, "height": 8, "duration": 1, "frameRate": 30})).unwrap()["comp"].as_u64().unwrap()) + }; + let solid = |s: &mut Session, c: ItemId| LayerId(s.execute("layer.newSolid", json!({"comp": c.0, "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap()); + let cid = comp(&mut s, "A"); + let (a, b) = (solid(&mut s, cid), solid(&mut s, cid)); + let other = comp(&mut s, "Other"); + let o = solid(&mut s, other); + let third = comp(&mut s, "Third"); + let t = solid(&mut s, third); + let fr = s.project.comp(cid).unwrap().frame_rate; + let mut p0 = (*s.project).clone(); + for (l, i, out) in [(a, 0, 15), (b, 15, 30)] { + let l = p0.comp_mut(cid).unwrap().layer_mut(l).unwrap(); + (l.start_time, l.in_point, l.out_point) = (fr.tick_of(i), fr.tick_of(i), fr.tick_of(out)); + } + p0.comp_mut(cid).unwrap().layer_mut(a).unwrap().props.prop_mut("transform/opacity").unwrap().expr = + Some(Expression { text: "comp(\"Other\").layer(1).transform.opacity".into(), enabled: true }); + let p0 = Arc::new(p0); + let opts = RenderOpts::default(); + let oh = opts_hash(&opts); + let src = |p: &Arc| RenderSource { + project: p.clone(), + footage: Arc::new(effectcraft_engine::render::NoFootage), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: Some(disk.clone()), + }; + let content0 = disk_cache::comp_content_key(&p0, cid); + for f in 0..30 { + disk.put_frame_now(disk_cache::frame_key(content0, f, 1000, oh), 1, 1, &[1, 2, 3, 255], &ReadFiles::default()); + } + let opacity = |p: &mut Project, c: ItemId, l: LayerId| { + p.comp_mut(c).unwrap().layer_mut(l).unwrap().props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(40.0) + }; + for (name, edited, layer, same_key, kept) in [("Third", third, t, true, (0..30).collect::>()), ("Other", other, o, false, (17..30).collect())] { + let f = Frames::default(); + let series = |p: &Arc, rev: u64| FrameKey { revision: rev, content: f.content_of(p, rev, cid), comp: cid.0, opts: oh, ..key(0, 0, 0) }; + let blue = |p: &Arc, rev: u64, want: &[i64]| { + let base = series(p, rev); + let mut got = vec![]; + for _ in 0..1000 { + got = f.disk_frames(&src(p), &base, 0..30, &opts); + if got == want { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + got + }; + assert_eq!(blue(&p0, 1, &(0..30).collect::>()), (0..30).collect::>(), "{name}: before"); + let mut p1 = p0.clone(); + opacity(Arc::make_mut(&mut p1), edited, layer); + assert_eq!(disk_cache::comp_content_key(&p1, cid) == content0, same_key, "{name}: the key"); + assert_eq!(blue(&p1, 2, &kept), kept, "{name}: after"); + } + let _ = std::fs::remove_dir_all(&dir); +} + /// A keyframe edit re-renders only around the keys it changed: two keys either side (auto-Bezier /// and continuous tangents reach the neighbouring segments), and from the layer's in point when /// the first key changes. A static value or an expression still reaches the whole layer. Every @@ -2041,7 +2239,7 @@ fn a_keyframe_edit_reaches_only_the_keys_around_it() { fn music_volume_keeps_the_frames_with_expressions_that_dont_read_audio() { use effectcraft_engine::Session; use effectcraft_engine::project::{Expression, Footage, FootageKind, LayerId, Value, build}; - use effectcraft_engine::render::disk_cache::comp_content_key_with; + use effectcraft_engine::render::disk_cache::comp_content_key; use serde_json::json; let mut s = Session::default(); let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 4, "frameRate": 30})).unwrap()["comp"].as_u64().unwrap()); @@ -2069,7 +2267,7 @@ fn music_volume_keeps_the_frames_with_expressions_that_dont_read_audio() { let mut p1 = p0.clone(); Arc::make_mut(&mut p1).comp_mut(cid).unwrap().layer_mut(music_id).unwrap().props.prop_mut("audio/levels").unwrap().value = Value::Vec2([-6.0, -6.0]); let id1 = f.content_of(&p1, 2, cid); - (f.cached_frames(&k(id1, 0)).len(), comp_content_key_with(&p0, cid, None) == comp_content_key_with(&p1, cid, None)) + (f.cached_frames(&k(id1, 0)).len(), comp_content_key(&p0, cid) == comp_content_key(&p1, cid)) }; assert_eq!(kept("wiggle(2, 20)"), (120, true), "a wiggle reads no audio"); let (frames, disk) = kept("value + thisComp.layer(\"Music\")(\"Audio\")(\"Audio Levels\")[0]"); @@ -2449,3 +2647,313 @@ fn a_keyframe_edit_beside_muted_keys_reaches_the_keys_that_play() { assert!(same(i), "kept frame {i} renders differently after the edit"); } } + +/// Disk-cache frame entries that keep the source files they were made of (#3184): a sequence +/// comp in a folder of its own, with a large unused sequence in the project. +mod frame_files { + use super::*; + use effectcraft_engine::project::{Footage, FootageKind, build}; + use effectcraft_engine::time::FrameRate; + use std::path::{Path, PathBuf}; + use std::sync::atomic::AtomicUsize; + + const RATE: FrameRate = FrameRate::FPS_30; + const FRAMES: i64 = 5; + + /// A fresh folder for test `name` (on the build drive, not the system temp folder), deleted + /// when the guard is dropped. + fn folder(name: &str) -> tempfile::TempDir { + let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target"); + std::fs::create_dir_all(&target).unwrap(); + tempfile::Builder::new().prefix(&format!("test-frame-files-{name}-")).tempdir_in(target).unwrap() + } + + /// A flat grey PNG of `level` at `path`. + fn png(path: &Path, level: u8) -> String { + image::RgbaImage::from_pixel(16, 8, image::Rgba([level, level, level, 255])).save(path).unwrap(); + path.to_string_lossy().to_string() + } + + /// The grey level of sequence frame `i` as first written. + fn level(i: i64) -> u8 { + 20 + i as u8 * 30 + } + + /// A project with a comp showing a sequence of [`FRAMES`] PNGs (in `dir/seq`) and, unused, a + /// sequence of 5000 frames that don't even exist. The files of the shown sequence. + fn project(dir: &Path) -> (Arc, ItemId, Vec) { + let seq_dir = dir.join("seq"); + std::fs::create_dir_all(&seq_dir).unwrap(); + let files: Vec = (0..FRAMES).map(|i| png(&seq_dir.join(format!("f_{i:04}.png")), level(i))).collect(); + let mut seq = Footage { kind: FootageKind::Sequence, sequence: files.clone(), frame_rate: RATE, ..effectcraft_media::probe(&files[0]).unwrap() }; + seq.sync_sequence_duration(); + let unused = Footage { + kind: FootageKind::Sequence, + sequence: (0..5000).map(|i| dir.join("unused").join(format!("big_{i:05}.exr")).to_string_lossy().to_string()).collect(), + frame_rate: RATE, + width: 16, + height: 8, + has_video: true, + ..Default::default() + }; + let mut p = Project::default(); + p.add_item("Unused", effectcraft_engine::color::Label::Aqua, None, ItemKind::Footage(unused)); + let item = p.add_item("Seq", effectcraft_engine::color::Label::Aqua, None, ItemKind::Footage(seq)); + let mut comp = Comp::new(16, 8, RATE, RATE.tick_of(FRAMES)); + comp.layers = vec![build::layer(&mut p, &comp, "Seq", LayerSource::Footage { item }, (16, 8), Some(comp.duration))].into(); + let cid = p.add_item("C", effectcraft_engine::color::Label::None, None, ItemKind::Comp(comp.into())); + (Arc::new(p), cid, files) + } + + /// A session over the disk cache in `dir`: a new preview cache, media pool, layer cache and + /// disk cache instance (as after reopening the project), counting the calls of the hook for + /// files served from memory. + struct Session { + frames: Frames, + src: RenderSource, + disk: Arc, + hook_calls: Arc, + /// The session's media pool (`src.footage`): what it decoded and opened. + pool: Arc, + } + + fn session(p: &Arc, dir: &Path) -> Session { + let disk = DiskCache::open(dir.join("cache"), 1 << 30).unwrap(); + let hook_calls = Arc::new(AtomicUsize::new(0)); + let mut frames = Frames::default(); + let calls = hook_calls.clone(); + frames.set_virtual_id(Arc::new(move |_| { + calls.fetch_add(1, Ordering::Relaxed); + None + })); + let pool = Arc::new(effectcraft_media::MediaPool::new()); + let src = RenderSource { + project: p.clone(), + footage: pool.clone(), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: Some(disk.clone()), + }; + Session { frames, src, disk, hook_calls, pool } + } + + impl Session { + fn key(&self, cid: ItemId, frame: i64) -> FrameKey { + FrameKey { + revision: 1, + content: self.frames.content_of(&self.src.project, 1, cid), + comp: cid.0, + frame, + scale: 1000, + view: 0, + opts: opts_hash(&RenderOpts::default()), + } + } + + /// Show `frames` of `cid` (rendered or read from disk); the grey level of each, once + /// their disk writes are done. + fn show(&self, cid: ItemId, frames: &[i64]) -> Vec { + for &f in frames { + self.frames.request(&self.src, self.key(cid, f), cid, RATE.tick_of(f), RenderOpts::default()); + } + wait_for_renders(&self.frames); + // (the worker queues the write after it has released the frame) + for _ in 0..400 { + self.disk.flush(); + if frames.iter().all(|f| self.disk.contains(disk_cache::Kind::Frame, self.disk_key(cid, *f))) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + frames + .iter() + .map(|f| match self.frames.get(&self.key(cid, *f)) { + Some(FrameImage::Cpu(c)) => c.pixels[0].r(), + _ => panic!("frame {f}: an 8-bit CPU frame"), + }) + .collect() + } + + fn disk_key(&self, cid: ItemId, frame: i64) -> u128 { + frame_disk_key(&self.frames.content_keys, &self.src.project, &self.key(cid, frame), opts_hash(&RenderOpts::default())) + } + + /// (disk hits, disk misses) so far. + fn hits(&self) -> (u64, u64) { + let s = self.disk.stats(); + (s.hits, s.misses) + } + } + + fn close(a: u8, b: u8) -> bool { + a.abs_diff(b) <= 2 + } + + /// Give `path` a modification time `secs` later, contents and size unchanged. + fn touch(path: &str, secs: u64) { + let t = std::fs::metadata(path).unwrap().modified().unwrap() + std::time::Duration::from_secs(secs); + std::fs::File::options().append(true).open(path).unwrap().set_modified(t).unwrap(); + } + + /// Correct: a source file of the sequence changes (new contents and size; or only a new + /// time): exactly the frames that read it render again and their entries are replaced; the + /// others come from disk, also after reopening. + #[test] + fn a_changed_source_file_re_renders_exactly_its_frames() { + let tmp = folder("correct"); + let dir = tmp.path(); + let (p, cid, files) = project(dir); + let all: Vec = (0..FRAMES).collect(); + let first = session(&p, dir); + let shown = first.show(cid, &all); + assert!(all.iter().all(|f| close(shown[*f as usize], level(*f))), "{shown:?}"); + assert_eq!(first.hits(), (0, FRAMES as u64), "rendered, nothing on disk yet"); + assert_eq!(first.disk.stats().entries, FRAMES as usize, "every frame stored"); + drop(first); + // Frame 2's file gets new pixels (and size), frame 3's only a new time. + png(Path::new(&files[2]), 250); + touch(&files[3], 30); + let second = session(&p, dir); + let shown = second.show(cid, &all); + assert!(close(shown[2], 250), "frame 2 shows its new file: {shown:?}"); + assert!([0, 1, 3, 4].iter().all(|f| close(shown[*f as usize], level(*f))), "{shown:?}"); + assert_eq!(second.hits(), (3, 2), "frames 0, 1 and 4 from disk; 2 and 3 rendered again"); + assert_eq!(second.disk.file_checks(), FRAMES as u64, "one file checked per frame read"); + assert_eq!(second.disk.stats().entries, FRAMES as usize, "the entries of 2 and 3 were replaced"); + drop(second); + // Reopened: every frame from disk, the replaced ones with their new pixels. + let third = session(&p, dir); + let shown = third.show(cid, &all); + assert_eq!(third.hits(), (FRAMES as u64, 0)); + assert!(close(shown[2], 250) && close(shown[3], level(3)), "{shown:?}"); + } + + /// Reopened (a new preview cache, media pool, layer cache and disk cache on the same folder): + /// the frame comes from disk, its one source file is checked (a `metadata` call), and no + /// source file is opened or decoded for it. + #[test] + fn a_reopened_project_serves_its_frame_from_disk_without_opening_a_source_file() { + let tmp = folder("reopen"); + let dir = tmp.path(); + let (p, cid, _) = project(dir); + let first = session(&p, dir); + let shown = first.show(cid, &[1]); + assert!(close(shown[0], level(1)), "{shown:?}"); + assert!(first.pool.stats().misses > 0, "the first session decodes the frame's file: {:?}", first.pool.stats()); + assert!(first.disk.contains(disk_cache::Kind::Frame, first.disk_key(cid, 1))); + drop(first); + let again = session(&p, dir); + let shown = again.show(cid, &[1]); + assert!(close(shown[0], level(1)), "{shown:?}"); + assert_eq!(again.hits(), (1, 0), "served from disk"); + assert_eq!(again.disk.file_checks(), 1, "its one file checked"); + assert_eq!(again.pool.stats(), effectcraft_media::PoolStats::default(), "no source file opened or decoded"); + } + + /// No scan: opening a comp of a project that also holds a large sequence, and showing a + /// frame, looks only at the files that frame reads; the comp's key looks at no file (it + /// stays the same when its files change, and never asks for the identity of a path). + #[test] + fn showing_a_frame_checks_only_the_files_it_reads() { + let tmp = folder("no-scan"); + let dir = tmp.path(); + let (p, cid, files) = project(dir); + let key = disk_cache::comp_content_key(&p, cid); + png(Path::new(&files[1]), 99); + touch(&files[2], 5); + assert_eq!(disk_cache::comp_content_key(&p, cid), key, "the key looks at no file"); + let first = session(&p, dir); + first.show(cid, &[2]); + drop(first); + let again = session(&p, dir); + // (the indicator's key is computed off the UI thread first) + let mut blue = vec![]; + for _ in 0..400 { + blue = again.frames.disk_frames(&again.src, &again.key(cid, 0), 0..FRAMES, &RenderOpts::default()); + if !blue.is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(blue, vec![2], "the blue bar: present entries, no file looked at"); + assert_eq!(again.disk.file_checks(), 0, "the indicator checks no file"); + let shown = again.show(cid, &[2]); + assert!(close(shown[0], level(2)), "{shown:?}"); + assert_eq!(again.hits(), (1, 0)); + assert_eq!(again.disk.file_checks(), 1, "only the file frame 2 read"); + assert_eq!(again.hook_calls.load(Ordering::Relaxed), 0, "no path of the unused sequence was asked about"); + } + + /// The frame entry files under the cache folder. + fn entries(dir: &Path) -> Vec { + let mut out = vec![]; + let mut stack = vec![dir.join("cache")]; + while let Some(d) = stack.pop() { + for e in std::fs::read_dir(&d).into_iter().flatten().flatten() { + let p = e.path(); + if p.is_dir() { + stack.push(p); + } else if p.extension().is_some_and(|x| x == "ecc") && p.to_string_lossy().contains("frames") { + out.push(p); + } + } + } + out + } + + /// Safe: a missing source file, an unreadable one (a folder in its place) and a damaged + /// cache entry never serve the frame from before; each is rendered again and stored anew. + #[test] + fn missing_or_unreadable_files_and_damaged_entries_never_serve_a_stale_frame() { + let tmp = folder("safe"); + let dir = tmp.path(); + let (p, cid, files) = project(dir); + let first = session(&p, dir); + first.show(cid, &[0]); + assert_eq!(first.disk.stats().entries, 1); + drop(first); + // Missing: not served; the frame is rendered without it. + std::fs::remove_file(&files[0]).unwrap(); + let missing = session(&p, dir); + let shown = missing.show(cid, &[0]); + assert!(!close(shown[0], level(0)), "the frame from before was served: {shown:?}"); + assert_eq!(missing.hits(), (0, 1)); + drop(missing); + // Back, with other pixels: the entry made while it was missing is not served either. + png(Path::new(&files[0]), 180); + let back = session(&p, dir); + let shown = back.show(cid, &[0]); + assert!(close(shown[0], 180), "{shown:?}"); + assert_eq!(back.hits(), (0, 1)); + drop(back); + // Unreadable (a folder in its place). + std::fs::remove_file(&files[0]).unwrap(); + std::fs::create_dir(&files[0]).unwrap(); + let unreadable = session(&p, dir); + let shown = unreadable.show(cid, &[0]); + assert!(!close(shown[0], 180), "the frame from before was served: {shown:?}"); + assert_eq!(unreadable.hits(), (0, 1)); + drop(unreadable); + std::fs::remove_dir(&files[0]).unwrap(); + png(Path::new(&files[0]), 120); + let fresh = session(&p, dir); + assert!(close(fresh.show(cid, &[0])[0], 120)); + drop(fresh); + // A damaged entry: a miss, rendered, stored again, then served. + let found = entries(dir); + assert_eq!(found.len(), 1, "{found:?}"); + let mut bytes = std::fs::read(&found[0]).unwrap(); + let n = bytes.len() / 2; + bytes[n] ^= 0x5a; + std::fs::write(&found[0], bytes).unwrap(); + let damaged = session(&p, dir); + assert!(close(damaged.show(cid, &[0])[0], 120)); + assert_eq!(damaged.hits(), (0, 1)); + drop(damaged); + let healed = session(&p, dir); + assert!(close(healed.show(cid, &[0])[0], 120)); + assert_eq!(healed.hits(), (1, 0), "the rewritten entry is used"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 795a01e1e..e84ef79dd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -197,16 +197,18 @@ pub struct Playback { pub restore_max: Option>, /// Audio-only previews: the frame under the audible sample (the current time stays put). pub audio_frame: Option, - /// The preview's sound waits for frames: while the frames ahead aren't cached, every frame - /// shows as it renders, silently; the sound starts once the rest of the preview (or what - /// fits in the RAM preview) is cached, so it never plays over skipped frames (#103), or - /// sooner once the silent preview keeps real time with the frames ahead cached (#208). + /// The preview's sound waits for frames: a preview whose start gave up on frames that weren't + /// cached shows every frame as it renders, silently, and starts the sound once the frames + /// ahead are cached while it keeps real time (#208), or the rest of the preview is. Once the + /// sound plays it goes on through frames that aren't cached, and the picture drops frames to + /// follow it (#2473). pub audio_held: bool, /// Starting a preview with sound (#947): the playhead stays on the first frame while the /// frames ahead render (the prefetch window, at most what fits in the RAM preview), then /// sound and picture start together from it. Without this the picture played on silently /// while they rendered and the sound started at the later frame, so the opening was lost. - /// A stall later on still falls back to [`Playback::audio_held`]. + /// Once the sound plays, a stall no longer stops it: the picture drops frames to follow it + /// (#2473). pub starting: bool, /// When the preview started (seconds): [`Playback::starting`] gives up after `STARTUP_LIMIT`. pub starting_since: f64, diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 0c7573c91..76a70426e 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -163,6 +163,7 @@ fn vertex_delta(app: &EffectcraftApp, layer: effectcraft_engine::project::LayerI footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let (l2c, _) = crate::panels::viewer::l2c(&ectx, l); l2c.inverse().map_or(d, |inv| { diff --git a/crates/ui-egui/src/panels/effect_controls/panel.rs b/crates/ui-egui/src/panels/effect_controls/panel.rs index 0857dfa3f..39fd4847a 100644 --- a/crates/ui-egui/src/panels/effect_controls/panel.rs +++ b/crates/ui-egui/src/panels/effect_controls/panel.rs @@ -186,6 +186,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let hdr = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0)); p.text(pos2(hdr.min.x + 10.0, hdr.center().y), Align2::LEFT_CENTER, format!("{} • {}", comp_name, layer.name), Tokens::ui(11.5), t.text_dim); diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs index 8e4f3502f..e23e11cfb 100644 --- a/crates/ui-egui/src/panels/layer_panel.rs +++ b/crates/ui-egui/src/panels/layer_panel.rs @@ -113,6 +113,7 @@ fn render_tex( footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() }); r.expr = app.session.expr.as_deref(); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 25829be7d..4edcbb31a 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -79,6 +79,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("props-scroll"), rect); let rect = scroll.content; diff --git a/crates/ui-egui/src/panels/roto_tool.rs b/crates/ui-egui/src/panels/roto_tool.rs index 194bba26a..c01a8f07e 100644 --- a/crates/ui-egui/src/panels/roto_tool.rs +++ b/crates/ui-egui/src/panels/roto_tool.rs @@ -114,6 +114,7 @@ pub fn draw_overlay( footage: None, property_overrides: &[], media_overrides: &[], + reads: None, }; let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() }); r.expr = app.session.expr.as_deref(); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 72564f112..cdeb85116 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -5,7 +5,8 @@ //! //! Automation ids: `settings.page.`, `settings.` (e.g. `settings.general.undoLevels`), //! `settings.labels..name` / `.color`, `settings.button.