From 99e24f5867e1460be9a820f1558cc1fce6134a11 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 8 Nov 2026 01:33:26 +0100 Subject: [PATCH 1/6] .aep import: image sequences without a filename prefix or with Force alphabetical order (#3213) * AEP import: read image sequences without a filename prefix and with Force alphabetical order (#3174) After Effects leaves out an empty filename prefix, so only one Utf8 (the suffix, such as `.png`) follows the alias. The single part is now placed by the first file name stored in `opti`: a name starting with it has it as the prefix, one ending with it as the suffix; without that evidence a part starting with `.` is the suffix and anything else is not read. A sequence imported with Force alphabetical order stores its files in `LIST StVc` (`StVS` with the count as a little-endian u32, then one Utf8 per file name) instead of prefix and suffix; that list now replaces the numbering. A list with a name that is not a plain file name (a separator, a colon, `.` or `..`) is not read, since the names are joined to the folder. A folder alias whose naming still cannot be read is reported as an import loss instead of silently linking the folder. docs/aep-format.md describes the empty prefix and StVc/StVS. * AEP import: a listed sequence file name with a control character such as NUL is not read (#3174) A NUL or another control character in a Force alphabetical order file name now makes the whole list unreadable, like a separator or a dot directory, so the import reports the naming instead of linking files that cannot exist. * AEP import: a sequence file list without a readable count is not read (#3174) After Effects writes the StVS count before the names. A StVc list without StVS, or with one shorter than its u32, is now reported as unreadable naming instead of taking every listed name. * Doc comments for the functions this PR touches --- crates/aep/src/container/footage.rs | 89 ++++++++++++- crates/aep/src/import/build.rs | 36 ++++-- crates/aep/tests/it/import/synthetic.rs | 161 ++++++++++++++++++++++++ docs/aep-format.md | 5 +- 4 files changed, 274 insertions(+), 17 deletions(-) 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 4ec346328..48ae1c1a5 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/tests/it/import/synthetic.rs b/crates/aep/tests/it/import/synthetic.rs index 18709f9e5..5d4d89c56 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(); diff --git a/docs/aep-format.md b/docs/aep-format.md index d6ec7fb14..99a1199f0 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -1,6 +1,6 @@ # After Effects project format (`.aep`) -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-05 · **Change:** versions: `svap` is the version that wrote the file, `head` bytes 4 to 7 the version it is written for, and what a Save a Copy As 25.x / 24.x of After Effects 2026 changes (section 1, "Versions"); before that: the stretch numerator is signed (a negative one reverses the layer, 981-983); layer styles (3.12b, 950-980); before that: footage: `sspc@118` of an image sequence is its folder's modification time (860, read-back x1-x3); before that: text: paragraph `/24`, `/35` and style `/68` are neutral for 0 and 1 (732-734, edited in place); before that: keyframes: the ease of mask path keys (599, 700), the spatial speed unit of fractional values (597, 598, 730); before that: text: character and paragraph settings, manual kerning, vertical text, `/nil` is neutral (S17: 538-578, 595, 596); before that: cameras, lights and 3D rotations (S16: the auto Position of a two-node camera is where After Effects renders it, 537; camera and light defaults read back 255, 256; Orientation `otst/otda` 488), Timewarp (529-533), an expression on an effect parameter (534); before that: text: style slot /13 is superscript / subscript (526-528; every scripted text of the corpus is superscript), wrapping box text 517; before that: effects of batch 5 (409-450, 511-516: integer sliders, checkbox defaults in `pard`, arbitrary data of Hue/Saturation and Levels, the parameters of Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow; section 3.7); before that: shape items of batch 5 (451-484, 519: ellipse, fill and stroke opacity, fill rule, group transform, shape layer anchor, trim paths, rectangle roundness, polystar fields, gradient default, nested groups, the defaults of every item; section 3.9); before that: track mattes: mode at `ldta` byte 107 and the matte layer id at byte 160 (cases 402-408, section 3.1); before that: text: automatic leading is verified (400); Exposure (`ADBE Exposure2`, cases 389-397): group start / end parameter types 13 / 14, float slider valid minimum and slider range (section 3.7); before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: the XML form `.aepx` is read into the same chunk tree (section 1, "The XML form"); the `head` save counter is a `u32` at byte 16; text documents for the importer (stroke colour, automatic leading, the font of each run, the undecoded-slot check, the text-layer Anchor Point, the Source Text record); before that: third round of verified field layouts (explicit-default semantics, type-major bezier ease, exact time stretch and in / out points, Time Remapping state, comp pixel aspect / shutter phase / display start, Point default, Layer Control None, dropdown selection, one `mkif` per mask, polystar and radial gradient records, text styles, camera and light options, marker cue data, audio and image-sequence footage) added to the typed view of `effectcraft-aep` · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-07 · **Change:** footage: an image sequence without a filename prefix stores only the suffix `Utf8`, and one imported with Force alphabetical order stores its file list in `LIST StVc` / `StVS` (section 3, footage); before that: versions: `svap` is the version that wrote the file, `head` bytes 4 to 7 the version it is written for, and what a Save a Copy As 25.x / 24.x of After Effects 2026 changes (section 1, "Versions"); before that: the stretch numerator is signed (a negative one reverses the layer, 981-983); layer styles (3.12b, 950-980); before that: footage: `sspc@118` of an image sequence is its folder's modification time (860, read-back x1-x3); before that: text: paragraph `/24`, `/35` and style `/68` are neutral for 0 and 1 (732-734, edited in place); before that: keyframes: the ease of mask path keys (599, 700), the spatial speed unit of fractional values (597, 598, 730); before that: text: character and paragraph settings, manual kerning, vertical text, `/nil` is neutral (S17: 538-578, 595, 596); before that: cameras, lights and 3D rotations (S16: the auto Position of a two-node camera is where After Effects renders it, 537; camera and light defaults read back 255, 256; Orientation `otst/otda` 488), Timewarp (529-533), an expression on an effect parameter (534); before that: text: style slot /13 is superscript / subscript (526-528; every scripted text of the corpus is superscript), wrapping box text 517; before that: effects of batch 5 (409-450, 511-516: integer sliders, checkbox defaults in `pard`, arbitrary data of Hue/Saturation and Levels, the parameters of Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow; section 3.7); before that: shape items of batch 5 (451-484, 519: ellipse, fill and stroke opacity, fill rule, group transform, shape layer anchor, trim paths, rectangle roundness, polystar fields, gradient default, nested groups, the defaults of every item; section 3.9); before that: track mattes: mode at `ldta` byte 107 and the matte layer id at byte 160 (cases 402-408, section 3.1); before that: text: automatic leading is verified (400); Exposure (`ADBE Exposure2`, cases 389-397): group start / end parameter types 13 / 14, float slider valid minimum and slider range (section 3.7); before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: the XML form `.aepx` is read into the same chunk tree (section 1, "The XML form"); the `head` save counter is a `u32` at byte 16; text documents for the importer (stroke colour, automatic leading, the font of each run, the undecoded-slot check, the text-layer Anchor Point, the Source Text record); before that: third round of verified field layouts (explicit-default semantics, type-major bezier ease, exact time stretch and in / out points, Time Remapping state, comp pixel aspect / shutter phase / display start, Point default, Layer Control None, dropdown selection, one `mkif` per mask, polystar and radial gradient records, text styles, camera and light options, marker cue data, audio and image-sequence footage) added to the typed view of `effectcraft-aep` · **Target:** Adobe After Effects 2026 What `crates/aep` ([`effectcraft-aep`](../crates/aep/README.md)) reads and writes, and the evidence for each statement. Everything here was observed on project files we generated ourselves; no @@ -279,7 +279,8 @@ Footage items beyond solids and stills (`Pin /sspc`, 222 bytes; audio and image | bits per pixel | 60 | u32 | RGBA still 32, RGB sequence 24, 0 for solids and audio | 126, 352 | V | | **sample rate** | 160 | f64 | 48 000.0 for the WAV; the same for an mp4 with sound (48 000.0 and 44 100.0 read back from AAC tracks of those rates), absent for an mp4 without (the importer takes a clip's audio from it) | 350, read-back vid-audio | V | | **file size** | 208 | u32 | bytes of the source file(s): png 149, WAV 96 044 (44 + 48 000 x 2), three png of 168 bytes 504 | 126, 350, 352 | V | -| **sequence numbering** | 172, 176, 180 | u32 x 3 | first and last frame number and the digits of a sequence: 1, 3, 3 for `seq_001.png`..`seq_003.png` (353), 101, 103, 4 for `shot_0101.exr`..`shot_0103.exr` (603); 0 for single files (604, 605, 607). The name around the number is the pair of `Utf8` chunks between `Als2` and `opti` in `Pin ` (`seq_` / `.png`, `shot_` / `.exr`), absent on single files. `Item::sequence_info`, `SequenceInfo::file_name` | 353, 603, 604, 605, 607 | V | +| **sequence numbering** | 172, 176, 180 | u32 x 3 | first and last frame number and the digits of a sequence: 1, 3, 3 for `seq_001.png`..`seq_003.png` (353), 101, 103, 4 for `shot_0101.exr`..`shot_0103.exr` (603); 0 for single files (604, 605, 607). The name around the number is the pair of `Utf8` chunks between `Als2` and `opti` in `Pin ` (`seq_` / `.png`, `shot_` / `.exr`), absent on single files. **An empty prefix is not written**: `0001.png`..`0005.png` leave one `Utf8` (`.png`) there, so a single `Utf8` is the prefix when the first file name in `opti` (byte 58: `0001.png`) starts with it and the suffix when it ends with it; without that evidence the reader takes one starting with `.` as the suffix and reads nothing else. `Item::sequence_info`, `SequenceInfo::file_name` | 353, 603, 604, 605, 607; the prefix-less sequence of issue 3174 (After Effects 25.2) | V | +| **sequence file list** (Force alphabetical order) | `Pin /LIST StVc` | `StVS` (4 bytes, `u32` LE), then `Utf8` x count | a sequence imported with Force alphabetical order has, between `Als2` and `opti`, a `LIST StVc` instead of the prefix and suffix `Utf8`s: `StVS` holds the file count as a **little-endian** `u32` (`05 00 00 00`), followed by one `Utf8` per file name in order (`shot_0001.png`..`shot_0005.png`). The numbering at @172 / @176 is then `0xFFFFFFFF` and the digits @180 0; duration and rate are stored as for a numbered sequence (5/30, 30). The list replaces the numbering; a list without a readable `StVS` count, or with a name that is not a plain file name (`/`, `\`, `:`, a control character such as NUL, `.`, `..`), is not read. Without a stored item name the importer names the item after its first listed file (an assumption: what After Effects shows was not sampled). `SequenceInfo::files`, `SequenceInfo::nth_file_name` | the alphabetical sequence of issue 3174 (After Effects 25.2) | V | | **sequence folder time** | 118 | u32 (unaligned) | seconds since 1904-01-01 UTC: the modification time of a sequence's folder when After Effects scanned it (860: 0xe6fb443a = 2026-10-19 03:51:22, the folder's and its files' time). With the folder's time After Effects keeps the stored numbering on open; with another (an older time, the first or the last file's time while the folder's differs: x1, x2 against x3) it scans the folder again and warns about a gap in a modal dialog | 860, `export-tests\sparse860\exp` x1-x3 | V | | still duration denominator | 42 | u32 | 1 for png (126, 607), 600 for jpeg (605), 100 for OpenEXR (604): a still's duration is 0 over a per-format denominator | 126, 604, 605, 607 | V | From 514e16c027cae577ee40bac5ef27790365775af6 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 8 Nov 2026 01:57:47 +0100 Subject: [PATCH 2/6] .aep import: effects without definitions borrow them from another instance (#3223) Refs #3170 (the Color Control part). Some files have no project-level effect definition list (EfdG); it was seen in 17 projects, all from inside After Effects-made Motion Graphics templates. In those, one instance per effect type keeps the full parameter definitions (parT) and every other instance of the type has an empty parT. The others were reported "without readable parameter definitions" and dropped (129 Color Controls of one template). The definition index now falls back to the parT of the first instance of the same match name that has its own (DefinitionSource::OtherInstance). Values still come from each instance's own records; a value record that borrowed definitions do not name is reported instead of vanishing. The "Color ... holds an unreadable value" report came from 0.21.1, which read only written records (tdsb 1). Reading default-state records (tdsb 3, #3126) probably fixes it, but that is not confirmed for Color Control; a synthetic test now pins a default-state colour record. --- crates/aep/src/container/effects.rs | 65 +++++- crates/aep/src/import/effects.rs | 16 ++ crates/aep/tests/it/container/robustness.rs | 6 +- crates/aep/tests/it/import/robustness.rs | 12 +- crates/aep/tests/it/import/synthetic.rs | 237 ++++++++++++++++++++ docs/aep-format.md | 2 +- 6 files changed, 322 insertions(+), 16 deletions(-) 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/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 5d4d89c56..a023bb162 100644 --- a/crates/aep/tests/it/import/synthetic.rs +++ b/crates/aep/tests/it/import/synthetic.rs @@ -553,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() { @@ -564,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/docs/aep-format.md b/docs/aep-format.md index 99a1199f0..d6eff25f8 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -461,7 +461,7 @@ The expression engine is project-wide (`LIST ExEn/Utf8`, `javascript-1.0`). API: | **default** | `pard` | 56 | by type | float slider: f64 (Slider 0, **Blurriness 25**); fixed slider and angle: 16.16 (drop shadow opacity 127.5, direction 135, distance 5, softness 0; Fill opacity 1.0 = 100 %; **Tint Amount to Tint 100, Ramp Scatter 0, Blend With Original 0**); **colour: ARGB bytes (drop shadow `ff000000` black, Fill `ffff0000` red; Ramp Start `ff000000`, End `ffffffff`; Tint Map Black To `00000000` and Map White To `00ffffff`: alpha 0, which is what After Effects reads back)**; popup: u32 index (1); **point: two 16.16 fractions of the layer size at 56 and 60 (Point Control 0.5, 0.5; Ramp Start 0.5, 0 and End 0.5, 1)** | 071, 077, 080, 214, 215, 356, 368, 383 | V | | **valid maximum** | `pard` | 128 / 108 | 16.16 / f32 | the largest valid value of a fixed slider (128: Tint Amount 100, Ramp Scatter 512, Blend With Original **1.0**) and of a float slider (108: Blurriness 30 000; **Exposure 100, Offset 2, Gamma Correction 10**). The stored scale differs per slider (below), so this is the reader's only hint at it | 356, 368, 078, 389 | V | | **float slider valid minimum, slider range** | `pard` | 104; 112 / 116 | f32; f32 x 2 | the smallest valid value (Exposure -100, Offset -2, Gamma Correction 0.1 as f32) and the range the slider covers (Exposure -4..4, Offset -0.5..0.5, Gamma 0.1..10); the slider range is repeated as `tdum` / `tduM` (f64) in each value record (391-394). @120 holds the default again as f32 (Gamma 1.0, Blurriness 25), u16 @124 the display decimals (**H**: Exposure 2, Offset 4) | 389, 391-394 | V | -| **definitions of a second instance** | top-level `LIST EfdG` | | | one `EfDf` per effect TYPE (`tdmn` = match name, then a copy of ONE instance's `sspc`, including that instance's value records); the `EfDC` count before them is a little-endian u32. When two layers carry the same effect type, only one instance keeps a full `parT`; **the other has an empty `LIST parT` (4 bytes)** but its own `tdgp` (input layer `-0000` with its own `tdpi`, the button record, and its value records). Its definitions come from the `EfdG` copy by match name, its values from its own records, never from the copy (383: the copy holds S100's Start [0.1, 0.2]; L's Ramp reads the 368 defaults). Which instance keeps the full `parT` is **?** (in 383 S100 is both first in the file and newest). The `EfDf` order follows the stack order (365 / 366); `Pefl/pjef` does not | 365, 366, 383 | V | +| **definitions of a second instance** | top-level `LIST EfdG` | | | one `EfDf` per effect TYPE (`tdmn` = match name, then a copy of ONE instance's `sspc`, including that instance's value records); the `EfDC` count before them is a little-endian u32. When two layers carry the same effect type, only one instance keeps a full `parT`; **the other has an empty `LIST parT` (4 bytes)** but its own `tdgp` (input layer `-0000` with its own `tdpi`, the button record, and its value records). Its definitions come from the `EfdG` copy by match name, its values from its own records, never from the copy (383: the copy holds S100's Start [0.1, 0.2]; L's Ramp reads the 368 defaults). Which instance keeps the full `parT` is **?** (in 383 S100 is both first in the file and newest). The `EfDf` order follows the stack order (365 / 366); `Pefl/pjef` does not. **Some files have no `EfdG` at all** (seen in 17 projects, all from inside After Effects-made Motion Graphics templates). In each of them every effect type (196 in all; 2 to 14 Color Controls per file) has exactly one instance with a full `parT` and an empty one in every other instance, and the reader takes the others' definitions from that instance (`DefinitionSource::OtherInstance`, #3170). Which instance keeps it is **?** here too | 365, 366, 383; templates | V | | popup items | `parT/pdnm/Utf8` | | text | items separated by `\|` | 077 (`Item 1\|Item 2\|Item 3`), 214 | V | | stored value | `sspc/tdgp/-NNNN` | | `tdbs` | present only for a parameter that differs from its default; `tdsb` 1, `cdat` as in 3.4. **Absent record = the `pard` default.** Some parameters are recorded in the default state (`tdsb` 3) and the record still holds the value After Effects uses: every one of the 25 such records in the corpus and the samples differs from its `pard` default and reads back as recorded (Lumetri Color's hidden controls High Dynamic Range, HDR Specular, the second Look, Legacy Tone And Saturation, Face Detection, Auto Color, Auto Intensity and SemanticAutoTone Progress, 1200; Cryptomatte's Selection Mode on, 1400). No effect record in the default state holds a value that does not count. The reader takes the value in either state (`EffectParam::recorded_value`, `EffectParam::value`); `EffectParam::stored_value` keeps to written records, and a record in any other state (no sample has one) is not read: the importer reports it as damaged data. That other effects record values so is H (no sample shows one) | see below; 1200, 1400 | V (1200, 1400), H (other effects) | From 84382487bbeaef3d45dde3e1c76a91597132ac57 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 8 Nov 2026 02:30:59 +0100 Subject: [PATCH 3/6] Disk cache: entries keep the files they read, keys from memory (#3184) (#3191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Disk cache: record the source files each frame reads Part of #3184 (step 1): renders now report which footage files they read, each with its identity (size and modification time) as read. Nothing uses the list yet: images, cache keys and cache behaviour are unchanged. - render: `reads` module with `FileStamp`, `ReadFile`, `ReadFiles` (sorted, deduplicated, bounded; marked incomplete when it can't vouch for every file) and the thread-safe `ReadLog`. `Renderer::reads` collects a frame's files; `FootageSource::recording` lets a source note what it reads. `file_stamp` uses the same identity. - Layer cache entries keep the files their buffer was made of and report them on every hit; derived entries (styles, blend space, flattened) keep their base's. Buffers from the disk cache or the browser store don't know their files yet and mark the list incomplete. - Media pool: every file read notes its identity, taken from the file it opens. Decoded frames, opened movies, GIFs, models and EXR channels keep it with the cache entry, so a frame served from memory reports its file without touching the disk. - Viewer frame worker collects each frame's list and carries it to where the disk entry is written (step 2 stores it). Co-Authored-By: Claude Opus 5.5 * Automation: confined footage passes on the files it reads Part of #3184: ConfinedFootage forwards FootageSource::recording to the source it wraps, so renders of an automation session list their files instead of coming back incomplete. The read tests also render through the viewer's CancellableFootage wrapper. Co-Authored-By: Claude Opus 5.5 * Disk cache: frame entries keep their source files, comp key from memory Part of #3184 (step 2). Fixes #3175: opening a comp no longer stamps every footage file of the project before its first frame can show. - The comp's content key (`disk_cache::comp_content_key`) is taken from the project in memory alone: no footage or proxy file is stamped, no `VirtualFileId` is asked. `comp_content_key_with` is gone; `frame_key` hashes a new entry version, so entries of the earlier format are never looked up again (LRU evicts them). - Frame entries (formats 2 and 3) store the files the frame read (`EntryFiles`: size and modification time as read; size and content hash through `VirtualFileId` for files from memory; missing files), then the pixels. Formats 0 and 1 read as misses and are deleted. - Check on hit: `get_frame`/`get_frame_f16` look only at the listed files (one metadata call each) on the frame worker. A changed, back or unreadable file, or a damaged entry, is a miss: the entry is deleted and the frame rendered and stored again. - A frame whose file list is incomplete is shown but not stored. The RAM preview keeps each frame's files, so `persist_missing` writes only frames with a complete list. - Remote frame worker: `FrameServer` records each frame's reads (`take_files`); the web worker stores them with the entry and the page checks them through the file table before using it. The browser's media epoch existed only for the old key and is removed. Export does not use frame entries. - The cache indicator still only asks whether an entry exists. Tests: entry format round trip and damage, the check per file, and in the viewer: a changed file re-renders exactly its frames (and a reopen reads the rest from disk); a frame checks only its own files in a project with a large unused sequence and the key looks at no file; missing, unreadable files and damaged entries never serve a stale frame. Co-Authored-By: Claude Opus 5.5 * Disk cache: layer entries keep their source files, no footage salt Part of #3184 (step 3 of 4). Layer buffers on disk (and in the browser's layer store) now carry the files they were made of, like frame entries since step 2, and are checked against exactly those files when used. The project-wide footage salt is gone: nothing stamps every footage file any more. - Layer entry format 4: the buffer's EntryFiles, then the pixels. A hit checks only the listed files (metadata, or the VirtualFileId hook for files from memory; Missing must still be missing); a change, a damaged entry or the old format 0 is a miss and the entry is deleted, so the re-rendered buffer replaces it. A hit reports its file list, so frames made of buffers from disk are complete and get stored again. - Buffers whose files can't all be named are not written. - Layer disk keys: layer_disk_key = hash of the layer entry version, the renderer version and the layer cache key (which names the footage items with their paths and settings, from memory). - PrefetchStore checks on use; an entry made of changed files is dropped and forgotten, so the new buffer is queued over it. FrameServer passes its virtual-file hook to its layer cache. - Removed: footage_salt(_with), file_stamp, footage_stamp, set_disk_salt, set_store_salt, Session::refresh_disk_salt, poll_disk_salt, footage_fingerprint, disk_salt, disk_salt_background, disk_salt_job, DiskSaltJob, the UI's per-frame poll, the salt in FrameServer and in the availability worker (with its cache_enabled plumbing). The disk cache is attached as soon as it is configured. - Docs: architecture.md, web.md. Tests: layer entries check their files (size, time, missing/back, folder, damaged, old format, incomplete); virtual files; prefetch store; media tests with a real pool across restarts (only the changed file is read, projects with the same item ids never share an entry, missing, unreadable and damaged never serve stale buffers); engine tests that the cache is attached at once and edits do no file work (#1926, #3106). Co-Authored-By: Claude Opus 5.5 * Disk cache: no-salt worker test, sequence paths in the frame-blend key Part of #3184 (step 3 of 4). - A frame-blended footage frame's layer-cache key now hashes the sequence's file list: with no project-wide salt, two projects whose items share an id and a first frame must not share its disk entry. - Test: a frame worker syncing, patching and rendering a project with an unused 5000-frame sequence asks its file hook only about the file its buffer reads (the salt asked about every file at every sync). Co-Authored-By: Claude Opus 5.5 * Disk cache: the comp key covers what its expressions can read With an expression anywhere in the project, every comp's disk key hashed every item of the project, so any edit (another comp, an unused footage item) threw away every comp's frames on disk. The key now covers what the comp can draw plus what the expressions among it can reach (Project::pixel_reach): - without expressions: the items the comp draws, without silent layers, plus the footage its precomp layers swap in (Media Replacement); - with expressions: every layer of the comps they reach, their markers, the comps and footage they name with string literals (comp("Name"), footage("Name")) together with which items have those names now, and the footage named like a reached source (its data is read by name); - the whole project when an expression computes a name, uses the object model's constructors or internals, dynamic code, computed keys or writes, or text the scanner can't read for sure (regex literals, template strings, escapes): expression_reach errs towards reaching more. Markers count only where an expression can read them (#1083). Memory only, no file is looked at. Tests: scanner and reach unit tests in the project crate; engine tests for the key (own-comp expressions keep the key through edits elsewhere, comp("Other") follows Other and not a third comp, a second comp taking the name counts, a computed name counts every item, markers read from another comp); a ui-egui test that the disk frames outside an expression layer are carried when the comp it names changes. Part of #3184. Part of #1492. Co-Authored-By: Claude Opus 5.5 * Disk cache: Reload Footage drops the entries of its files, docs File > Reload Footage now deletes the frame and layer entries whose file lists name the reloaded item's files (DiskCache::drop_files), so a file replaced by one with the same size and modification time, which the check on hit can't tell apart, is read again. The index knows keys, not files: the writer thread reads the files section at the start of each frame and layer entry (not the pixels) after the writes queued before it, and deletes the entries that list one of the files. Until it is done, an entry made of one of them is a miss and frames aren't linked to new keys. The entries are gone for good, so they don't come back after a restart. Automatic reloads don't drop anything: they follow a changed stamp, which the check on hit catches. The browser needs nothing (its file identities are content hashes). Removed: disk_cache::seal, which steps 2 and 3 left without callers (the tests use seal_format). Updated the module, seal_format and forget_media docs, and docs/architecture.md / docs/web.md describe the final design: what is cached, keys from memory, file lists checked on hit, nothing scans the project, incomplete lists stay in memory, Reload Footage drops entries, edits keep the frames they can't change. Tests: render unit tests (same size and time passes the check but not the drop; other files' entries stay; an entry queued before the drop is dropped; a layer entry too; written again afterwards it is used; the drop survives a restart; a pending drop is a miss), and an engine test through file.reloadFootage. Closes #3184. Part of #1492. Co-Authored-By: Claude Opus 5.5 * Disk cache: file stamps keep the whole modification time A file's modification time was nanoseconds since 1970 as u64: a time before 1970 read as "no time" (two such files of one size looked alike) and times after 2554 were truncated. It is now i128 nanoseconds, negative before the epoch; None only means the file system has no time. The content-key hash writes a presence tag and the full value. Co-Authored-By: Claude Opus 5.5 * Automation: a refused footage read leaves the render's file list incomplete Footage outside the read roots shows nothing and was noted as no file at all, so a picture made without it had a complete, empty list and could be kept on disk for a later session with other roots. A refused read during `recording` now marks the list incomplete. Co-Authored-By: Claude Opus 5.5 * Render: a buffer kept by another worker comes with its own files LayerCache::insert returns the files of the buffer it keeps, and Renderer::keep notes them in the render's log and returns them, so a render that uses another worker's buffer lists that buffer's files (a file changed between the two reads then has two identities and the list is incomplete). Co-Authored-By: Claude Opus 5.5 * Render: sampleImage and text fonts note the files they read EvalCtx carries the render's read log. A sampleImage render records its footage reads, keeps them with its per-thread cached buffer and notes them in the evaluating render's log on every use. Text notes the font files it is drawn with, with the identity their bytes were read with; a family that isn't installed, a fallback or missing glyph, or a font from memory leaves the list incomplete. Co-Authored-By: Claude Opus 5.5 * Disk cache: frame entries store the whole modification time A frame entry's file list stores a file's modification time as i128 nanoseconds since 1970 (negative before), as the file stamps now keep it; the frame entry format version goes to frame-entry-files-2 so entries in the old layout are not read. Co-Authored-By: Claude Opus 5.5 * Disk cache: layer entries store the whole modification time Layer entries share the frame entries' file list layout, which now stores the full i128 modification time: the layer entry format version goes to layer-entry-files-2. The module doc names the font files text is drawn with among an entry's source files, and what keeps a list incomplete. Co-Authored-By: Claude Opus 5.5 * Layer cache: buffers that read other layers are keyed by the switches passed down A layer whose effect reads another layer (Gradient Wipe, Set Matte, Blend…) gets that layer drawn by its own renderer. Through a precomp layer, Switches Affect Nested Comps, the switches inherited from the precomp layers above and motion blur on change those pixels, but the key held only the layers' own switches: a buffer made with the setting on was reused (from memory or the disk cache) with it off. Such layers now derive their key with what the renderer passes down. Co-Authored-By: Claude Opus 5.5 * Layer cache: masked buffers are keyed by the mask motion blur gate A mask with Mask Motion Blur On blurs even when the layer's own switch is off, as long as the render's gate is open (Render Settings motion blur, and with Switches Affect Nested Comps the precomp layers above). The key held the layer's own motion blur state only, so a buffer blurred with the gate open was reused with it closed (and the other way round), also from the disk cache. Masked layer, input and footprint keys now tell a closed gate apart. Co-Authored-By: Claude Opus 5.5 * Render: cached buffers list what their expressions and substituted characters read - Buffers made under `Renderer::recorded` (layer content, a Time effect's input, an adjustment layer's footprint) evaluate their masks, shapes and effects with the context that notes into the buffer's own read log, so a file an expression reads (`sampleImage`, also at shutter times mask motion blur adds and the cache key doesn't see) is in the buffer's files. - A styled layer served from the cache lists the files of the cached body and passes too, so the buffers made of it (flattened, blending space) don't get a complete list when one of them came with an incomplete one. - A character put in by Character Offset or Character Value without a relayout notes the font it is drawn with; a fallback face (or none) leaves the list incomplete. Co-Authored-By: Claude Opus 5.5 * Render: text lists the font file of a run that draws no glyph; tests clean their temp dirs An empty first paragraph draws no glyph, but in a box its face sets the first baseline that places every later line. The read list took faces only from glyphs, so it could be complete without that file. Note each style run's resolved face as well. The font-reads and confine tests use tempfile::tempdir(): a fresh directory per run, removed on panic too. Co-Authored-By: Claude Opus 5.5 * Disk cache: Reload Footage closes the gaps around its drop (#3184) - A reload while the disk cache is off is kept on the session (pending_disk_drops) and applied when the cache is switched on, before anything is read from it. - Each render takes the cache's drop generation before it reads (frame worker; layer cache at the miss). An entry listing a file dropped after that is refused under the queue's lock, so a render that was under way during the reload can't store pixels from before it. Such a frame is shown with an incomplete file list, so persist_missing skips it too. The per-file record is bounded (past it, everything older is refused). - An entry read while its drop was pending is a miss even when the writer ends the sweep between the read and the check (a stale hit found under load): the pending drops and the generation are taken before the entry is read. - Test: a reopened project serves its frame from disk with no source file opened or decoded. Co-Authored-By: Claude Opus 5.5 * Disk cache: RAM frames keep the drop generation their render started at Reload Footage empties the RAM preview, but an idle pass of persist_missing may already hold a frame from before the reload. It took the cache's drop generation when it ran, after the drop, so the old pixels could be written back and, with the file's size and time unchanged, served again after a restart. Each RAM frame now keeps the disk cache its render started with and that cache's drop generation then. persist_missing writes a frame only to that cache, with that generation, and skips it once one of its files was dropped since; a frame rendered without that cache is not written from there. Tests use tempfile directories (the layer drop test, the frame_files folders under target/). Co-Authored-By: Claude Opus 5.5 * Doc comments for the functions this PR touches * Disk cache: cap the free-space reserve at 10 GB, skip encodes the limit refuses The reserve the cache leaves free on its disk was max(2 GB, 5% of the disk) (#2551). On a 4 TB disk with 190 GB free that is 200 GB, so a cache set to 20 GB had a limit of 0: every write was refused, the cache never held anything, and only a log warning said so. The reserve is now clamp(5% of the disk, 2 GB, 10 GB). It is there to keep the cache from filling the disk, not to keep a share of a large disk free: 10 GB leaves the system room to work on any disk, a small disk still keeps 2 GB, and a 100 GB disk keeps 5 GB as before. After Effects has only a maximum cache size and no reserve; a clamp keeps the floor for small disks, where a fixed 10 GB would take most of the space. Writes the limit refuses cost no work: DiskCache::accepts_writes (one atomic read) is checked before a frame is converted (put_disk_frame) and before frames and layers are compressed (put_frame, put_frame_f16, put_layer). The frame workers spent about 8 ms per frame and 14 ms per layer on encodes that were then refused. persist_missing returns at once at limit 0 instead of converting the RAM frames on every idle pass. Settings > Disk shows the limit in effect in MB under Maximum Disk Cache Size, with a note when free disk space lowers it (settings.disk.cacheLimit). * Disk cache: refuse writes below 64 MB of room, show this disk's reserve Review follow-ups to the reserve fix: - accepts_writes also refuses while free disk space leaves less than 64 MB of room (a limit the setting asks for is always used): a few frames would fill it, and the rest would be compressed only to be refused. - While writes are refused the idle passes ask for no repaint, so the UI keeps a frame coming every 5 s: the free space is read again and the RAM frames are written once there is room. - Settings > Disk names the space kept free on this disk (the reserve the last reading applied; the rule from MIN/MAX_DISK_RESERVE when it is unknown), and the "no room" note follows accepts_writes. - The settings.disk.cacheLimit automation label is machine-readable and the same in every language: limitMb, maxMb, reserveMb, spaceLimited, writes. - cargo fmt. * Disk cache: follow 3D model sources in the comp's reach The reach walk followed comp, footage and solid sources but not LayerSource::Model. An expression reading a model layer's source (thisComp.layer("Model").source.name) left the model item out of the comp's content key, so renaming the model kept the key and the old frames on disk were served. Every source item is followed now (LayerSource::item), which also counts the model when no expression reads it (Advanced 3D draws it). The same omission is fixed in the RAM preview's content walk (frames.rs) and in the layer key's walk of referenced layers (cache/dependencies.rs). * Disk cache: a render's drop generation travels with its reads; no disk entries for Expression Selectors Drop generation per render: the layer cache recovered a buffer's drop generation from the latest miss of its key, so a second render of the key that started after File > Reload Footage lent its generation to a render still running from before, and that render's buffer (the file as it was) was written after the sweep. Now Renderer::recorded starts each buffer's read log at the store's drop generation before anything is read (ReadLog::starting_at); ReadFiles carries the earliest generation through merges, so derived buffers (styles, the blend-space copy, the flattened layer) and frames that use a buffer from memory carry it too. LayerCache::insert keeps a buffer made of a file dropped since its reads began neither on disk nor in memory; a list that doesn't say counts as made before every drop. DiskCache::dropped_since takes the list's own generation too. missed_at keeps only the miss time. Expression Selectors: the layer key evaluates a text Expression Selector's amount for the first character only, so a per-character dependency such as textIndex == 1 ? 0 : footage("ref.png").width was not in the key, and after the footage item's width changed a reopened session reused the old layer entry. A text layer with an Expression Selector now marks its reads unkeyed (ReadFiles::is_unkeyed), which merges like the generation; such buffers stay in memory and are never written to disk. Frame entries are unaffected: the comp's frame key hashes what the expressions name. The equality and hash of ReadFiles ignore both new fields. * Disk cache: never serve a RAM layer buffer made before a drop of its files A render that passed the drop check could sit in put_layer's encode while Reload Footage cleared the layer cache and dropped the file, then publish its old pixels to memory after the purge; get, and a fresh render's insert, then served them. The disk-hit promotion went through the same insert_mem. Every way the cache hands out a RAM buffer (get, the kept-first path of insert, an existing entry in insert_mem) now checks the entry's files against the store's drops from their own generation (ReadFiles::since): an entry made before a drop of one of its files is forgotten and the lookup misses, so a fresh render's buffer replaces it. insert_mem also re-checks a buffer just before publishing it. --------- Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 3 + apps/effectcraft-web/src/app.rs | 2 +- apps/effectcraft-web/src/diskcache.rs | 24 +- apps/effectcraft-web/src/frames.rs | 7 +- apps/effectcraft-web/src/store.rs | 18 +- apps/effectcraft/src/main.rs | 3 - crates/automation/Cargo.toml | 1 + crates/automation/src/confine.rs | 105 +- crates/engine/src/analysis/tracking.rs | 2 +- crates/engine/src/background/remote.rs | 53 +- crates/engine/src/background/remote/tests.rs | 138 +- crates/engine/src/cmd/effect.rs | 5 + crates/engine/src/cmd/layer/parent.rs | 1 + crates/engine/src/cmd/layer_time.rs | 1 + crates/engine/src/cmd/path_nulls.rs | 4 + crates/engine/src/cmd/prop.rs | 3 + crates/engine/src/cmd/puppet.rs | 13 +- crates/engine/src/cmd/rig3d.rs | 2 + crates/engine/src/cmd/three_d.rs | 27 +- crates/engine/src/footage/availability.rs | 43 +- crates/engine/src/footage/footage_check.rs | 2 +- crates/engine/src/footage/footage_reload.rs | 45 +- crates/engine/src/lib.rs | 17 +- crates/engine/src/session.rs | 2 +- crates/engine/src/session/edit.rs | 117 -- .../engine/src/settings/session_settings.rs | 14 +- crates/engine/src/tests/crypto_pick.rs | 1 + crates/engine/src/tests/disk_cache.rs | 352 +++-- crates/engine/src/tests/rig3d.rs | 14 +- crates/engine/src/tests/sequence.rs | 21 +- crates/expr/src/host.rs | 2 + crates/expr/src/lib.rs | 1 + crates/expr/src/sample.rs | 24 +- crates/expr/src/tests.rs | 39 + crates/gpu/src/tests/adv3d.rs | 2 + crates/host/src/tests.rs | 34 + crates/media/Cargo.toml | 4 + crates/media/src/exr_channels.rs | 84 +- crates/media/src/lib.rs | 26 +- crates/media/src/pool.rs | 202 ++- crates/media/src/reads.rs | 120 ++ crates/media/src/reads/tests.rs | 390 +++++ crates/media/src/stream.rs | 17 +- crates/project/src/lib.rs | 2 + crates/project/src/reach.rs | 405 +++++ crates/project/src/reach/tests.rs | 198 +++ crates/render/Cargo.toml | 3 + crates/render/src/accel.rs | 23 +- crates/render/src/cache.rs | 597 +++++-- crates/render/src/cache/dependencies.rs | 2 +- crates/render/src/disk_cache.rs | 1390 +++++++++++++---- crates/render/src/disk_cache/entry_files.rs | 276 ++++ crates/render/src/eval.rs | 6 +- crates/render/src/footage.rs | 19 + crates/render/src/fx_host.rs | 3 +- crates/render/src/layers.rs | 87 +- crates/render/src/lib.rs | 5 + crates/render/src/pipeline.rs | 236 ++- crates/render/src/reads.rs | 348 +++++ crates/render/src/tests.rs | 174 +++ crates/render/src/tests/collapse.rs | 13 +- crates/render/src/tests/mask_blur.rs | 29 + crates/render/src/tests/styles.rs | 31 + crates/render/src/tests/time.rs | 1 + crates/render/src/text.rs | 72 +- crates/render/src/text/tests.rs | 87 ++ crates/render/src/three_d/adv/scene.rs | 11 +- crates/render/src/three_d/adv/tests.rs | 4 + crates/render/src/three_d/compose.rs | 6 +- crates/script/src/tests/ui_more.rs | 1 + crates/text/src/fonts.rs | 33 +- crates/ui-egui/src/app/frame.rs | 4 - crates/ui-egui/src/frames.rs | 339 ++-- crates/ui-egui/src/frames/tests.rs | 548 ++++++- crates/ui-egui/src/menus/invoke.rs | 1 + .../src/panels/effect_controls/panel.rs | 1 + crates/ui-egui/src/panels/layer_panel.rs | 1 + crates/ui-egui/src/panels/properties.rs | 1 + crates/ui-egui/src/panels/roto_tool.rs | 1 + crates/ui-egui/src/panels/settings.rs | 110 +- crates/ui-egui/src/panels/text_panels.rs | 1 + crates/ui-egui/src/panels/timeline.rs | 1 + crates/ui-egui/src/panels/viewer.rs | 2 + crates/ui-egui/src/panels/viewer/shapes.rs | 2 + crates/ui-egui/src/panels/viewer/view.rs | 1 + crates/ui-egui/src/prefs_live.rs | 6 + .../ui-egui/tests/it/ui_unreadable_footage.rs | 3 +- crates/ui-egui/tests/it/ui_web_gpu_storage.rs | 9 +- docs/architecture.md | 47 +- docs/preferences.md | 7 +- docs/web.md | 15 +- 91 files changed, 5945 insertions(+), 1202 deletions(-) create mode 100644 crates/media/src/reads.rs create mode 100644 crates/media/src/reads/tests.rs create mode 100644 crates/project/src/reach.rs create mode 100644 crates/project/src/reach/tests.rs create mode 100644 crates/render/src/disk_cache/entry_files.rs create mode 100644 crates/render/src/reads.rs 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/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 079b81765..c9322ac37 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, }; 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/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/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.