mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 02:27:00 -06:00
Merge main into M13.5 long-tail polish
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -330,6 +330,7 @@ mod tests {
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
};
|
||||
let fid = p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(f));
|
||||
let comp = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0));
|
||||
|
||||
@@ -25,9 +25,13 @@ use effectcraft_project::{ItemKind, Layer, LayerSource, Node, PropGroup};
|
||||
|
||||
use crate::eval::EvalCtx;
|
||||
|
||||
/// Thread-safe, memory-budgeted cache of processed layer buffers.
|
||||
/// Thread-safe, memory-budgeted cache of processed layer buffers, optionally backed by the
|
||||
/// persistent [`DiskCache`](crate::disk_cache::DiskCache): a memory miss reads the disk, and
|
||||
/// buffers that were slow to render are written there (keys salted per project, see
|
||||
/// [`LayerCache::set_disk`]).
|
||||
pub struct LayerCache {
|
||||
inner: Mutex<Inner>,
|
||||
disk: Mutex<Option<(Arc<crate::disk_cache::DiskCache>, u64)>>,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
@@ -43,6 +47,8 @@ struct Inner {
|
||||
clock: u64,
|
||||
hits: u64,
|
||||
misses: u64,
|
||||
/// When each recent miss happened (to time the render that follows it).
|
||||
missed_at: HashMap<u64, web_time::Instant>,
|
||||
}
|
||||
|
||||
/// Hit/miss counters and memory use (for perf readouts and tests).
|
||||
@@ -63,7 +69,37 @@ impl Default for LayerCache {
|
||||
impl LayerCache {
|
||||
/// A cache holding at most `budget` bytes of pixels.
|
||||
pub fn new(budget: usize) -> LayerCache {
|
||||
LayerCache { inner: Mutex::new(Inner { map: HashMap::new(), bytes: 0, budget, clock: 0, hits: 0, misses: 0 }) }
|
||||
LayerCache {
|
||||
inner: Mutex::new(Inner { map: HashMap::new(), bytes: 0, budget, clock: 0, hits: 0, misses: 0, missed_at: HashMap::new() }),
|
||||
disk: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach (or detach) the disk cache. `salt` separates projects and footage versions (see
|
||||
/// `footage_salt`); keys on disk are `(salt, key)`.
|
||||
pub fn set_disk(&self, disk: Option<(Arc<crate::disk_cache::DiskCache>, u64)>) {
|
||||
if let Ok(mut d) = self.disk.lock() {
|
||||
*d = disk;
|
||||
}
|
||||
}
|
||||
|
||||
/// Change the disk salt (after footage changed) keeping the attached disk cache.
|
||||
pub fn set_disk_salt(&self, salt: u64) {
|
||||
if let Ok(mut d) = self.disk.lock()
|
||||
&& let Some((_, s)) = d.as_mut()
|
||||
{
|
||||
*s = salt;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn disk(&self) -> Option<Arc<crate::disk_cache::DiskCache>> {
|
||||
self.disk.lock().ok()?.as_ref().map(|d| d.0.clone())
|
||||
}
|
||||
|
||||
fn disk_key(&self, key: u64) -> Option<(Arc<crate::disk_cache::DiskCache>, u128)> {
|
||||
let d = self.disk.lock().ok()?;
|
||||
let (dc, salt) = d.as_ref()?;
|
||||
Some((dc.clone(), ((*salt as u128) << 64) | key as u128))
|
||||
}
|
||||
|
||||
pub fn get(&self, key: u64) -> Option<Arc<Buf>> {
|
||||
@@ -79,12 +115,32 @@ impl LayerCache {
|
||||
}
|
||||
None => {
|
||||
g.misses += 1;
|
||||
None
|
||||
if g.missed_at.len() > 4096 {
|
||||
g.missed_at.clear();
|
||||
}
|
||||
g.missed_at.insert(key, web_time::Instant::now());
|
||||
drop(g);
|
||||
// Disk cache: a hit comes back into memory.
|
||||
let (dc, dk) = self.disk_key(key)?;
|
||||
let buf = Arc::new(dc.get_layer(dk)?);
|
||||
self.insert_mem(key, buf.clone());
|
||||
Some(buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&self, key: u64, buf: Arc<Buf>) {
|
||||
let started = self.inner.lock().ok().and_then(|mut g| g.missed_at.remove(&key));
|
||||
if let Some((dc, dk)) = self.disk_key(key) {
|
||||
let slow = started.is_some_and(|t| t.elapsed().as_secs_f64() * 1e3 >= dc.min_layer_ms() as f64);
|
||||
if slow {
|
||||
dc.put_layer(dk, &buf);
|
||||
}
|
||||
}
|
||||
self.insert_mem(key, buf);
|
||||
}
|
||||
|
||||
fn insert_mem(&self, key: u64, buf: Arc<Buf>) {
|
||||
let bytes = buf.img.data.len() * std::mem::size_of::<effectcraft_raster::Px>();
|
||||
let Ok(mut g) = self.inner.lock() else { return };
|
||||
if bytes > g.budget / 4 {
|
||||
@@ -286,6 +342,8 @@ fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool
|
||||
let it = ctx.project.item(*item)?;
|
||||
let ItemKind::Footage(f) = &it.kind else { return None };
|
||||
hash_debug(&mut h, f);
|
||||
// A proxy (and its Use Proxy switch) changes the pixels.
|
||||
hash_debug(&mut h, &it.proxy);
|
||||
item.hash(&mut h);
|
||||
ctx.source_time(layer).0.hash(&mut h);
|
||||
// Frame blending mixes neighbouring source frames.
|
||||
|
||||
@@ -0,0 +1,831 @@
|
||||
//! 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 (the comp and everything it uses, footage
|
||||
//! files' size and modification time included) plus frame, scale and view; layer buffers by
|
||||
//! the layer cache's content key salted with the project's footage fingerprint. A changed
|
||||
//! project therefore never reads stale pixels: its keys simply change.
|
||||
//! * Payloads are LZ4-compressed (`lz4_flex`, MIT): frames as 8-bit premultiplied RGBA, layer
|
||||
//! buffers as `f32` RGBA with the bytes shuffled into planes (exact, so cached and fresh
|
||||
//! renders are bit-identical). Every file ends in a checksum; damaged files read as misses and
|
||||
//! are deleted.
|
||||
//! * Size limit with least-recently-used eviction. 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::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
|
||||
use effectcraft_effects::Buf;
|
||||
use effectcraft_raster::Image;
|
||||
|
||||
/// File format version (part of the folder layout).
|
||||
const VERSION: &str = "v1";
|
||||
const MAGIC: &[u8; 4] = b"ECDC";
|
||||
/// Pending writes before new ones are dropped.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const QUEUE_LIMIT: usize = 64;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Kind {
|
||||
Frame,
|
||||
Layer,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
fn dir(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Frame => "frames",
|
||||
Kind::Layer => "layers",
|
||||
}
|
||||
}
|
||||
fn tag(self) -> u8 {
|
||||
match self {
|
||||
Kind::Frame => 1,
|
||||
Kind::Layer => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
pub writes: u64,
|
||||
pub evictions: u64,
|
||||
pub dropped: u64,
|
||||
}
|
||||
|
||||
struct Meta {
|
||||
bytes: u64,
|
||||
last_use: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Index {
|
||||
map: HashMap<(Kind, u128), Meta>,
|
||||
total: u64,
|
||||
clock: u64,
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
enum Job {
|
||||
Write(Kind, u128, Vec<u8>),
|
||||
}
|
||||
|
||||
struct Queue {
|
||||
jobs: std::collections::VecDeque<Job>,
|
||||
busy: bool,
|
||||
}
|
||||
|
||||
pub struct DiskCache {
|
||||
dir: PathBuf,
|
||||
index: Mutex<Index>,
|
||||
max_bytes: AtomicU64,
|
||||
hits: AtomicU64,
|
||||
misses: AtomicU64,
|
||||
writes: AtomicU64,
|
||||
evictions: AtomicU64,
|
||||
dropped: 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,
|
||||
}
|
||||
|
||||
/// 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 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(Index::default()),
|
||||
max_bytes: AtomicU64::new(max_bytes),
|
||||
hits: AtomicU64::new(0),
|
||||
misses: AtomicU64::new(0),
|
||||
writes: AtomicU64::new(0),
|
||||
evictions: AtomicU64::new(0),
|
||||
dropped: AtomicU64::new(0),
|
||||
tmp_counter: AtomicU64::new(0),
|
||||
queue: Arc::new((Mutex::new(Queue { jobs: Default::default(), busy: false }), Condvar::new())),
|
||||
min_layer_ms: AtomicU64::new(30),
|
||||
});
|
||||
dc.rescan();
|
||||
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] {
|
||||
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(|n| n.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 = Index::default();
|
||||
for (_, kind, key, bytes) in found {
|
||||
idx.clock += 1;
|
||||
let clock = idx.clock;
|
||||
idx.total += bytes;
|
||||
idx.map.insert((kind, key), Meta { bytes, last_use: clock });
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
/// 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.map.contains_key(&(kind, key)))
|
||||
}
|
||||
|
||||
/// Keys of one kind (unordered).
|
||||
pub fn keys(&self, kind: Kind) -> Vec<u128> {
|
||||
self.index.lock().map(|g| g.map.keys().filter(|k| k.0 == kind).map(|k| k.1).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> DiskStats {
|
||||
let (entries, bytes) = self.index.lock().map(|g| (g.map.len(), g.total)).unwrap_or_default();
|
||||
DiskStats {
|
||||
entries,
|
||||
bytes,
|
||||
max_bytes: self.max_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),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = Index::default();
|
||||
}
|
||||
for kind in [Kind::Frame, Kind::Layer] {
|
||||
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();
|
||||
}
|
||||
while !q.jobs.is_empty() || q.busy {
|
||||
q = match cv.wait(q) {
|
||||
Ok(q) => q,
|
||||
Err(_) => return,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn read(&self, kind: Kind, key: u128) -> Option<Vec<u8>> {
|
||||
if !self.contains(kind, key) {
|
||||
self.misses.fetch_add(1, Ordering::Relaxed);
|
||||
return None;
|
||||
}
|
||||
let path = self.path(kind, key);
|
||||
let data = std::fs::read(&path).ok();
|
||||
let ok = data.as_deref().and_then(|d| verify(d, kind));
|
||||
match ok {
|
||||
Some(payload) => {
|
||||
let payload = payload.to_vec();
|
||||
self.hits.fetch_add(1, Ordering::Relaxed);
|
||||
if let Ok(mut g) = self.index.lock() {
|
||||
g.clock += 1;
|
||||
let c = g.clock;
|
||||
if let Some(m) = g.map.get_mut(&(kind, key)) {
|
||||
m.last_use = c;
|
||||
}
|
||||
}
|
||||
// Persist the recency for the next session (best effort).
|
||||
if let Ok(f) = std::fs::File::options().append(true).open(&path) {
|
||||
let _ = f.set_modified(std::time::SystemTime::now());
|
||||
}
|
||||
Some(payload)
|
||||
}
|
||||
None => {
|
||||
self.misses.fetch_add(1, Ordering::Relaxed);
|
||||
// Gone (another process evicted it) or damaged: forget it.
|
||||
if data.is_some() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
self.forget(kind, key);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn forget(&self, kind: Kind, key: u128) {
|
||||
if let Ok(mut g) = self.index.lock()
|
||||
&& let Some(m) = g.map.remove(&(kind, key))
|
||||
{
|
||||
g.total -= m.bytes;
|
||||
}
|
||||
}
|
||||
|
||||
fn enqueue(&self, kind: Kind, key: u128, payload: Vec<u8>) {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
self.write_now(kind, key, &payload);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let (m, cv) = &*self.queue;
|
||||
let Ok(mut q) = m.lock() else { return };
|
||||
if q.jobs.len() >= QUEUE_LIMIT {
|
||||
self.dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
q.jobs.push_back(Job::Write(kind, key, payload));
|
||||
cv.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an entry now (atomic rename), then evict down to the limit.
|
||||
fn write_now(&self, kind: Kind, key: u128, payload: &[u8]) {
|
||||
if self.contains(kind, key) {
|
||||
return;
|
||||
}
|
||||
let path = self.path(kind, key);
|
||||
let Some(parent) = path.parent() else { return };
|
||||
if std::fs::create_dir_all(parent).is_err() {
|
||||
return;
|
||||
}
|
||||
let mut data = Vec::with_capacity(payload.len() + 16);
|
||||
data.extend_from_slice(MAGIC);
|
||||
data.push(kind.tag());
|
||||
data.extend_from_slice(&[0, 0, 0]);
|
||||
data.extend_from_slice(payload);
|
||||
let sum = checksum(&data);
|
||||
data.extend_from_slice(&sum.to_le_bytes());
|
||||
let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed);
|
||||
let tmp = parent.join(format!("{key:032x}.tmp.{}.{n}", std::process::id()));
|
||||
if std::fs::write(&tmp, &data).is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return;
|
||||
}
|
||||
if std::fs::rename(&tmp, &path).is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return;
|
||||
}
|
||||
self.writes.fetch_add(1, Ordering::Relaxed);
|
||||
if let Ok(mut g) = self.index.lock() {
|
||||
g.clock += 1;
|
||||
let c = g.clock;
|
||||
let bytes = data.len() as u64;
|
||||
if let Some(old) = g.map.insert((kind, key), Meta { bytes, last_use: c }) {
|
||||
g.total -= old.bytes;
|
||||
}
|
||||
g.total += bytes;
|
||||
}
|
||||
self.evict();
|
||||
}
|
||||
|
||||
/// Remove least recently used entries until the cache fits its limit.
|
||||
fn evict(&self) {
|
||||
let max = self.max_bytes();
|
||||
let victims: Vec<(Kind, u128)> = {
|
||||
let Ok(mut g) = self.index.lock() else { return };
|
||||
if g.total <= max {
|
||||
return;
|
||||
}
|
||||
let mut by_age: Vec<(u64, (Kind, u128), u64)> = g.map.iter().map(|(k, m)| (m.last_use, *k, m.bytes)).collect();
|
||||
by_age.sort_unstable_by_key(|e| e.0);
|
||||
let mut out = vec![];
|
||||
for (_, k, bytes) in by_age {
|
||||
if g.total <= max {
|
||||
break;
|
||||
}
|
||||
g.map.remove(&k);
|
||||
g.total -= bytes;
|
||||
out.push(k);
|
||||
}
|
||||
out
|
||||
};
|
||||
for (kind, key) in victims {
|
||||
let _ = std::fs::remove_file(self.path(kind, key));
|
||||
self.evictions.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ typed entries
|
||||
|
||||
pub fn get_frame(&self, key: u128) -> Option<Frame8> {
|
||||
let p = self.read(Kind::Frame, key)?;
|
||||
decode_frame(&p)
|
||||
}
|
||||
|
||||
/// Queue a frame for writing (8-bit premultiplied RGBA, `width * height * 4` bytes).
|
||||
pub fn put_frame(&self, key: u128, width: u32, height: u32, rgba: &[u8]) {
|
||||
if self.contains(Kind::Frame, key) || rgba.len() != width as usize * height as usize * 4 {
|
||||
return;
|
||||
}
|
||||
self.enqueue(Kind::Frame, key, encode_frame(width, height, rgba));
|
||||
}
|
||||
|
||||
pub fn get_layer(&self, key: u128) -> Option<Buf> {
|
||||
let p = self.read(Kind::Layer, key)?;
|
||||
decode_layer(&p)
|
||||
}
|
||||
|
||||
/// Queue a layer buffer for writing.
|
||||
pub fn put_layer(&self, key: u128, buf: &Buf) {
|
||||
if self.contains(Kind::Layer, key) {
|
||||
return;
|
||||
}
|
||||
self.enqueue(Kind::Layer, key, encode_layer(buf));
|
||||
}
|
||||
|
||||
/// Write a layer buffer synchronously (tests).
|
||||
pub fn put_layer_now(&self, key: u128, buf: &Buf) {
|
||||
self.write_now(Kind::Layer, key, &encode_layer(buf));
|
||||
}
|
||||
|
||||
/// Write a frame synchronously (tests).
|
||||
pub fn put_frame_now(&self, key: u128, width: u32, height: u32, rgba: &[u8]) {
|
||||
self.write_now(Kind::Frame, key, &encode_frame(width, height, rgba));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn writer(dc: std::sync::Weak<DiskCache>, q: Arc<(Mutex<Queue>, Condvar)>) {
|
||||
let (m, cv) = &*q;
|
||||
loop {
|
||||
let job = {
|
||||
let Ok(mut g) = m.lock() else { return };
|
||||
loop {
|
||||
if let Some(j) = g.jobs.pop_front() {
|
||||
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 };
|
||||
match job {
|
||||
Job::Write(kind, key, payload) => cache.write_now(kind, key, &payload),
|
||||
}
|
||||
drop(cache);
|
||||
if let Ok(mut g) = m.lock() {
|
||||
g.busy = false;
|
||||
}
|
||||
cv.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 })
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 * h as usize;
|
||||
if planes.len() != n * 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-1";
|
||||
|
||||
fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) {
|
||||
h.write(format!("{v:?}").as_bytes());
|
||||
h.write(&[0xff]);
|
||||
}
|
||||
|
||||
/// A file's size and modification time (so edited footage gets new keys).
|
||||
fn file_stamp(h: &mut Hash128, path: &str) {
|
||||
if path.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(md) = std::fs::metadata(path) {
|
||||
h.write_u64(md.len());
|
||||
if let Ok(t) = md.modified()
|
||||
&& let Ok(d) = t.duration_since(std::time::UNIX_EPOCH)
|
||||
{
|
||||
h.write_u64(d.as_nanos() as u64);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn footage_stamp(h: &mut Hash128, f: &effectcraft_project::Footage) {
|
||||
file_stamp(h, &f.path);
|
||||
for p in &f.sequence {
|
||||
file_stamp(h, p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Salt for layer cache keys on disk: the renderer version and every footage file's identity
|
||||
/// (layer keys name footage items, not file contents).
|
||||
pub fn footage_salt(project: &effectcraft_project::Project) -> u64 {
|
||||
let mut h = Hash128::default();
|
||||
h.write(RENDER_VERSION.as_bytes());
|
||||
for (id, it) in &project.items {
|
||||
if let effectcraft_project::ItemKind::Footage(f) = &it.kind {
|
||||
h.write_u64(id.0);
|
||||
hash_debug(&mut h, f);
|
||||
footage_stamp(&mut h, f);
|
||||
}
|
||||
}
|
||||
let v = h.finish();
|
||||
(v as u64) ^ ((v >> 64) as u64)
|
||||
}
|
||||
|
||||
/// Content key of a composition's pixels: project render settings, the comp and everything it
|
||||
/// uses (nested comps, footage, solids, footage files' size and time). With expressions in play
|
||||
/// (which can read any comp) the whole project counts.
|
||||
pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 {
|
||||
use effectcraft_project::{ItemKind, LayerSource};
|
||||
let mut h = Hash128::default();
|
||||
h.write(RENDER_VERSION.as_bytes());
|
||||
hash_debug(&mut h, &project.settings);
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut stack = vec![comp];
|
||||
let mut expressions = false;
|
||||
while let Some(id) = stack.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let Some(it) = project.item(id) else { continue };
|
||||
if let ItemKind::Comp(c) = &it.kind {
|
||||
for l in &c.layers {
|
||||
if let LayerSource::Comp { item } | LayerSource::Footage { item } | LayerSource::Solid { item } = l.source {
|
||||
stack.push(item);
|
||||
}
|
||||
if !expressions {
|
||||
l.props.walk("", &mut |_, p| expressions |= p.expr.is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let ids: Vec<effectcraft_project::ItemId> = if expressions { project.items.keys().copied().collect() } else { seen.into_iter().collect() };
|
||||
for id in ids {
|
||||
let Some(it) = project.item(id) else { continue };
|
||||
h.write_u64(id.0);
|
||||
h.write(it.name.as_bytes());
|
||||
hash_debug(&mut h, &it.kind);
|
||||
if let ItemKind::Footage(f) = &it.kind {
|
||||
footage_stamp(&mut h, f);
|
||||
}
|
||||
}
|
||||
h.write_u64(comp.0);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// 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(&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
|
||||
}
|
||||
|
||||
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 hit_miss_and_exact_round_trip() {
|
||||
let dir = tmpdir("hit");
|
||||
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
|
||||
assert!(dc.get_layer(42).is_none());
|
||||
let b = buf(1, 33, 17);
|
||||
dc.put_layer(42, &b);
|
||||
dc.flush();
|
||||
let back = dc.get_layer(42).unwrap();
|
||||
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();
|
||||
dc.put_frame(7, 20, 10, &rgba);
|
||||
dc.flush();
|
||||
assert_eq!(dc.get_frame(7).unwrap().rgba, rgba);
|
||||
let st = dc.stats();
|
||||
assert_eq!((st.entries, st.hits, st.misses, st.writes), (2, 2, 1, 2));
|
||||
// Wrong kind is a miss.
|
||||
assert!(dc.get_frame(42).is_none());
|
||||
}
|
||||
|
||||
#[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));
|
||||
dc.put_frame_now(2, 2, 2, &[9; 16]);
|
||||
}
|
||||
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
|
||||
assert_eq!(dc.stats().entries, 2);
|
||||
assert!(dc.contains(Kind::Layer, 1) && dc.contains(Kind::Frame, 2));
|
||||
assert_eq!(dc.get_frame(2).unwrap().rgba, vec![9; 16]);
|
||||
dc.clear();
|
||||
assert_eq!(dc.stats().entries, 0);
|
||||
assert!(dc.get_frame(2).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));
|
||||
}
|
||||
let per = dc.stats().bytes / 6;
|
||||
// Touch 0 so it is the most recently used.
|
||||
assert!(dc.get_layer(0).is_some());
|
||||
dc.set_max_bytes(per * 3 + per / 2);
|
||||
let st = dc.stats();
|
||||
assert!(st.bytes <= per * 3 + per / 2, "{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));
|
||||
assert!(dc.stats().bytes <= per * 3 + per / 2);
|
||||
}
|
||||
|
||||
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]);
|
||||
let p = dc.path(Kind::Frame, 5);
|
||||
let mut d = std::fs::read(&p).unwrap();
|
||||
let n = d.len();
|
||||
d[n / 2] ^= 0xff;
|
||||
std::fs::write(&p, d).unwrap();
|
||||
assert!(dc.get_frame(5).is_none());
|
||||
assert!(!dc.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));
|
||||
if let Some(x) = w.get_layer(k) {
|
||||
assert_eq!(x.img, buf(k as u32, 16, 16).img);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
b.rescan();
|
||||
for k in 0..5u128 {
|
||||
assert_eq!(b.get_layer(k).unwrap().img, buf(k as u32, 16, 16).img);
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
-2
@@ -36,11 +36,14 @@ pub struct EvalCtx<'a> {
|
||||
/// Comp time.
|
||||
pub time: Tick,
|
||||
pub expr: Option<&'a dyn ExprHost>,
|
||||
/// Footage frames for expressions that read rendered pixels (`sampleImage`): the renderer's
|
||||
/// own source while rendering; `None` renders footage as transparent.
|
||||
pub footage: Option<&'a dyn crate::FootageSource>,
|
||||
}
|
||||
|
||||
impl<'a> EvalCtx<'a> {
|
||||
pub fn new(project: &'a Project, comp_id: ItemId, comp: &'a Comp, time: Tick) -> EvalCtx<'a> {
|
||||
EvalCtx { project, comp_id, comp, time, expr: None }
|
||||
EvalCtx { project, comp_id, comp, time, expr: None, footage: None }
|
||||
}
|
||||
pub fn at(&self, time: Tick) -> EvalCtx<'a> {
|
||||
EvalCtx { time, ..*self }
|
||||
@@ -100,6 +103,15 @@ impl<'a> EvalCtx<'a> {
|
||||
if let Some(tr) = layer.props.get("timeRemap") {
|
||||
return Tick::from_seconds_f64(self.value(layer, tr).as_f64());
|
||||
}
|
||||
// Responsive Design — Time: a time-stretched precomp keeps its protected regions at
|
||||
// their original speed.
|
||||
if let LayerSource::Comp { item } = layer.source
|
||||
&& (layer.stretch - 100.0).abs() > 1e-9
|
||||
&& let Some(nc) = self.project.comp(item)
|
||||
&& let Some(t) = responsive_source_time(nc, layer.stretch / 100.0, (self.time - layer.start_time).seconds())
|
||||
{
|
||||
return t;
|
||||
}
|
||||
layer.layer_time(self.time)
|
||||
}
|
||||
pub fn layer(&self, id: LayerId) -> Option<&'a Layer> {
|
||||
@@ -108,6 +120,28 @@ impl<'a> EvalCtx<'a> {
|
||||
|
||||
/// Local (parent-space) transform of a layer.
|
||||
pub fn local_matrix(&self, layer: &Layer) -> Mat4 {
|
||||
self.local_matrix_par(layer, 1.0)
|
||||
}
|
||||
|
||||
/// Source pixel aspect relative to the comp's: non-square footage (or solids, or precomps
|
||||
/// with another pixel aspect) is stretched horizontally by this factor in the comp.
|
||||
pub fn par_ratio(&self, layer: &Layer) -> f64 {
|
||||
let par = match &layer.source {
|
||||
LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match self.project.item(*item).map(|i| &i.kind) {
|
||||
Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect,
|
||||
Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect,
|
||||
Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect,
|
||||
_ => 1.0,
|
||||
},
|
||||
_ => 1.0,
|
||||
};
|
||||
let r = par / if self.comp.pixel_aspect > 0.0 { self.comp.pixel_aspect } else { 1.0 };
|
||||
if r.is_finite() && r > 0.0 && (r - 1.0).abs() > 1e-9 { r } else { 1.0 }
|
||||
}
|
||||
|
||||
/// [`Self::local_matrix`] with the source's pixel aspect folded into the scale (`par`; 1 for
|
||||
/// a parent's matrix, which children don't inherit).
|
||||
fn local_matrix_par(&self, layer: &Layer, par: f64) -> Mat4 {
|
||||
let Some(tr) = layer.transform() else { return Mat4::IDENTITY };
|
||||
let three = layer.is_3d();
|
||||
let pos = self.position(layer, tr);
|
||||
@@ -118,6 +152,7 @@ impl<'a> EvalCtx<'a> {
|
||||
}
|
||||
let anchor = self.v3(layer, tr, "anchor", [0.0; 3]);
|
||||
let mut scale = self.v3(layer, tr, "scale", [100.0; 3]);
|
||||
scale[0] *= par;
|
||||
if !three {
|
||||
scale[2] = 100.0;
|
||||
let a = Vec3::from([anchor[0], anchor[1], 0.0]);
|
||||
@@ -142,7 +177,7 @@ impl<'a> EvalCtx<'a> {
|
||||
|
||||
/// Layer space → comp (world) space, including parents.
|
||||
pub fn world_matrix(&self, layer: &Layer) -> Mat4 {
|
||||
let mut m = self.local_matrix(layer);
|
||||
let mut m = self.local_matrix_par(layer, self.par_ratio(layer));
|
||||
let mut cur = layer.parent;
|
||||
let mut guard = 0;
|
||||
while let Some(pid) = cur {
|
||||
@@ -185,6 +220,73 @@ impl<'a> EvalCtx<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The protected regions of a comp (Responsive Design — Time markers), merged, sorted and
|
||||
/// clamped to the comp, in seconds.
|
||||
pub fn protected_regions(comp: &Comp) -> Vec<(f64, f64)> {
|
||||
let d = comp.duration.seconds();
|
||||
let mut v: Vec<(f64, f64)> = comp
|
||||
.markers
|
||||
.iter()
|
||||
.filter(|m| m.protected && m.duration > Tick::ZERO)
|
||||
.map(|m| (m.time.seconds().clamp(0.0, d), (m.time + m.duration).seconds().clamp(0.0, d)))
|
||||
.filter(|(a, b)| b > a)
|
||||
.collect();
|
||||
v.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut out: Vec<(f64, f64)> = vec![];
|
||||
for (a, b) in v {
|
||||
match out.last_mut() {
|
||||
Some(last) if a <= last.1 => last.1 = last.1.max(b),
|
||||
_ => out.push((a, b)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Source time of a precomp time-stretched by `stretch` (1 = 100%), `elapsed` comp seconds
|
||||
/// after the layer's start, with Responsive Design — Time: protected regions play at their
|
||||
/// original speed and the unprotected parts absorb the whole stretch. `None` when the comp has
|
||||
/// no protected regions or the stretch can't be absorbed (reversed, or shorter than the
|
||||
/// protected regions), so the plain stretch applies.
|
||||
pub fn responsive_source_time(comp: &Comp, stretch: f64, elapsed: f64) -> Option<Tick> {
|
||||
let regions = protected_regions(comp);
|
||||
if regions.is_empty() || stretch <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let d = comp.duration.seconds();
|
||||
let lp: f64 = regions.iter().map(|(a, b)| b - a).sum();
|
||||
let lu = d - lp;
|
||||
let k = if lu > 1e-9 { (d * stretch - lp) / lu } else { 1.0 };
|
||||
if k <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
if elapsed < 0.0 {
|
||||
return Some(Tick::from_seconds_f64(elapsed / k));
|
||||
}
|
||||
// Segments in source order: (start, end, protected).
|
||||
let mut segs = vec![];
|
||||
let mut at = 0.0;
|
||||
for (a, b) in ®ions {
|
||||
if *a > at {
|
||||
segs.push((at, *a, false));
|
||||
}
|
||||
segs.push((*a, *b, true));
|
||||
at = *b;
|
||||
}
|
||||
if d > at {
|
||||
segs.push((at, d, false));
|
||||
}
|
||||
let mut pos = 0.0;
|
||||
for (a, b, prot) in segs {
|
||||
let speed = if prot { 1.0 } else { k };
|
||||
let len = (b - a) * speed;
|
||||
if elapsed < pos + len {
|
||||
return Some(Tick::from_seconds_f64(a + (elapsed - pos) / speed));
|
||||
}
|
||||
pos += len;
|
||||
}
|
||||
Some(Tick::from_seconds_f64(d + (elapsed - pos) / k))
|
||||
}
|
||||
|
||||
/// Size of a layer's source in layer pixels.
|
||||
pub fn source_size(project: &Project, layer: &Layer) -> (u32, u32) {
|
||||
match &layer.source {
|
||||
|
||||
+173
-18
@@ -9,6 +9,7 @@
|
||||
pub mod audio;
|
||||
pub mod cache;
|
||||
pub mod color;
|
||||
pub mod disk_cache;
|
||||
use color::Region;
|
||||
pub mod eval;
|
||||
pub mod masks;
|
||||
@@ -56,6 +57,16 @@ pub trait FootageSource: Send + Sync {
|
||||
fn cache_budget(&self) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
/// Vector footage (SVG) rasterised at `scale` × its pixel size, for Continuously Rasterize.
|
||||
/// `None` when the footage is not vector or cannot be read.
|
||||
fn vector_frame(&self, _item: ItemId, _footage: &Footage, _scale: f64) -> Option<Arc<Image>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Footage that can be rasterised at any scale (SVG).
|
||||
pub fn is_vector_footage(f: &Footage) -> bool {
|
||||
f.codec == "SVG"
|
||||
}
|
||||
|
||||
/// Layer parameters and audio for effects (see [`effectcraft_effects::EffectHost`]).
|
||||
@@ -154,6 +165,22 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Essential Properties overrides of a precomp layer, evaluated at the context time.
|
||||
pub fn essential_overrides(ctx: &EvalCtx, layer: &Layer) -> Vec<effectcraft_project::essential::Override> {
|
||||
use effectcraft_project::essential;
|
||||
let over = essential::overridden(layer);
|
||||
let Some(g) = essential::group(layer).filter(|_| !over.is_empty()) else { return vec![] };
|
||||
let mut out = vec![];
|
||||
g.walk("", &mut |_, p| {
|
||||
if over.contains(&p.uid)
|
||||
&& let Some(control) = essential::control_of(&p.match_id)
|
||||
{
|
||||
out.push(essential::Override { control, value: ctx.value(layer, p) });
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// No footage available (renders footage layers as transparent).
|
||||
pub struct NoFootage;
|
||||
impl FootageSource for NoFootage {
|
||||
@@ -231,11 +258,22 @@ pub struct RenderOpts {
|
||||
/// Region of interest `[x, y, w, h]` in comp pixels (viewer only): the top-level frame covers
|
||||
/// just this rectangle, and only its pixels are composited.
|
||||
pub roi: Option<[f64; 4]>,
|
||||
/// Which proxies stand in for footage and compositions (Render Settings ▸ Proxy Use).
|
||||
pub proxy: effectcraft_project::render_queue::ProxyUse,
|
||||
}
|
||||
|
||||
impl Default for RenderOpts {
|
||||
fn default() -> Self {
|
||||
RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None, roi: None, backend: Backend::Cpu }
|
||||
RenderOpts {
|
||||
scale: 1.0,
|
||||
motion_blur: true,
|
||||
guides: false,
|
||||
draft: false,
|
||||
view: None,
|
||||
roi: None,
|
||||
backend: Backend::Cpu,
|
||||
proxy: effectcraft_project::render_queue::ProxyUse::CurrentSettings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,6 +370,10 @@ impl<'a> Renderer<'a> {
|
||||
if styles::active(ctx, layer) {
|
||||
return None;
|
||||
}
|
||||
// Instance overrides (Essential Properties) need the nested comp rendered on its own.
|
||||
if !effectcraft_project::essential::overridden(layer).is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(*item)
|
||||
}
|
||||
|
||||
@@ -387,7 +429,12 @@ impl<'a> Renderer<'a> {
|
||||
/// stays sharp when scaled up).
|
||||
pub fn raster_scale(&self, ctx: &EvalCtx, layer: &Layer) -> f64 {
|
||||
let s = self.opts.scale;
|
||||
if !layer.switches.collapse || !matches!(layer.source, LayerSource::Text | LayerSource::Shape) {
|
||||
let vector = match layer.source {
|
||||
LayerSource::Text | LayerSource::Shape => true,
|
||||
LayerSource::Footage { item } => matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f)),
|
||||
_ => false,
|
||||
};
|
||||
if !layer.switches.collapse || !vector {
|
||||
return s;
|
||||
}
|
||||
let (l2c, _) = ctx.layer_to_comp(layer);
|
||||
@@ -431,7 +478,7 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
|
||||
fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> {
|
||||
EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr }
|
||||
EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage) }
|
||||
}
|
||||
|
||||
/// The accelerator to use for this render, if any (see [`Backend`]).
|
||||
@@ -655,7 +702,22 @@ impl<'a> Renderer<'a> {
|
||||
if self.project.comp_contains(*item, ctx.comp_id) {
|
||||
return None;
|
||||
}
|
||||
let sub = self.nested();
|
||||
// A composition proxy (a rendered still or movie) stands in for the comp.
|
||||
if let Some(pf) = self.proxy_for(*item)
|
||||
&& let Some(nc) = self.project.comp(*item)
|
||||
{
|
||||
let img = self.footage.frame(*item, pf, ctx.source_time(layer))?;
|
||||
return Some(self.footage_buf(&img, pf, nc.width, nc.height));
|
||||
}
|
||||
// Essential Properties overrides render the nested comp with this instance's
|
||||
// values.
|
||||
let ov = essential_overrides(ctx, layer);
|
||||
let tmp = if ov.is_empty() { None } else { effectcraft_project::essential::with_overrides(self.project, *item, &ov) };
|
||||
let base = self.nested();
|
||||
let sub = match &tmp {
|
||||
Some(p) => Renderer { project: p, ..base },
|
||||
None => base,
|
||||
};
|
||||
let lt = ctx.source_time(layer);
|
||||
// Frame blending between the nested comp's frames (time-stretched/remapped).
|
||||
let mode = frame_blend_mode(ctx, layer);
|
||||
@@ -677,21 +739,22 @@ impl<'a> Renderer<'a> {
|
||||
if !f.has_video {
|
||||
return None;
|
||||
}
|
||||
let lt = ctx.source_time(layer);
|
||||
let img = self.footage_frame(ctx, layer, *item, f, lt)?;
|
||||
let k = if img.width > 0 { f.width.max(1) as f64 / img.width as f64 } else { 1.0 };
|
||||
// Keep native pixels when downsampling is small; resample otherwise.
|
||||
let mut buf = if s < 0.75 {
|
||||
let w = ((f.width as f64 * s).round() as u32).max(1);
|
||||
let h = ((f.height as f64 * s).round() as u32).max(1);
|
||||
Buf { img: effectcraft_raster::resample(&img, w, h), offset: [0.0; 2], scale: s }
|
||||
} else {
|
||||
Buf { img: (*img).clone(), offset: [0.0; 2], scale: 1.0 / k }
|
||||
};
|
||||
if let Some(c) = self.pipe.media_in(f.color_profile) {
|
||||
color::convert(&mut buf.img, &c);
|
||||
// Continuously rasterised vector footage: drawn at the on-screen scale.
|
||||
if layer.switches.collapse && is_vector_footage(f) {
|
||||
let k = self.raster_scale(ctx, layer);
|
||||
if let Some(img) = self.footage.vector_frame(*item, f, k) {
|
||||
let mut buf = Buf { img: (*img).clone(), offset: [0.0; 2], scale: k };
|
||||
if let Some(c) = self.pipe.media_in(f.color_profile) {
|
||||
color::convert(&mut buf.img, &c);
|
||||
}
|
||||
return Some(buf);
|
||||
}
|
||||
}
|
||||
Some(buf)
|
||||
let lt = ctx.source_time(layer);
|
||||
// The proxy is decoded instead, at its own size, and fills the footage's frame.
|
||||
let pf = self.proxy_for(*item).unwrap_or(f);
|
||||
let img = self.footage_frame(ctx, layer, *item, pf, lt)?;
|
||||
Some(self.footage_buf(&img, pf, f.width, f.height))
|
||||
}
|
||||
LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))),
|
||||
LayerSource::Shape => layer.props.sub("contents").map(|c| self.authored(shapes::render(ctx, layer, c, self.raster_scale(ctx, layer)))),
|
||||
@@ -699,6 +762,32 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The proxy footage used for `item` under this render's Proxy Use, if any.
|
||||
pub fn proxy_for(&self, item: ItemId) -> Option<&'a Footage> {
|
||||
let it = self.project.item(item)?;
|
||||
let px = it.proxy.as_deref()?;
|
||||
(self.opts.proxy.uses(matches!(it.kind, ItemKind::Comp(_)), px.enabled) && px.footage.has_video).then_some(&px.footage)
|
||||
}
|
||||
|
||||
/// A decoded frame of `f` as a layer buffer covering `w`×`h` layer pixels (the footage's
|
||||
/// nominal size; proxies are scaled up to it), converted from its colour profile.
|
||||
fn footage_buf(&self, img: &Image, f: &Footage, w: u32, h: u32) -> Buf {
|
||||
let s = self.opts.scale;
|
||||
let k = if img.width > 0 { w.max(1) as f64 / img.width as f64 } else { 1.0 };
|
||||
// Keep native pixels when downsampling is small; resample otherwise.
|
||||
let mut buf = if s < 0.75 && k <= 1.0 + 1e-9 {
|
||||
let rw = ((w as f64 * s).round() as u32).max(1);
|
||||
let rh = ((h as f64 * s).round() as u32).max(1);
|
||||
Buf { img: effectcraft_raster::resample(img, rw, rh), offset: [0.0; 2], scale: s }
|
||||
} else {
|
||||
Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 / k }
|
||||
};
|
||||
if let Some(c) = self.pipe.media_in(f.color_profile) {
|
||||
color::convert(&mut buf.img, &c);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// Authored colours into the working space (linear working spaces).
|
||||
fn authored(&self, mut buf: Buf) -> Buf {
|
||||
if let Some(c) = self.pipe.authored_in() {
|
||||
@@ -711,6 +800,21 @@ impl<'a> Renderer<'a> {
|
||||
/// when the layer's Frame Blending switch (and the comp's Enable Frame Blending) is on and
|
||||
/// `t` falls between frames (footage rate ≠ comp rate, time stretch, time remapping).
|
||||
fn footage_frame(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
// Interpret Footage ▸ Separate Fields: each field is a frame at twice the rate.
|
||||
if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
|
||||
let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den);
|
||||
let i = field_rate.frame_at(t).max(0);
|
||||
let img = self.footage.frame(item, f, f.frame_rate.tick_of(i / 2))?;
|
||||
let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst;
|
||||
// Upper field = even lines (0, 2, …).
|
||||
let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 };
|
||||
return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f)));
|
||||
}
|
||||
let img = self.footage_frame_raw(ctx, layer, item, f, t)?;
|
||||
if f.invert_alpha || f.linear_light { Some(Arc::new(interpret_pixels(&img, f))) } else { Some(img) }
|
||||
}
|
||||
|
||||
fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
let mode = frame_blend_mode(ctx, layer);
|
||||
if mode != FrameBlend::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
|
||||
let (i, w) = frame_position(t, f.frame_rate);
|
||||
@@ -1317,6 +1421,57 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
|
||||
/// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on.
|
||||
/// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even /
|
||||
/// upper, 1 = odd / lower) kept, the other lines interpolated from their neighbours.
|
||||
pub fn field_frame(img: &Image, parity: usize) -> Image {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let mut out = img.clone();
|
||||
if h < 2 {
|
||||
return out;
|
||||
}
|
||||
for y in 0..h {
|
||||
if y % 2 == parity {
|
||||
continue;
|
||||
}
|
||||
let above = y.checked_sub(1);
|
||||
let below = (y + 1 < h).then_some(y + 1);
|
||||
for x in 0..w {
|
||||
let px = match (above, below) {
|
||||
(Some(a), Some(b)) => {
|
||||
let (p, q) = (img.data[a * w + x], img.data[b * w + x]);
|
||||
[(p[0] + q[0]) * 0.5, (p[1] + q[1]) * 0.5, (p[2] + q[2]) * 0.5, (p[3] + q[3]) * 0.5]
|
||||
}
|
||||
(Some(a), None) => img.data[a * w + x],
|
||||
(None, Some(b)) => img.data[b * w + x],
|
||||
(None, None) => img.data[y * w + x],
|
||||
};
|
||||
out.data[y * w + x] = px;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Interpret Footage ▸ Invert Alpha and Interpret As Linear Light on decoded (premultiplied)
|
||||
/// pixels.
|
||||
pub fn interpret_pixels(img: &Image, f: &Footage) -> Image {
|
||||
if !f.invert_alpha && !f.linear_light {
|
||||
return img.clone();
|
||||
}
|
||||
let space = f.color_profile.unwrap_or(effectcraft_color::ColorSpace::Srgb);
|
||||
let mut out = img.clone();
|
||||
for p in out.data.iter_mut() {
|
||||
let a = p[3];
|
||||
let mut c = if a > 1e-6 { [p[0] / a, p[1] / a, p[2] / a] } else { [0.0; 3] };
|
||||
if f.linear_light {
|
||||
// The file's values are linear light: encode them like the rest of the footage.
|
||||
c = c.map(|v| space.encode(v));
|
||||
}
|
||||
let a = if f.invert_alpha { 1.0 - a } else { a };
|
||||
*p = [c[0] * a, c[1] * a, c[2] * a, a];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn frame_blend_mode(ctx: &EvalCtx, layer: &Layer) -> FrameBlend {
|
||||
if ctx.comp.enable_frame_blending { layer.switches.frame_blend } else { FrameBlend::Off }
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ fn audio_footage() -> Footage {
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ fn footage(profile: Option<ColorSpace>) -> Footage {
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: profile,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ fn setup(mode: FrameBlend, size: (u32, u32)) -> (Project, ItemId) {
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
};
|
||||
let fid = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(f));
|
||||
let mut l = build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, size, None);
|
||||
|
||||
@@ -303,7 +303,7 @@ fn extruded_text_renders_as_a_mesh() {
|
||||
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
|
||||
set(l, "transform/rotationY", Value::Scalar(40.0));
|
||||
});
|
||||
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None };
|
||||
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
|
||||
let l = &ctx.comp.layers[0];
|
||||
let params = scene::extrusion_params(&ctx, l).unwrap();
|
||||
let meshes = scene::extruded_meshes(&ctx, l, ¶ms);
|
||||
|
||||
Reference in New Issue
Block a user