mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-11 02:41:57 -06:00
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com>
2402 lines
109 KiB
Rust
2402 lines
109 KiB
Rust
//! The persistent disk cache (Settings ▸ Media & Disk Cache ▸ Disk Cache): RAM-preview frames
|
|
//! and layer cache entries kept on disk between sessions, like After Effects' disk cache.
|
|
//!
|
|
//! * Entries are files keyed by a 128-bit content hash (`<folder>/v1/<kind>/<2 hex>/<32 hex>.ecc`):
|
|
//! 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: 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.
|
|
//! * Size limit with least-recently-used eviction, down to 90% of the limit so the eviction scan
|
|
//! runs once per tenth of the limit written, not on every write. The limit shrinks to keep
|
|
//! free space on the cache's disk ([`DiskCache::set_disk_space`]). The in-memory index is
|
|
//! rebuilt from the folder when the cache opens (file modification times order it), so the
|
|
//! cache survives restarts; reads refresh a file's time.
|
|
//! * Safe concurrent access: writes go to a unique temporary file that is renamed into place
|
|
//! (atomic on every desktop OS), so readers in this or another process never see a partial
|
|
//! entry; a missing or damaged file is just a miss. Writes run on a background thread with a
|
|
//! bounded queue (a full queue drops the write rather than stalling rendering).
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
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";
|
|
/// Pending layer and matte writes before new ones are dropped.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
const QUEUE_LIMIT: usize = 64;
|
|
/// Pending frame bytes before new frames are dropped. Frames have their own lane, written
|
|
/// before layer buffers: a burst of large layer writes used to fill the shared queue and drop
|
|
/// frames, so a fully cached comp came back with gaps in its blue bar.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
const FRAME_QUEUE_BYTES: usize = 1 << 30;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
|
pub enum Kind {
|
|
Frame,
|
|
Layer,
|
|
/// A Roto Brush / Object Matte segmentation.
|
|
Matte,
|
|
}
|
|
|
|
impl Kind {
|
|
/// The kind's folder name.
|
|
pub fn dir(self) -> &'static str {
|
|
match self {
|
|
Kind::Frame => "frames",
|
|
Kind::Layer => "layers",
|
|
Kind::Matte => "mattes",
|
|
}
|
|
}
|
|
fn tag(self) -> u8 {
|
|
match self {
|
|
Kind::Frame => 1,
|
|
Kind::Layer => 2,
|
|
Kind::Matte => 3,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A cached viewer frame: 8-bit premultiplied RGBA.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct Frame8 {
|
|
pub width: u32,
|
|
pub height: u32,
|
|
pub rgba: Vec<u8>,
|
|
}
|
|
|
|
/// A cached 32 bpc viewer frame: premultiplied RGBA half floats (IEEE binary16 bits), four
|
|
/// per pixel.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct FrameF16 {
|
|
pub width: u32,
|
|
pub height: u32,
|
|
pub rgba: Vec<u16>,
|
|
}
|
|
|
|
/// The entry header's format byte: segmentations (and 8-bit frames and layer buffers before
|
|
/// #3184).
|
|
const FORMAT_RGBA8: u8 = 0;
|
|
/// 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)]
|
|
pub struct DiskStats {
|
|
pub entries: usize,
|
|
pub bytes: u64,
|
|
pub max_bytes: u64,
|
|
/// The limit in effect: `max_bytes`, or less when the disk is short of free space.
|
|
pub limit_bytes: u64,
|
|
pub hits: u64,
|
|
pub misses: u64,
|
|
pub writes: u64,
|
|
pub evictions: u64,
|
|
pub dropped: u64,
|
|
/// Writes that failed (a full disk, permissions).
|
|
pub failed: u64,
|
|
}
|
|
|
|
struct Meta {
|
|
bytes: u64,
|
|
last_use: u64,
|
|
}
|
|
|
|
/// The cache's index: entry sizes and least-recently-used order under a size limit. Portable
|
|
/// (no file system): [`DiskCache`] keeps one over its folder, and the browser keeps one over the
|
|
/// Origin Private File System (`apps/effectcraft-web`, where the files are written by workers).
|
|
#[derive(Default)]
|
|
pub struct DiskIndex {
|
|
map: HashMap<(Kind, u128), Meta>,
|
|
total: u64,
|
|
clock: u64,
|
|
}
|
|
|
|
impl DiskIndex {
|
|
/// Record an entry (`bytes` on disk) as the most recently used; replaces an older record.
|
|
pub fn insert(&mut self, kind: Kind, key: u128, bytes: u64) {
|
|
self.clock += 1;
|
|
let c = self.clock;
|
|
if let Some(old) = self.map.insert((kind, key), Meta { bytes, last_use: c }) {
|
|
self.total -= old.bytes;
|
|
}
|
|
self.total += bytes;
|
|
}
|
|
|
|
/// Mark an entry as just used (a read). `false` when it is not indexed.
|
|
pub fn touch(&mut self, kind: Kind, key: u128) -> bool {
|
|
self.clock += 1;
|
|
let c = self.clock;
|
|
match self.map.get_mut(&(kind, key)) {
|
|
Some(m) => {
|
|
m.last_use = c;
|
|
true
|
|
}
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
/// Forget an entry; its size, if it was indexed.
|
|
pub fn remove(&mut self, kind: Kind, key: u128) -> Option<u64> {
|
|
let m = self.map.remove(&(kind, key))?;
|
|
self.total -= m.bytes;
|
|
Some(m.bytes)
|
|
}
|
|
|
|
pub fn contains(&self, kind: Kind, key: u128) -> bool {
|
|
self.map.contains_key(&(kind, key))
|
|
}
|
|
|
|
pub fn len(&self) -> usize {
|
|
self.map.len()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.map.is_empty()
|
|
}
|
|
|
|
/// Bytes of every indexed entry.
|
|
pub fn total(&self) -> u64 {
|
|
self.total
|
|
}
|
|
|
|
/// Keys of one kind (unordered).
|
|
pub fn keys(&self, kind: Kind) -> Vec<u128> {
|
|
self.map.keys().filter(|k| k.0 == kind).map(|k| k.1).collect()
|
|
}
|
|
|
|
/// Entries from least to most recently used.
|
|
pub fn lru_order(&self) -> Vec<(Kind, u128)> {
|
|
let mut v: Vec<(u64, (Kind, u128))> = self.map.iter().map(|(k, m)| (m.last_use, *k)).collect();
|
|
v.sort_unstable_by_key(|e| e.0);
|
|
v.into_iter().map(|e| e.1).collect()
|
|
}
|
|
|
|
/// When the total exceeds `max` bytes, remove least recently used entries down to 90% of
|
|
/// it (so the sort runs once per tenth of `max` written, not on every write); returns them
|
|
/// (the caller deletes their files).
|
|
pub fn evict(&mut self, max: u64) -> Vec<(Kind, u128)> {
|
|
if self.total <= max {
|
|
return vec![];
|
|
}
|
|
let target = max - max / 10;
|
|
let mut out = vec![];
|
|
for k in self.lru_order() {
|
|
if self.total <= target {
|
|
break;
|
|
}
|
|
self.remove(k.0, k.1);
|
|
out.push(k);
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn clear(&mut self) {
|
|
*self = DiskIndex::default();
|
|
}
|
|
}
|
|
|
|
/// Which layer buffers a browser frame worker should fetch from the disk cache before its next
|
|
/// frame (its lookups mid-render are synchronous, the Origin Private File System is not): the
|
|
/// layer-cache misses the workers reported for recent frames, most recent first, that are on
|
|
/// disk ([`DiskIndex`], kind [`Kind::Layer`]).
|
|
#[derive(Default)]
|
|
pub struct LayerPrefetch {
|
|
recent: std::collections::VecDeque<u128>,
|
|
}
|
|
|
|
/// Misses [`LayerPrefetch`] remembers.
|
|
const PREFETCH_RECENT: usize = 512;
|
|
|
|
impl LayerPrefetch {
|
|
/// A frame's layer-cache misses (disk keys, [`layer_disk_key`]).
|
|
pub fn note_misses(&mut self, keys: impl IntoIterator<Item = u128>) {
|
|
for k in keys {
|
|
self.recent.retain(|x| *x != k);
|
|
self.recent.push_front(k);
|
|
}
|
|
self.recent.truncate(PREFETCH_RECENT);
|
|
}
|
|
|
|
/// Up to `max` keys to fetch before the next frame.
|
|
pub fn plan(&self, index: &DiskIndex, max: usize) -> Vec<u128> {
|
|
self.recent.iter().copied().filter(|k| index.contains(Kind::Layer, *k)).take(max).collect()
|
|
}
|
|
|
|
pub fn clear(&mut self) {
|
|
self.recent.clear();
|
|
}
|
|
}
|
|
|
|
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
|
enum Job {
|
|
/// Kind, key, header format byte, payload.
|
|
Write(Kind, u128, u8, Vec<u8>),
|
|
/// Delete the frame and layer entries made of these files ([`DiskCache::drop_files`]).
|
|
Drop(Vec<Arc<str>>),
|
|
}
|
|
|
|
struct Queue {
|
|
/// Frame writes (taken first) and their payload bytes.
|
|
frames: std::collections::VecDeque<Job>,
|
|
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
|
frame_bytes: usize,
|
|
/// Layer and matte writes.
|
|
jobs: std::collections::VecDeque<Job>,
|
|
busy: bool,
|
|
/// Keys of frames queued or being written.
|
|
pending_frames: std::collections::HashSet<u128>,
|
|
/// 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<Arc<str>, 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<str>]) {
|
|
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<Item = &'a Arc<str>>, 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<Job> {
|
|
if let Some(j) = self.frames.pop_front() {
|
|
if let Job::Write(_, _, _, p) = &j {
|
|
self.frame_bytes = self.frame_bytes.saturating_sub(p.len());
|
|
}
|
|
return Some(j);
|
|
}
|
|
self.jobs.pop_front()
|
|
}
|
|
fn is_empty(&self) -> bool {
|
|
self.frames.is_empty() && self.jobs.is_empty()
|
|
}
|
|
}
|
|
|
|
pub struct DiskCache {
|
|
dir: PathBuf,
|
|
index: Mutex<DiskIndex>,
|
|
max_bytes: AtomicU64,
|
|
/// The size free disk space allows (`u64::MAX` = unknown), see [`DiskCache::set_disk_space`].
|
|
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<Queue>, 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<HashMap<Arc<str>, u32>>,
|
|
}
|
|
|
|
/// FNV-1a 128-bit (content keys).
|
|
#[derive(Clone, Copy)]
|
|
pub struct Hash128(u128);
|
|
|
|
impl Default for Hash128 {
|
|
fn default() -> Self {
|
|
Hash128(0x6c62_272e_07bb_0142_62b8_2175_6295_c58d)
|
|
}
|
|
}
|
|
|
|
impl Hash128 {
|
|
pub fn write(&mut self, bytes: &[u8]) {
|
|
for &b in bytes {
|
|
self.0 ^= b as u128;
|
|
self.0 = self.0.wrapping_mul(0x0000_0000_0100_0000_0000_0000_0000_013b);
|
|
}
|
|
}
|
|
pub fn write_u64(&mut self, v: u64) {
|
|
self.write(&v.to_le_bytes());
|
|
}
|
|
pub fn finish(&self) -> u128 {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
fn checksum(data: &[u8]) -> u64 {
|
|
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
|
for &b in data {
|
|
h ^= b as u64;
|
|
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
|
}
|
|
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);
|
|
if cfg!(target_os = "macos")
|
|
&& let Some(h) = &home
|
|
{
|
|
return h.join("Library/Caches/EffectCraft/Disk Cache");
|
|
}
|
|
if cfg!(windows)
|
|
&& let Some(l) = std::env::var_os("LOCALAPPDATA")
|
|
{
|
|
return PathBuf::from(l).join("EffectCraft").join("Disk Cache");
|
|
}
|
|
if let Some(x) = std::env::var_os("XDG_CACHE_HOME") {
|
|
return PathBuf::from(x).join("effectcraft/disk-cache");
|
|
}
|
|
home.map(|h| h.join(".cache/effectcraft/disk-cache")).unwrap_or_else(|| std::env::temp_dir().join("effectcraft-disk-cache"))
|
|
}
|
|
|
|
impl DiskCache {
|
|
/// Open (or create) the cache in `folder`, holding at most `max_bytes`.
|
|
pub fn open(folder: impl AsRef<Path>, max_bytes: u64) -> std::io::Result<Arc<DiskCache>> {
|
|
let dir = folder.as_ref().join(VERSION);
|
|
std::fs::create_dir_all(&dir)?;
|
|
let dc = Arc::new(DiskCache {
|
|
dir,
|
|
index: Mutex::new(DiskIndex::default()),
|
|
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 {
|
|
frames: Default::default(),
|
|
frame_bytes: 0,
|
|
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.
|
|
#[cfg(target_arch = "wasm32")]
|
|
dc.evict();
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
let weak = Arc::downgrade(&dc);
|
|
let q = dc.queue.clone();
|
|
std::thread::Builder::new().name("ec-disk-cache".into()).spawn(move || writer(weak, q))?;
|
|
}
|
|
Ok(dc)
|
|
}
|
|
|
|
pub fn folder(&self) -> &Path {
|
|
self.dir.parent().unwrap_or(&self.dir)
|
|
}
|
|
|
|
/// Rebuild the index from the folder (oldest files first).
|
|
pub fn rescan(&self) {
|
|
let mut found: Vec<(std::time::SystemTime, Kind, u128, u64)> = vec![];
|
|
for kind in [Kind::Frame, Kind::Layer, Kind::Matte] {
|
|
let Ok(shards) = std::fs::read_dir(self.dir.join(kind.dir())) else { continue };
|
|
for shard in shards.flatten() {
|
|
let Ok(files) = std::fs::read_dir(shard.path()) else { continue };
|
|
for f in files.flatten() {
|
|
let p = f.path();
|
|
let name = p.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("");
|
|
if name.contains(".tmp") {
|
|
// A writer that died mid-write; old temporaries are removed.
|
|
if f.metadata().and_then(|m| m.modified()).is_ok_and(|t| t.elapsed().is_ok_and(|e| e.as_secs() > 600)) {
|
|
let _ = std::fs::remove_file(&p);
|
|
}
|
|
continue;
|
|
}
|
|
let Some(hex) = name.strip_suffix(".ecc") else { continue };
|
|
let Ok(key) = u128::from_str_radix(hex, 16) else { continue };
|
|
let Ok(md) = f.metadata() else { continue };
|
|
found.push((md.modified().unwrap_or(std::time::UNIX_EPOCH), kind, key, md.len()));
|
|
}
|
|
}
|
|
}
|
|
found.sort_by_key(|e| e.0);
|
|
let mut idx = DiskIndex::default();
|
|
for (_, kind, key, bytes) in found {
|
|
idx.insert(kind, key, bytes);
|
|
}
|
|
if let Ok(mut g) = self.index.lock() {
|
|
*g = idx;
|
|
}
|
|
}
|
|
|
|
fn path(&self, kind: Kind, key: u128) -> PathBuf {
|
|
let hex = format!("{key:032x}");
|
|
self.dir.join(kind.dir()).join(&hex[..2]).join(format!("{hex}.ecc"))
|
|
}
|
|
|
|
pub fn max_bytes(&self) -> u64 {
|
|
self.max_bytes.load(Ordering::Relaxed)
|
|
}
|
|
|
|
/// Change the size limit (evicts the least recently used entries when shrinking).
|
|
pub fn set_max_bytes(&self, b: u64) {
|
|
self.max_bytes.store(b, Ordering::Relaxed);
|
|
self.evict();
|
|
}
|
|
|
|
/// The limit in effect: the setting, or less when the disk is short of free space.
|
|
pub fn limit_bytes(&self) -> u64 {
|
|
self.max_bytes().min(self.disk_cap.load(Ordering::Relaxed))
|
|
}
|
|
|
|
/// 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 => {
|
|
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 {
|
|
log::warn!("disk cache at {}: little free disk space, limiting the cache to {} MB", self.folder().display(), cap >> 20);
|
|
}
|
|
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<u64> {
|
|
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);
|
|
}
|
|
|
|
pub fn min_layer_ms(&self) -> u64 {
|
|
self.min_layer_ms.load(Ordering::Relaxed)
|
|
}
|
|
|
|
pub fn contains(&self, kind: Kind, key: u128) -> bool {
|
|
self.index.lock().is_ok_and(|g| g.contains(kind, key))
|
|
}
|
|
|
|
/// A frame write is queued or in progress.
|
|
pub fn frame_pending(&self, key: u128) -> bool {
|
|
self.queue.0.lock().is_ok_and(|q| q.pending_frames.contains(&key))
|
|
}
|
|
|
|
/// Keys of one kind (unordered).
|
|
pub fn keys(&self, kind: Kind) -> Vec<u128> {
|
|
self.index.lock().map(|g| g.keys(kind)).unwrap_or_default()
|
|
}
|
|
|
|
pub fn stats(&self) -> DiskStats {
|
|
let (entries, bytes) = self.index.lock().map(|g| (g.len(), g.total())).unwrap_or_default();
|
|
DiskStats {
|
|
entries,
|
|
bytes,
|
|
max_bytes: self.max_bytes(),
|
|
limit_bytes: self.limit_bytes(),
|
|
hits: self.hits.load(Ordering::Relaxed),
|
|
misses: self.misses.load(Ordering::Relaxed),
|
|
writes: self.writes.load(Ordering::Relaxed),
|
|
evictions: self.evictions.load(Ordering::Relaxed),
|
|
dropped: self.dropped.load(Ordering::Relaxed),
|
|
failed: self.failed.load(Ordering::Relaxed),
|
|
}
|
|
}
|
|
|
|
/// Delete every entry (Edit ▸ Purge ▸ All Disk Cache / Empty Disk Cache).
|
|
pub fn clear(&self) {
|
|
self.flush();
|
|
if let Ok(mut g) = self.index.lock() {
|
|
g.clear();
|
|
}
|
|
for kind in [Kind::Frame, Kind::Layer, Kind::Matte] {
|
|
let _ = std::fs::remove_dir_all(self.dir.join(kind.dir()));
|
|
}
|
|
}
|
|
|
|
/// Wait until queued writes are on disk.
|
|
pub fn flush(&self) {
|
|
let (m, cv) = &*self.queue;
|
|
let Ok(mut q) = m.lock() else { return };
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
q.jobs.clear();
|
|
q.frames.clear();
|
|
q.pending_frames.clear();
|
|
}
|
|
while !q.is_empty() || q.busy {
|
|
q = match cv.wait(q) {
|
|
Ok(q) => q,
|
|
Err(_) => return,
|
|
};
|
|
}
|
|
}
|
|
|
|
fn read(&self, kind: Kind, key: u128) -> Option<Vec<u8>> {
|
|
self.read_entry(kind, key).map(|(_, p)| p)
|
|
}
|
|
|
|
/// An entry's payload and its header's format byte.
|
|
fn read_entry(&self, kind: Kind, key: u128) -> Option<(u8, Vec<u8>)> {
|
|
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<u8>)> {
|
|
if !self.contains(kind, key) {
|
|
return None;
|
|
}
|
|
let path = self.path(kind, key);
|
|
let data = std::fs::read(&path).ok();
|
|
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<T>(
|
|
&self,
|
|
kind: Kind,
|
|
key: u128,
|
|
format: u8,
|
|
virtual_id: Option<VirtualFileId<'_>>,
|
|
decode: impl FnOnce(&[u8]) -> Option<T>,
|
|
) -> 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.
|
|
fn discard(&self, kind: Kind, key: u128) {
|
|
let _ = std::fs::remove_file(self.path(kind, key));
|
|
self.forget(kind, key);
|
|
}
|
|
|
|
fn forget(&self, kind: Kind, key: u128) {
|
|
if let Ok(mut g) = self.index.lock() {
|
|
g.remove(kind, key);
|
|
}
|
|
}
|
|
|
|
/// Queue a write; `false` when it was dropped (a full queue).
|
|
fn enqueue(&self, kind: Kind, key: u128, payload: Vec<u8>) -> bool {
|
|
self.enqueue_format(kind, key, FORMAT_RGBA8, payload, None)
|
|
}
|
|
|
|
/// [`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<u8>, 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
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
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,
|
|
};
|
|
if full {
|
|
self.dropped.fetch_add(1, Ordering::Relaxed);
|
|
return false;
|
|
}
|
|
if kind == Kind::Frame {
|
|
q.pending_frames.insert(key);
|
|
q.frame_bytes = q.frame_bytes.saturating_add(payload.len());
|
|
q.frames.push_back(Job::Write(kind, key, format, payload));
|
|
} else {
|
|
q.jobs.push_back(Job::Write(kind, key, format, payload));
|
|
}
|
|
cv.notify_all();
|
|
true
|
|
}
|
|
}
|
|
|
|
/// Write an entry now (atomic rename), then evict down to the limit.
|
|
fn write_format(&self, kind: Kind, key: u128, format: u8, payload: &[u8]) {
|
|
if self.contains(kind, key) {
|
|
return;
|
|
}
|
|
let path = self.path(kind, key);
|
|
let Some(parent) = path.parent() else { return };
|
|
if let Err(e) = std::fs::create_dir_all(parent) {
|
|
self.write_failed(&e);
|
|
return;
|
|
}
|
|
let data = seal_format(kind, format, payload);
|
|
// An entry that cannot fit would be evicted straight away.
|
|
if data.len() as u64 > self.limit_bytes() {
|
|
return;
|
|
}
|
|
// Make room first, so the cache never exceeds its limit (or the free-space reserve).
|
|
self.evict_to(self.limit_bytes().saturating_sub(data.len() as u64));
|
|
let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed);
|
|
let tmp = parent.join(format!("{key:032x}.tmp.{}.{n}", std::process::id()));
|
|
if let Err(e) = std::fs::write(&tmp, &data).and_then(|()| std::fs::rename(&tmp, &path)) {
|
|
let _ = std::fs::remove_file(&tmp);
|
|
self.write_failed(&e);
|
|
return;
|
|
}
|
|
self.writes.fetch_add(1, Ordering::Relaxed);
|
|
if let Ok(mut g) = self.index.lock() {
|
|
g.insert(kind, key, data.len() as u64);
|
|
}
|
|
}
|
|
|
|
/// Count a failed write; the first one is logged (a full disk shouldn't go unnoticed).
|
|
fn write_failed(&self, e: &std::io::Error) {
|
|
if self.failed.fetch_add(1, Ordering::Relaxed) == 0 {
|
|
log::warn!("disk cache at {}: a write failed: {e}", self.folder().display());
|
|
}
|
|
}
|
|
|
|
/// Remove least recently used entries until the cache fits its limit.
|
|
fn evict(&self) {
|
|
self.evict_to(self.limit_bytes());
|
|
}
|
|
|
|
/// Remove least recently used entries until the cache fits `max` bytes.
|
|
fn evict_to(&self, max: u64) {
|
|
let victims = match self.index.lock() {
|
|
Ok(mut g) => g.evict(max),
|
|
Err(_) => return,
|
|
};
|
|
for (kind, key) in victims {
|
|
let _ = std::fs::remove_file(self.path(kind, key));
|
|
self.evictions.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
/// 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<Item = impl Into<Arc<str>>>) {
|
|
let paths: Vec<Arc<str>> = 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<Arc<str>>) {
|
|
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<Arc<str>>)) -> 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<str>]) {
|
|
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<EntryFiles> {
|
|
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 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<VirtualFileId<'_>>) -> Option<(Frame8, ReadFiles)> {
|
|
self.get_checked(Kind::Frame, key, FORMAT_RGBA8_FILES, virtual_id, decode_frame)
|
|
}
|
|
|
|
/// A half-float frame (32 bpc viewer frames), as [`DiskCache::get_frame`].
|
|
pub fn get_frame_f16(&self, key: u128, virtual_id: Option<VirtualFileId<'_>>) -> 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) 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<VirtualFileId<'_>>,
|
|
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_f16(width, height, rgba));
|
|
self.enqueue_format(Kind::Frame, key, FORMAT_F16_FILES, payload, Some((&files, since)))
|
|
}
|
|
|
|
/// 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 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<VirtualFileId<'_>>, 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<VirtualFileId<'_>>) -> 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;
|
|
}
|
|
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); 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`].
|
|
pub fn get_seg(&self, key: u64, scale: f64) -> Option<effectcraft_effects::roto::FrameSeg> {
|
|
let p = self.read(Kind::Matte, seg_key(key, scale))?;
|
|
let raw = lz4_flex::decompress_size_prepended(&p).ok()?;
|
|
effectcraft_effects::roto::FrameSeg::from_bytes(&raw)
|
|
}
|
|
|
|
/// Queue a segmentation for writing.
|
|
pub fn put_seg(&self, key: u64, scale: f64, seg: &effectcraft_effects::roto::FrameSeg) {
|
|
let k = seg_key(key, scale);
|
|
if self.contains(Kind::Matte, k) {
|
|
return;
|
|
}
|
|
let _ = self.enqueue(Kind::Matte, k, lz4_flex::compress_prepend_size(&seg.to_bytes()));
|
|
}
|
|
|
|
/// Make the frame at key `from` the frame at key `to` too (an edit that can't change it gave
|
|
/// its comp a new content key): a hard link to the same file, or a copy where the file system
|
|
/// has no links. `false` when `from` isn't cached, `to` already is, or the file system
|
|
/// refused. (Entries don't contain their key, so the same file serves both.)
|
|
pub fn link_frame(&self, from: u128, to: u128) -> bool {
|
|
// (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));
|
|
let Some(parent) = dst.parent() else { return false };
|
|
let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed);
|
|
let tmp = parent.join(format!("{to:032x}.tmp.{}.{n}", std::process::id()));
|
|
let linked = std::fs::create_dir_all(parent)
|
|
.and_then(|()| std::fs::hard_link(&src, &tmp).or_else(|_| std::fs::copy(&src, &tmp).map(|_| ())))
|
|
.and_then(|()| std::fs::metadata(&tmp))
|
|
.and_then(|m| std::fs::rename(&tmp, &dst).map(|()| m.len()));
|
|
let bytes = match linked {
|
|
Ok(bytes) => bytes,
|
|
Err(_) => {
|
|
// (gone meanwhile: another process evicted it)
|
|
let _ = std::fs::remove_file(&tmp);
|
|
return false;
|
|
}
|
|
};
|
|
// (a link shares its file, but counts in full: the cache only ever errs small)
|
|
self.evict_to(self.limit_bytes().saturating_sub(bytes));
|
|
if let Ok(mut g) = self.index.lock() {
|
|
g.insert(Kind::Frame, to, bytes);
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Write a frame synchronously (tests); 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<u8>) -> Vec<u8> {
|
|
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<DiskCache>, q: Arc<(Mutex<Queue>, Condvar)>) {
|
|
let (m, cv) = &*q;
|
|
// The eviction at open (a lower limit than the folder holds), off the opening thread;
|
|
// `busy` starts set, so `flush` waits for it.
|
|
if let Some(cache) = dc.upgrade() {
|
|
cache.evict();
|
|
}
|
|
if let Ok(mut g) = m.lock() {
|
|
g.busy = false;
|
|
}
|
|
cv.notify_all();
|
|
loop {
|
|
let job = {
|
|
let Ok(mut g) = m.lock() else { return };
|
|
loop {
|
|
if let Some(j) = g.pop() {
|
|
g.busy = true;
|
|
break j;
|
|
}
|
|
if dc.strong_count() == 0 {
|
|
return;
|
|
}
|
|
g = match cv.wait_timeout(g, std::time::Duration::from_millis(500)) {
|
|
Ok((g, _)) => g,
|
|
Err(_) => return,
|
|
};
|
|
}
|
|
};
|
|
let Some(cache) = dc.upgrade() else { return };
|
|
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 let Some(key) = written {
|
|
g.pending_frames.remove(&key);
|
|
}
|
|
}
|
|
cv.notify_all();
|
|
}
|
|
}
|
|
|
|
/// 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<u8> {
|
|
let mut data = Vec::with_capacity(payload.len() + 16);
|
|
data.extend_from_slice(MAGIC);
|
|
data.push(kind.tag());
|
|
data.extend_from_slice(&[format, 0, 0]);
|
|
data.extend_from_slice(payload);
|
|
let sum = checksum(&data);
|
|
data.extend_from_slice(&sum.to_le_bytes());
|
|
data
|
|
}
|
|
|
|
/// 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<VirtualFileId<'_>>) -> Option<Vec<u8>> {
|
|
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))))
|
|
}
|
|
|
|
/// 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<VirtualFileId<'_>>) -> Option<Frame8> {
|
|
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 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<u8> {
|
|
seal_format(Kind::Layer, FORMAT_LAYER_FILES, &with_files(files, encode_layer(buf)))
|
|
}
|
|
|
|
/// 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.
|
|
fn seg_key(key: u64, scale: f64) -> u128 {
|
|
let mut h = Hash128::default();
|
|
h.write(b"segmentation-v2");
|
|
h.write_u64(key);
|
|
h.write_u64(scale.to_bits());
|
|
h.finish()
|
|
}
|
|
|
|
impl effectcraft_effects::roto::SegStore for DiskCache {
|
|
fn get_seg(&self, key: u64, scale: f64) -> Option<effectcraft_effects::roto::FrameSeg> {
|
|
DiskCache::get_seg(self, key, scale)
|
|
}
|
|
fn put_seg(&self, key: u64, scale: f64, seg: &effectcraft_effects::roto::FrameSeg) {
|
|
DiskCache::put_seg(self, key, scale, seg);
|
|
}
|
|
}
|
|
|
|
impl crate::cache::LayerStore for DiskCache {
|
|
/// 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<VirtualFileId<'_>>) -> Option<(Arc<Buf>, ReadFiles)> {
|
|
DiskCache::get_layer(self, key, virtual_id).map(|(b, f)| (Arc::new(b), f))
|
|
}
|
|
/// 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)
|
|
}
|
|
}
|
|
|
|
/// An entry's file name in its kind's folder (`<32 hex>.ecc`).
|
|
pub fn entry_name(key: u128) -> String {
|
|
format!("{key:032x}.ecc")
|
|
}
|
|
|
|
/// The key of an entry file name (`None` for temporaries and other files).
|
|
pub fn parse_entry_name(name: &str) -> Option<u128> {
|
|
let hex = name.strip_suffix(".ecc")?;
|
|
(hex.len() == 32).then(|| u128::from_str_radix(hex, 16).ok()).flatten()
|
|
}
|
|
|
|
/// Check magic, kind and checksum; returns the payload.
|
|
fn verify(d: &[u8], kind: Kind) -> Option<&[u8]> {
|
|
if d.len() < 16 || &d[..4] != MAGIC || d[4] != kind.tag() {
|
|
return None;
|
|
}
|
|
let (body, sum) = d.split_at(d.len() - 8);
|
|
let mut s = [0u8; 8];
|
|
s.copy_from_slice(sum);
|
|
(checksum(body) == u64::from_le_bytes(s)).then_some(&body[8..])
|
|
}
|
|
|
|
fn encode_frame(w: u32, h: u32, rgba: &[u8]) -> Vec<u8> {
|
|
let mut out = Vec::with_capacity(rgba.len() / 2 + 16);
|
|
out.extend_from_slice(&w.to_le_bytes());
|
|
out.extend_from_slice(&h.to_le_bytes());
|
|
out.extend_from_slice(&lz4_flex::compress_prepend_size(rgba));
|
|
out
|
|
}
|
|
|
|
fn decode_frame(p: &[u8]) -> Option<Frame8> {
|
|
if p.len() < 8 {
|
|
return None;
|
|
}
|
|
let w = u32::from_le_bytes(p[0..4].try_into().ok()?);
|
|
let h = u32::from_le_bytes(p[4..8].try_into().ok()?);
|
|
let rgba = lz4_flex::decompress_size_prepended(&p[8..]).ok()?;
|
|
(rgba.len() == w as usize * h as usize * 4).then_some(Frame8 { width: w, height: h, rgba })
|
|
}
|
|
|
|
/// Half floats with their low and high bytes in planes (per channel), then LZ4: the high bytes
|
|
/// (sign, exponent) repeat a lot, mixed with the low ones LZ4 finds little.
|
|
fn encode_frame_f16(w: u32, h: u32, rgba: &[u16]) -> Vec<u8> {
|
|
use rayon::prelude::*;
|
|
let n = rgba.len() / 4;
|
|
let mut planes = vec![0u8; n * 8];
|
|
// Plane (byte k, channel c) at (k · 4 + c) · n.
|
|
planes.par_chunks_mut(n.max(1)).enumerate().for_each(|(plane, out)| {
|
|
let (k, c) = (plane / 4, plane % 4);
|
|
for (o, px) in out.iter_mut().zip(rgba.as_chunks::<4>().0) {
|
|
*o = px[c].to_le_bytes()[k];
|
|
}
|
|
});
|
|
let mut out = Vec::with_capacity(planes.len() / 3 + 16);
|
|
out.extend_from_slice(&w.to_le_bytes());
|
|
out.extend_from_slice(&h.to_le_bytes());
|
|
out.extend_from_slice(&lz4_flex::compress_prepend_size(&planes));
|
|
out
|
|
}
|
|
|
|
fn decode_frame_f16(p: &[u8]) -> Option<FrameF16> {
|
|
if p.len() < 8 {
|
|
return None;
|
|
}
|
|
let w = u32::from_le_bytes(p[0..4].try_into().ok()?);
|
|
let h = u32::from_le_bytes(p[4..8].try_into().ok()?);
|
|
let n = (w as usize).checked_mul(h as usize)?;
|
|
let planes = lz4_flex::decompress_size_prepended(&p[8..]).ok()?;
|
|
if planes.len() != n.checked_mul(8)? {
|
|
return None;
|
|
}
|
|
let mut rgba = vec![0u16; n * 4];
|
|
for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
|
for (c, v) in px.iter_mut().enumerate() {
|
|
*v = u16::from_le_bytes([planes[c * n + i], planes[(4 + c) * n + i]]);
|
|
}
|
|
}
|
|
Some(FrameF16 { width: w, height: h, rgba })
|
|
}
|
|
|
|
fn encode_layer(b: &Buf) -> Vec<u8> {
|
|
let (w, h) = (b.img.width, b.img.height);
|
|
let n = b.img.data.len();
|
|
// Byte planes: all first bytes of every float, then all second bytes… (compresses well).
|
|
let mut planes = vec![0u8; n * 16];
|
|
for (i, px) in b.img.data.iter().enumerate() {
|
|
for c in 0..4 {
|
|
let bytes = px[c].to_le_bytes();
|
|
for (k, byte) in bytes.iter().enumerate() {
|
|
planes[(k * 4 + c) * n + i] = *byte;
|
|
}
|
|
}
|
|
}
|
|
let mut out = Vec::with_capacity(planes.len() / 4 + 40);
|
|
out.extend_from_slice(&w.to_le_bytes());
|
|
out.extend_from_slice(&h.to_le_bytes());
|
|
out.extend_from_slice(&b.offset[0].to_le_bytes());
|
|
out.extend_from_slice(&b.offset[1].to_le_bytes());
|
|
out.extend_from_slice(&b.scale.to_le_bytes());
|
|
out.extend_from_slice(&lz4_flex::compress_prepend_size(&planes));
|
|
out
|
|
}
|
|
|
|
/// Decodes a stored layer payload into a buffer, or `None` when it is too short or damaged.
|
|
fn decode_layer(p: &[u8]) -> Option<Buf> {
|
|
if p.len() < 32 {
|
|
return None;
|
|
}
|
|
let w = u32::from_le_bytes(p[0..4].try_into().ok()?);
|
|
let h = u32::from_le_bytes(p[4..8].try_into().ok()?);
|
|
let f = |i: usize| -> Option<f64> { Some(f64::from_le_bytes(p[i..i + 8].try_into().ok()?)) };
|
|
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).checked_mul(h as usize)?;
|
|
if planes.len() != n.checked_mul(16)? {
|
|
return None;
|
|
}
|
|
let mut img = Image::new(w, h);
|
|
for (i, px) in img.data.iter_mut().enumerate() {
|
|
for c in 0..4 {
|
|
let mut bytes = [0u8; 4];
|
|
for (k, byte) in bytes.iter_mut().enumerate() {
|
|
*byte = planes[(k * 4 + c) * n + i];
|
|
}
|
|
px[c] = f32::from_le_bytes(bytes);
|
|
}
|
|
}
|
|
Some(Buf { img, offset, scale })
|
|
}
|
|
|
|
// ---------------------------------------------------------------- content keys
|
|
|
|
/// 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]);
|
|
}
|
|
|
|
/// 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 [`VirtualFileId`] a cache can keep (the browser file table's identities).
|
|
pub type SharedVirtualFileId = Arc<dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync>;
|
|
|
|
/// 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";
|
|
|
|
/// 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());
|
|
h.write_u64(key);
|
|
h.finish()
|
|
}
|
|
|
|
/// 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_for_renderer(project, comp, RENDER_VERSION)
|
|
}
|
|
|
|
/// 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);
|
|
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) };
|
|
// (the names expressions look items up by: which items have them now)
|
|
if !reach.names.is_empty() {
|
|
hash_debug(&mut h, &reach.names);
|
|
}
|
|
let ids: Vec<effectcraft_project::ItemId> = 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);
|
|
h.write(it.name.as_bytes());
|
|
match &it.kind {
|
|
ItemKind::Comp(c) => {
|
|
let quiet = silent(id, c);
|
|
let heard = |l| heard.contains(id, l);
|
|
if quiet.is_empty() {
|
|
hash_debug(&mut h, &c.pixel_form_with(expressions, &heard));
|
|
} else {
|
|
let mut pic = (**c).clone();
|
|
pic.layers.retain(|l| !quiet.contains(&l.id));
|
|
hash_debug(&mut h, &pic.pixel_form_with(expressions, &heard));
|
|
}
|
|
}
|
|
k => hash_debug(&mut h, k),
|
|
}
|
|
hash_debug(&mut h, &it.proxy);
|
|
}
|
|
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);
|
|
h.write_u64(view);
|
|
h.finish()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn tmpdir(name: &str) -> PathBuf {
|
|
let d = std::env::temp_dir().join(format!("effectcraft-disk-cache-{name}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
d
|
|
}
|
|
|
|
/// A linked frame reads back as the original under its new key, survives the original's
|
|
/// eviction, and is never linked over an existing entry or from a missing one.
|
|
#[test]
|
|
fn link_frame_serves_a_frame_under_a_new_key() {
|
|
let dc = DiskCache::open(tmpdir("link"), 1 << 30).unwrap();
|
|
let rgba: Vec<u8> = (0..16).collect();
|
|
dc.put_frame_now(1, 2, 2, &rgba, &ReadFiles::default());
|
|
assert!(dc.link_frame(1, 2));
|
|
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], &ReadFiles::default());
|
|
assert!(!dc.link_frame(5, 2), "an existing entry stays");
|
|
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, 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 {
|
|
let mut img = Image::new(w, h);
|
|
for (i, p) in img.data.iter_mut().enumerate() {
|
|
let v = ((i as u32).wrapping_mul(2654435761u32).wrapping_add(seed) % 1000) as f32 / 999.0;
|
|
*p = [v, v * 0.5, 1.0 - v, if i % 7 == 0 { 0.0 } else { 1.0 }];
|
|
}
|
|
Buf { img, offset: [1.5, -2.25], scale: 0.5 }
|
|
}
|
|
|
|
#[test]
|
|
fn index_evicts_least_recently_used() {
|
|
let mut ix = DiskIndex::default();
|
|
ix.insert(Kind::Frame, 1, 100);
|
|
ix.insert(Kind::Frame, 2, 100);
|
|
ix.insert(Kind::Layer, 3, 100);
|
|
assert_eq!((ix.len(), ix.total()), (3, 300));
|
|
assert!(ix.touch(Kind::Frame, 1));
|
|
assert!(!ix.touch(Kind::Frame, 3));
|
|
assert_eq!(ix.lru_order(), vec![(Kind::Frame, 2), (Kind::Layer, 3), (Kind::Frame, 1)]);
|
|
assert_eq!(ix.evict(250), vec![(Kind::Frame, 2)]);
|
|
assert!(ix.evict(250).is_empty());
|
|
ix.insert(Kind::Frame, 1, 50);
|
|
assert_eq!(ix.total(), 150);
|
|
assert_eq!(ix.remove(Kind::Layer, 3), Some(100));
|
|
assert_eq!(ix.keys(Kind::Frame), vec![1]);
|
|
ix.clear();
|
|
assert!(ix.is_empty() && ix.total() == 0);
|
|
}
|
|
|
|
#[test]
|
|
fn frame_entries_round_trip_and_names_parse() {
|
|
let rgba: Vec<u8> = (0..4 * 6 * 3).map(|i| (i * 7 % 256) as u8).collect();
|
|
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, 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);
|
|
assert_eq!(parse_entry_name(&format!("{key:032x}.tmp.1")), None);
|
|
}
|
|
|
|
/// Half-float frames (32 bpc viewer frames) round-trip bit for bit, over-range values,
|
|
/// negatives and odd sizes included; they and 8-bit frames never read as each other; a
|
|
/// damaged half-float entry is a miss and is deleted; they count toward the size limit.
|
|
#[test]
|
|
fn half_float_frames_round_trip() {
|
|
let dir = tmpdir("f16");
|
|
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
|
|
let (w, h) = (7u32, 5u32);
|
|
// 4.0, 16.0, -0.25, 65504 and a spread of bit patterns.
|
|
let rgba: Vec<u16> = (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, &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, 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_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, 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..], &ReadFiles::default(), None, 0);
|
|
dc.flush();
|
|
assert!(!dc.contains(Kind::Frame, 4));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// Segmentations round-trip by (chain key, scale), survive a reopen (rescan), are bounded by
|
|
/// the size limit like every entry, and a damaged file is a miss.
|
|
#[test]
|
|
fn segmentations_round_trip_and_reopen() {
|
|
use effectcraft_effects::roto::FrameSeg;
|
|
let dir = tmpdir("seg");
|
|
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
|
|
let seg = FrameSeg { w: 7, h: 5, matte: (0..35).map(|i| (i % 3 == 0) as u8).collect(), refine: vec![0; 35], ..Default::default() };
|
|
assert!(dc.get_seg(42, 1.0).is_none());
|
|
dc.put_seg(42, 1.0, &seg);
|
|
dc.flush();
|
|
assert_eq!(dc.get_seg(42, 1.0), Some(seg.clone()));
|
|
assert!(dc.get_seg(42, 0.5).is_none() && dc.get_seg(43, 1.0).is_none());
|
|
assert_eq!(dc.keys(Kind::Matte).len(), 1);
|
|
drop(dc);
|
|
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
|
|
assert_eq!(dc.get_seg(42, 1.0), Some(seg.clone()));
|
|
// Damaged on disk: a miss, and the entry is forgotten.
|
|
let path = dc.path(Kind::Matte, seg_key(42, 1.0));
|
|
let mut d = std::fs::read(&path).unwrap();
|
|
d[18] ^= 0x55;
|
|
std::fs::write(&path, d).unwrap();
|
|
assert!(dc.get_seg(42, 1.0).is_none());
|
|
assert!(!dc.contains(Kind::Matte, seg_key(42, 1.0)));
|
|
// The size limit evicts segmentations too.
|
|
dc.put_seg(1, 1.0, &seg);
|
|
dc.flush();
|
|
dc.set_max_bytes(1);
|
|
assert!(dc.keys(Kind::Matte).is_empty());
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn hit_miss_and_exact_round_trip() {
|
|
let dir = tmpdir("hit");
|
|
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
|
|
assert!(dc.get_layer(42, None).is_none());
|
|
let b = buf(1, 33, 17);
|
|
dc.put_layer(42, &b, &EntryFiles::default(), 0);
|
|
dc.flush();
|
|
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<u8> = (0..20 * 10 * 4).map(|i| (i % 251) as u8).collect();
|
|
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, 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, 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 them too.
|
|
#[test]
|
|
fn music_keeps_the_disk_keys() {
|
|
use effectcraft_project::{Comp, Footage, FootageKind, ItemKind, LayerSource, Project, Solid, build};
|
|
use effectcraft_time::{FrameRate, Tick};
|
|
let mut p = Project::default();
|
|
let comp = Comp::new(64, 64, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
|
let cid = p.add_item("Main", Default::default(), None, ItemKind::Comp(comp.clone().into()));
|
|
let sid = p.add_item("Solid", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 64, height: 64, pixel_aspect: 1.0 }));
|
|
let clip = build::layer(&mut p, &comp, "Clip", LayerSource::Solid { item: sid }, (64, 64), None);
|
|
let clip_id = clip.id;
|
|
p.comp_mut(cid).unwrap().layers.push(clip);
|
|
let key = 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!(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");
|
|
p.comp_mut(cid).unwrap().layer_mut(clip_id).unwrap().switches.video = false;
|
|
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]
|
|
fn frames_are_kept_when_layer_writes_fill_the_queue() {
|
|
let dir = tmpdir("burst");
|
|
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
|
|
for k in 0..300 {
|
|
dc.put_layer(10_000 + k, &buf(1, 64, 64), &EntryFiles::default(), 0);
|
|
}
|
|
let rgba = vec![7u8; 32 * 32 * 4];
|
|
for k in 0..300 {
|
|
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");
|
|
}
|
|
|
|
#[test]
|
|
fn previous_renderer_frames_are_not_reused() {
|
|
use effectcraft_project::{Comp, ItemKind, Project};
|
|
use effectcraft_time::{FrameRate, Tick};
|
|
|
|
let mut project = Project::default();
|
|
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, "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], &ReadFiles::default());
|
|
}
|
|
let cache = DiskCache::open(&dir, 1 << 30).unwrap();
|
|
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]
|
|
fn persists_across_restarts_and_clears() {
|
|
let dir = tmpdir("persist");
|
|
{
|
|
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
|
|
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, None).map(|f| f.0).unwrap().rgba, vec![9; 16]);
|
|
dc.clear();
|
|
assert_eq!(dc.stats().entries, 0);
|
|
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);
|
|
}
|
|
|
|
#[test]
|
|
fn lru_eviction_respects_the_limit() {
|
|
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), &ReadFiles::default());
|
|
}
|
|
let per = dc.stats().bytes / 6;
|
|
// Touch 0 so it is the most recently used.
|
|
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:?}");
|
|
assert_eq!(st.entries, 3);
|
|
assert!(dc.contains(Kind::Layer, 0), "recently used survives");
|
|
assert!(!dc.contains(Kind::Layer, 1) && !dc.contains(Kind::Layer, 2) && !dc.contains(Kind::Layer, 3));
|
|
assert_eq!(st.evictions, 3);
|
|
// Files are really gone.
|
|
let files = walk(&dir);
|
|
assert_eq!(files, 3);
|
|
// New writes keep evicting.
|
|
dc.put_layer_now(99, &buf(9, 64, 64), &ReadFiles::default());
|
|
assert!(dc.stats().bytes <= per * 3 + per * 3 / 4);
|
|
}
|
|
|
|
#[test]
|
|
fn eviction_goes_down_to_ninety_percent_in_lru_order() {
|
|
let mut ix = DiskIndex::default();
|
|
for k in 0..10u128 {
|
|
ix.insert(Kind::Layer, k, 100);
|
|
}
|
|
assert!(ix.touch(Kind::Layer, 0));
|
|
// Over 950: down to 855, least recently used first.
|
|
ix.insert(Kind::Layer, 10, 100);
|
|
assert_eq!(ix.evict(950), vec![(Kind::Layer, 1), (Kind::Layer, 2), (Kind::Layer, 3)]);
|
|
assert_eq!(ix.total(), 800);
|
|
// The slack absorbs the next write without another scan.
|
|
ix.insert(Kind::Layer, 11, 100);
|
|
assert!(ix.evict(950).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn free_disk_space_caps_the_limit() {
|
|
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), &ReadFiles::default());
|
|
}
|
|
let used = dc.stats().bytes;
|
|
// Unknown space: the setting alone.
|
|
dc.set_disk_space(None);
|
|
assert_eq!(dc.stats().limit_bytes, 1 << 30);
|
|
// 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 (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), &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), &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.
|
|
dc.set_disk_space(Some((100 << 30, 50 << 30)));
|
|
assert_eq!(dc.stats().limit_bytes, 1 << 30);
|
|
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), &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);
|
|
}
|
|
|
|
fn walk(d: &Path) -> usize {
|
|
let mut n = 0;
|
|
if let Ok(rd) = std::fs::read_dir(d) {
|
|
for e in rd.flatten() {
|
|
let p = e.path();
|
|
if p.is_dir() {
|
|
n += walk(&p);
|
|
} else if p.extension().is_some_and(|x| x == "ecc") {
|
|
n += 1;
|
|
}
|
|
}
|
|
}
|
|
n
|
|
}
|
|
|
|
#[test]
|
|
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], &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, None).map(|f| f.0).is_none());
|
|
assert!(!dc.contains(Kind::Frame, 5));
|
|
assert!(!p.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn concurrent_readers_and_writers() {
|
|
let dir = tmpdir("concurrent");
|
|
let a = DiskCache::open(&dir, 1 << 30).unwrap();
|
|
// A second instance on the same folder (another process, in effect).
|
|
let b = DiskCache::open(&dir, 1 << 30).unwrap();
|
|
std::thread::scope(|s| {
|
|
for t in 0..4u32 {
|
|
let (a, b) = (a.clone(), b.clone());
|
|
s.spawn(move || {
|
|
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), &ReadFiles::default());
|
|
if let Some((x, _)) = w.get_layer(k, None) {
|
|
assert_eq!(x.img, buf(k as u32, 16, 16).img);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
b.rescan();
|
|
for k in 0..5u128 {
|
|
assert_eq!(b.get_layer(k, None).unwrap().0.img, buf(k as u32, 16, 16).img);
|
|
}
|
|
}
|
|
|
|
/// 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 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};
|
|
|
|
let mut p = Project::default();
|
|
let f = Footage { path: "/virtual.mov".into(), width: 16, height: 9, duration: Tick::from_seconds_f64(1.0), has_video: true, ..Default::default() };
|
|
let fid = p.add_item("virtual.mov", effectcraft_color::Label::None, None, ItemKind::Footage(f));
|
|
let mut comp = Comp::new(16, 9, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
|
comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (16, 9), 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 == "/virtual.mov").then_some((8, mark));
|
|
let (one, two) = (id(1), id(2));
|
|
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::<std::collections::BTreeSet<_>>(), [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<str> = "/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<Arc<str>> = (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<str> = "/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);
|
|
}
|
|
}
|