diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 6a561461..2745f176 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -911,41 +911,7 @@ fn replace_with_solid(s: &mut Session, p: &Value) -> Result { fn reload(s: &mut Session, p: &Value) -> Result { let items = items_p(s, p); - if s.importer.is_none() { - return Err(EngineError::Other("media import is not available in this build".into())); - } - let mut updates = vec![]; - for i in &items { - if let Some(ItemKind::Footage(f)) = s.project.item(*i).map(|x| &x.kind) { - // An image sequence picks up frames added to (or removed from) its run. - let mut nf = match s.probe_footage(&f.path) { - Ok(n) => n, - Err(_) => Footage { missing: true, ..f.clone() }, - }; - // Keep the interpretation. - nf.alpha = f.alpha; - nf.loop_count = f.loop_count; - nf.pixel_aspect = f.pixel_aspect; - nf.start_timecode = f.start_timecode; - if nf.kind == FootageKind::Sequence && f.kind == FootageKind::Sequence { - nf.frame_rate = f.frame_rate; - nf.native_rate = f.native_rate; - nf.alphabetical = f.alphabetical; - nf.sync_sequence_duration(); - } - updates.push((*i, nf)); - } - } - let n = updates.len(); - s.edit("Reload Footage", None, |proj, _| { - for (i, f) in updates { - if let Some(it) = proj.item_mut(i) { - it.kind = ItemKind::Footage(f); - } - } - Ok(()) - })?; - s.events.push(crate::Event::PurgeCaches); + let n = crate::footage_reload::reload_items(s, &items)?; Ok(json!({"reloaded": n})) } @@ -1009,6 +975,15 @@ pub fn specs() -> Vec { has_items, crate::footage_check::command ), + cmd!( + "footage.reloadChanged", + "Reload Changed Footage", + [], + None, + "{} — reload the footage whose file changed on disk since it was last seen (size or modification time; Settings ▸ Import ▸ Reload Footage Changed on Disk runs this in the background); files seen for the first time are only remembered. Returns {files, reloaded}", + has_items, + crate::footage_reload::command + ), cmd!( "file.runScript", "Run Script File...", diff --git a/crates/engine/src/footage_reload.rs b/crates/engine/src/footage_reload.rs new file mode 100644 index 00000000..0d26a45c --- /dev/null +++ b/crates/engine/src/footage_reload.rs @@ -0,0 +1,266 @@ +//! Footage that changed on disk: File ▸ Reload Footage (Cmd+Alt+L) and Settings ▸ Import ▸ +//! Reload Footage Changed on Disk. +//! +//! Reloading re-reads what the file decides (its size, a movie's duration, streams and frame +//! rate, the codec, a data file's text) and keeps what the project decided: the Photoshop layer +//! or PDF page an item shows and its interpretation (alpha, conformed frame rate, pixel aspect, +//! fields, colour). A Photoshop layer is found again by name when layers were added or removed +//! around it. The media layer then forgets the file and the layer cache is emptied, so the next +//! render reads the new pixels. +//! +//! The automatic part compares each footage file's size and modification time with what they +//! were when the file was last seen. The desktop app takes those stamps every two seconds (and +//! when its window comes back to the front) on a worker thread, so a file on a slow network share +//! never holds up the interface ([`start_scan`], [`poll_scan`]); `footage.reloadChanged` takes them +//! on the spot (CLI, agents, tests). Like the footage check after File ▸ Open, an automatic reload +//! records the state of the disk: it is not an undo step and doesn't modify the project. + +use std::collections::BTreeSet; +use std::sync::{Arc, Mutex}; + +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, SourceLayer}; +use serde_json::{Value, json}; + +use crate::Session; + +/// A file's size and modification time (see [`crate::Services::stamp`]). +pub type Stamp = (u64, u128); + +/// Stamps taken on a worker thread, waiting for [`poll_scan`]. +pub type Scan = Arc)>>>>; + +/// The files footage items read: each item's file and every frame of an image sequence. +fn watched(s: &Session) -> BTreeSet { + let mut paths = BTreeSet::new(); + for it in s.project.items.values() { + if let ItemKind::Footage(f) = &it.kind { + if !f.path.is_empty() { + paths.insert(f.path.clone()); + } + paths.extend(f.sequence.iter().cloned()); + } + } + paths +} + +/// Size and position in the stack of Photoshop layer `l` in the file at `path` now: the layer +/// at its recorded index when the name still matches, else the first layer with its name. +fn psd_layer(s: &Session, path: &str, l: &SourceLayer) -> Option<(u32, Option<(u32, u32)>)> { + let psd = effectcraft_psd::Psd::parse(s.services.read_file(path).ok()?).ok()?; + let found = psd.layers.get(l.index as usize).filter(|x| x.name == l.name).or_else(|| psd.layers.iter().find(|x| x.name == l.name))?; + let size = (!found.rect.is_empty()).then(|| (found.rect.width(), found.rect.height())); + Some((u32::try_from(found.index).ok()?, size)) +} + +/// Footage `f` as its file is now, given the importer's fresh look at the file (`probed`). +fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage { + let mut nf = f.clone(); + nf.missing = false; + // An image sequence picks up frames added to (or removed from) its run; its frame rate and + // order are the interpretation's. + if f.kind == FootageKind::Sequence { + if probed.kind == FootageKind::Sequence { + (nf.sequence, nf.width, nf.height) = (probed.sequence, probed.width, probed.height); + nf.sync_sequence_duration(); + } + return nf; + } + nf.codec = probed.codec; + nf.has_video = probed.has_video; + nf.has_audio = probed.has_audio; + if matches!(f.kind, FootageKind::Video | FootageKind::Audio) { + nf.duration = probed.duration; + // Interpret Footage ▸ Conform to frame rate stays; the file's own rate is refreshed. + match f.native_rate { + Some(_) => nf.native_rate = Some(probed.native_rate.unwrap_or(probed.frame_rate)), + None => nf.frame_rate = probed.frame_rate, + } + } + if f.kind == FootageKind::Data { + nf.data = probed.data; + } + let (mut w, mut h) = (probed.width, probed.height); + match &f.layer { + // A smart object's size is its embedded file's, recorded at import. + Some(l) if l.embedded.is_some() => (w, h) = (f.width, f.height), + Some(l) => match psd_layer(s, &f.path, l) { + Some((index, size)) => { + if let Some(nl) = nf.layer.as_mut() { + nl.index = index; + } + // Retain Layer Sizes: the layer's own bounds; otherwise the document's. + if l.layer_size { + (w, h) = size.unwrap_or((f.width, f.height)); + } + } + // The layer is gone from the file: keep the item as it was. + None => (w, h) = (f.width, f.height), + }, + // A later page of a PDF: the probe measures the first page. + None if f.page != 0 => (w, h) = (f.width, f.height), + None => {} + } + (nf.width, nf.height) = (w, h); + nf +} + +/// Reload footage items `ids` from their files (File ▸ Reload Footage): an undo step; items +/// whose file can't be read are marked missing. Returns how many items were reloaded. +pub fn reload_items(s: &mut Session, ids: &[ItemId]) -> crate::Result { + Ok(reload(s, ids, true)?.0) +} + +/// Reload items `ids`. `undoable` makes it an edit; otherwise it records the disk without +/// modifying the project, and an item whose file can't be read now (another app is still +/// writing it) is left as it was. Returns the number reloaded and the files that couldn't be read +/// (only without `undoable`). +fn reload(s: &mut Session, ids: &[ItemId], undoable: bool) -> crate::Result<(usize, BTreeSet)> { + if s.importer.is_none() { + return Err(crate::EngineError::Other("media import is not available in this build".into())); + } + let mut updates = vec![]; + let mut paths = BTreeSet::new(); + let mut failed = BTreeSet::new(); + for i in ids { + if let Some(ItemKind::Footage(f)) = s.project.item(*i).map(|x| &x.kind) { + // Probed the way it imports by default, so a sequence is found as one. + let nf = match s.probe_footage(&f.path) { + Ok(p) => refreshed(s, f, p), + Err(_) if undoable => Footage { missing: true, ..f.clone() }, + Err(_) => { + failed.insert(f.path.clone()); + continue; + } + }; + paths.insert(f.path.clone()); + paths.extend(f.sequence.iter().chain(&nf.sequence).cloned()); + updates.push((*i, nf)); + } + } + let n = updates.len(); + if undoable { + s.edit("Reload Footage", None, |proj, _| { + for (i, f) in updates { + if let Some(it) = proj.item_mut(i) { + it.kind = ItemKind::Footage(f); + } + } + Ok(()) + })?; + } else { + let p = Arc::make_mut(&mut s.project); + let mut changed = false; + for (i, f) in updates { + if let Some(it) = p.item_mut(i) + && !matches!(&it.kind, ItemKind::Footage(cur) if *cur == f) + { + it.kind = ItemKind::Footage(f); + changed = true; + } + } + if changed { + let dirty = s.is_dirty(); + s.bump(); + if !dirty { + // Only the disk changed: the project isn't modified. + s.mark_saved(); + } + } + } + forget(s, &paths); + Ok((n, failed)) +} + +/// Make the media layer and the layer cache let go of `paths`, so renders read them again. +fn forget(s: &mut Session, paths: &BTreeSet) { + for p in paths { + s.footage.forget(p); + } + s.layer_cache.clear(); + s.events.push(crate::Event::PurgeCaches); +} + +/// Compare fresh stamps of the watched files with the ones last seen. A file seen for the first +/// time is only remembered; one whose stamp moved is reloaded: the items reading it get their +/// size and duration from it again and its pixels are read again. A file that can't be stamped +/// right now (an app saving by delete and rename) keeps its last stamp, and one that can't be +/// read yet (still being written) keeps its old stamp so the next scan tries again; missing +/// files are left to the footage check. Returns `{files, reloaded}`. +pub fn apply_stamps(s: &mut Session, now: Vec<(String, Option)>) -> crate::Result { + let before = std::mem::take(&mut s.footage_stamps); + let mut changed = BTreeSet::new(); + // Only what is watched now is remembered (files no longer used are dropped). + for (path, stamp) in now { + match (before.get(&path), stamp) { + (Some(old), None) => { + s.footage_stamps.insert(path, *old); + } + (old, Some(stamp)) => { + if old.is_some_and(|o| *o != stamp) { + changed.insert(path.clone()); + } + s.footage_stamps.insert(path, stamp); + } + (None, None) => {} + } + } + if changed.is_empty() { + return Ok(json!({"files": 0, "reloaded": 0})); + } + let ids: Vec = s.project.items.values().filter(|i| matches!(&i.kind, ItemKind::Footage(f) if changed.contains(&f.path))).map(|i| i.id).collect(); + let (reloaded, failed) = if ids.is_empty() { (0, BTreeSet::new()) } else { reload(s, &ids, false)? }; + for p in &failed { + if let Some(old) = before.get(p) { + s.footage_stamps.insert(p.clone(), *old); + } + changed.remove(p); + } + if changed.is_empty() { + return Ok(json!({"files": 0, "reloaded": 0})); + } + // Changed frames of image sequences (their items keep their metadata). + forget(s, &changed); + let files = changed.len(); + s.toast(if reloaded > 0 { + format!("Reloaded {reloaded} footage item(s) changed on disk") + } else { + format!("Reloaded {files} footage file(s) changed on disk") + }); + Ok(json!({"files": files, "reloaded": reloaded})) +} + +/// `footage.reloadChanged`: stamp the watched files now and reload the ones that changed. +pub(crate) fn command(s: &mut Session, _: &Value) -> crate::Result { + let services = s.services.clone(); + let now = watched(s).into_iter().map(|p| (p.clone(), services.stamp(&p))).collect(); + apply_stamps(s, now) +} + +/// Start stamping the watched files on a worker thread, unless a scan is still running (or the +/// build has no threads: the web app, whose footage lives in browser storage). +pub fn start_scan(s: &mut Session) { + if s.footage_scan.is_some() || cfg!(target_arch = "wasm32") { + return; + } + let paths = watched(s); + if paths.is_empty() { + return; + } + let services = s.services.clone(); + let slot: Scan = Arc::default(); + let out = slot.clone(); + let spawned = std::thread::Builder::new().name("footage-watch".into()).spawn(move || { + let stamps = paths.into_iter().map(|p| (p.clone(), services.stamp(&p))).collect(); + *out.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stamps); + }); + if spawned.is_ok() { + s.footage_scan = Some(slot); + } +} + +/// Apply a finished scan (`None` while there is none, or it is still running). +pub fn poll_scan(s: &mut Session) -> Option> { + let stamps = s.footage_scan.as_ref()?.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()?; + s.footage_scan = None; + Some(apply_stamps(s, stamps)) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 24ccf1af..1f97a346 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -16,6 +16,7 @@ pub mod commands; pub mod config; pub mod demo; pub mod footage_check; +pub mod footage_reload; pub mod guard; pub mod history; pub mod jobs; @@ -101,6 +102,13 @@ pub trait Services: Send + Sync { fn exists(&self, path: &str) -> bool { std::path::Path::new(path).exists() } + /// A file's size and modification time (nanoseconds since 1970), to notice that it changed + /// on disk (Settings ▸ Import ▸ Reload Footage Changed on Disk); `None` when it can't be read. + fn stamp(&self, path: &str) -> Option<(u64, u128)> { + let meta = std::fs::metadata(path).ok()?; + let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?; + Some((meta.len(), modified.as_nanos())) + } /// Keep a file the app made for itself (footage extracted from a template): like /// [`Services::write_file`], creating its folder, but never offered as a download (web: it /// goes to browser storage only). @@ -459,6 +467,11 @@ pub struct Session { /// event loop (the desktop app) turn this on; headless sessions run `footage.check` when /// they want it. pub check_footage_on_open: bool, + /// Each footage file's size and modification time when last seen (Settings ▸ Import ▸ + /// Reload Footage Changed on Disk, [`footage_reload`]). + pub footage_stamps: std::collections::HashMap, + /// The stamps being taken on a worker thread ([`footage_reload::start_scan`]). + pub footage_scan: Option, } /// A thumbnail render ([`Session::thumbnail_job`]): (width, height, RGBA8). @@ -544,6 +557,8 @@ impl Default for Session { next_task_id: 1, learn: None, check_footage_on_open: false, + footage_stamps: Default::default(), + footage_scan: None, } } } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 4ee6cc97..8f5ffa94 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -182,6 +182,9 @@ page!(Import { still_seconds: f64 = 5.0, sequence_fps: f64 = 30.0, report_missing_frames: bool = true, + /// Reload footage whose file another app changed (size or modification time), checked every + /// two seconds and when the window comes back to the front. + reload_changed_footage: bool = true, /// Interpret unlabeled alpha as: `ask`, `guess`, `ignore`, `straight`, `premultiplied`. unlabeled_alpha: String = "ask".into(), /// Default drag import as: `footage`, `comp`, `compLayerSizes`. @@ -1065,6 +1068,7 @@ pub fn pages() -> Vec { s("import.stillSeconds", "Still Duration", Kind::Float(0.04, 86_400.0, "s"), true), s("import.sequenceFps", "Sequence Footage", Kind::Float(1.0, 999.0, "frames per second"), true), s("import.reportMissingFrames", "Report Missing Frames", B, true), + s("import.reloadChangedFootage", "Reload Footage Changed on Disk", B, true), s( "import.unlabeledAlpha", "Interpret Unlabeled Alpha As", diff --git a/crates/host/tests/footage_reload.rs b/crates/host/tests/footage_reload.rs new file mode 100644 index 00000000..21b123c9 --- /dev/null +++ b/crates/host/tests/footage_reload.rs @@ -0,0 +1,170 @@ +//! Footage changed on disk by another app, through the fully wired session (media decoding +//! included): File ▸ Reload Footage shows the new pixels and keeps a Photoshop layer item on its +//! layer, and `footage.reloadChanged` (Settings ▸ Import ▸ Reload Footage Changed on Disk) finds +//! changed files by itself. + +use effectcraft_engine::render::RenderOpts; +use effectcraft_engine::{Session, footage_reload}; +use effectcraft_project::{Footage, ItemId, ItemKind}; +use effectcraft_psd::Rect; +use effectcraft_psd::write::*; +use effectcraft_time::Tick; +use serde_json::json; + +fn tmp(name: &str) -> String { + let d = std::env::temp_dir().join(format!("effectcraft-host-reload-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d.join(name).to_string_lossy().to_string() +} + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; +const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0]; +const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; + +/// A one-colour document; `extra` adds a hidden layer, so the file's size changes too. +fn flat(rgba: [f32; 4], extra: bool) -> Vec { + let mut d = WDoc::new(64, 48); + d.layers = vec![WLayer::solid("Background", Rect::new(0, 0, 64, 48), rgba)]; + if extra { + let mut hidden = WLayer::solid("Hidden", Rect::new(0, 0, 8, 8), RED); + hidden.hidden = true; + d.layers.push(hidden); + } + d.composite = Some(vec![rgba; 64 * 48]); + write(&d) +} + +/// Write `bytes` to `path` so its modification time moves even on coarse file systems. +fn save(path: &str, bytes: &[u8]) { + std::thread::sleep(std::time::Duration::from_millis(30)); + std::fs::write(path, bytes).unwrap(); +} + +fn footage(s: &Session, id: ItemId) -> Footage { + match &s.project.item(id).unwrap().kind { + ItemKind::Footage(f) => f.clone(), + k => panic!("not footage: {k:?}"), + } +} + +/// The colour (straight RGB) at `(x, y)` of comp `cid` at time 0. +fn pixel(s: &Session, cid: ItemId, x: usize, y: usize) -> [f32; 3] { + let img = s.render(cid, Tick::ZERO, RenderOpts::default()); + let p = img.data[y * img.width as usize + x]; + [p[0], p[1], p[2]].map(|c| if p[3] > 0.0 { c / p[3] } else { 0.0 }) +} + +fn near(a: [f32; 3], b: [f32; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.02) +} + +/// A flat file as footage in a comp of its size: (session, footage item, comp). +fn flat_in_comp(path: &str) -> (Session, ItemId, ItemId) { + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap(); + let item = ItemId(r["items"][0].as_u64().unwrap()); + s.execute_checked("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 30, "duration": 1})).unwrap(); + s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap(); + let cid = s.active_comp_id().unwrap(); + (s, item, cid) +} + +#[test] +fn reload_footage_shows_the_new_pixels() { + let path = tmp("flat.psd"); + std::fs::write(&path, flat(RED, false)).unwrap(); + let (mut s, item, cid) = flat_in_comp(&path); + assert!(near(pixel(&s, cid, 32, 24), RED)); + // Same size, new colour: the frames decoded before must not be shown again. + save(&path, &flat(BLUE, false)); + let r = s.execute_checked("file.reloadFootage", json!({"items": [item.0]})).unwrap(); + assert_eq!(r["reloaded"], 1); + assert!(near(pixel(&s, cid, 32, 24), BLUE), "{:?}", pixel(&s, cid, 32, 24)); +} + +#[test] +fn reload_keeps_a_photoshop_layer_item_on_its_layer() { + let path = tmp("layers.psd"); + let mut d = WDoc::new(64, 48); + d.layers = vec![WLayer::solid("Background", Rect::new(0, 0, 64, 48), RED), WLayer::solid("Patch", Rect::new(0, 0, 32, 48), BLUE)]; + std::fs::write(&path, write(&d)).unwrap(); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "compositionLayerSizes"})).unwrap(); + let cid = ItemId(r["comps"][0].as_u64().unwrap()); + let items: Vec = r["items"].as_array().unwrap().iter().filter_map(|v| v.as_u64()).map(ItemId).collect(); + let patch = *items.iter().find(|i| s.project.item(**i).is_some_and(|it| it.name.starts_with("Patch/"))).unwrap(); + assert_eq!((footage(&s, patch).width, footage(&s, patch).layer.unwrap().index), (32, 1)); + assert!(near(pixel(&s, cid, 8, 24), BLUE)); + // In the other app: a layer added under the patch, and the patch narrower and green. + d.layers = vec![ + WLayer::solid("Background", Rect::new(0, 0, 64, 48), RED), + WLayer::solid("Added", Rect::new(40, 0, 8, 8), RED), + WLayer::solid("Patch", Rect::new(0, 0, 24, 48), GREEN), + ]; + save(&path, &write(&d)); + let footage_items: Vec = items.iter().filter(|i| matches!(s.project.item(**i).map(|x| &x.kind), Some(ItemKind::Footage(_)))).map(|i| i.0).collect(); + s.execute_checked("file.reloadFootage", json!({"items": footage_items})).unwrap(); + let f = footage(&s, patch); + let l = f.layer.clone().expect("still one layer of the file"); + assert_eq!((l.name.as_str(), l.index, f.width, f.height), ("Patch", 2, 24, 48)); + assert!(!f.missing); + assert!(near(pixel(&s, cid, 8, 24), GREEN), "{:?}", pixel(&s, cid, 8, 24)); +} + +#[test] +fn changed_footage_reloads_by_itself_without_an_undo_step() { + let path = tmp("auto.psd"); + std::fs::write(&path, flat(RED, false)).unwrap(); + let (mut s, _, cid) = flat_in_comp(&path); + s.mark_saved(); + let undo = s.history.undo.len(); + // First look: remembered, nothing to reload. + assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0})); + save(&path, &flat(BLUE, true)); + assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 1, "reloaded": 1})); + assert!(near(pixel(&s, cid, 32, 24), BLUE)); + assert_eq!(s.history.undo.len(), undo, "the disk changed, not the project"); + assert!(!s.is_dirty()); + assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0})); +} + +#[test] +fn a_file_still_being_written_is_tried_again() { + let path = tmp("partial.psd"); + std::fs::write(&path, flat(RED, false)).unwrap(); + let (mut s, item, cid) = flat_in_comp(&path); + s.execute_checked("footage.reloadChanged", json!({})).unwrap(); + // Another app has only written the start of the file so far. + let full = flat(BLUE, true); + save(&path, &full[..12]); + assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0})); + assert!(!footage(&s, item).missing, "a file being written isn't missing"); + // Meanwhile it renders as whatever part of it can be decoded (here nothing); the next scan, + // after the other app has finished, reads it whole. + save(&path, &full); + assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 1, "reloaded": 1})); + assert!(near(pixel(&s, cid, 32, 24), BLUE)); +} + +#[test] +fn background_scan_reloads_changed_footage() { + let path = tmp("scan.psd"); + std::fs::write(&path, flat(RED, false)).unwrap(); + let (mut s, _, cid) = flat_in_comp(&path); + let scan = |s: &mut Session| { + footage_reload::start_scan(s); + let t0 = std::time::Instant::now(); + loop { + if let Some(r) = footage_reload::poll_scan(s) { + return r.unwrap(); + } + assert!(t0.elapsed().as_secs() < 10, "scan never finished"); + std::thread::yield_now(); + } + }; + assert_eq!(scan(&mut s), json!({"files": 0, "reloaded": 0})); + save(&path, &flat(GREEN, true)); + assert_eq!(scan(&mut s), json!({"files": 1, "reloaded": 1})); + assert!(near(pixel(&s, cid, 32, 24), GREEN)); + assert!(s.footage_scan.is_none()); +} diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index 93836dcc..73b02ddb 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -166,10 +166,21 @@ pub fn layered_image(bytes: &[u8]) -> Option { type Cache = Mutex>)>>; +fn cache() -> &'static Cache { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(Vec::new())) +} + +/// Drop the cached channels of `path` (the file changed on disk). +pub(crate) fn forget(path: &str) { + if let Ok(mut c) = cache().lock() { + c.retain(|(p, _)| p != path); + } +} + /// Read (and cache, a few files) the channels of the EXR at `path` via `read`. pub(crate) fn cached(path: &str, read: impl FnOnce() -> Option>) -> Option> { - static CACHE: OnceLock = OnceLock::new(); - let cache = CACHE.get_or_init(|| Mutex::new(Vec::new())); + let cache = cache(); if let Some(v) = cache.lock().ok().and_then(|c| c.iter().find(|(p, _)| p == path).map(|(_, v)| v.clone())) { return v; } diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 3df9ec45..6c1fc83c 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -312,6 +312,8 @@ impl MediaPool { c.lru.remove(&k); } c.last.remove(path); + drop(c); + crate::exr_channels::forget(path); } /// Provide the contents of `path` from memory (used instead of the file system; web builds). @@ -693,6 +695,9 @@ impl FootageSource for MediaPool { fn purge(&self) { self.clear_frames(); } + fn forget(&self, path: &str) { + MediaPool::forget(self, path); + } fn cache_budget(&self) -> Option { Some(self.budget()) } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 591213e6..13b0d938 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -61,6 +61,10 @@ pub trait FootageSource: Send + Sync { /// Drop the decoded frames it keeps (Edit ▸ Purge ▸ All Memory / Image Cache Memory): the /// next reads decode again. Sources without a cache ignore it. fn purge(&self) {} + /// The file at `path` changed on disk (Reload Footage): drop what it keeps of it (decoded + /// frames, open decoders, parsed models), so the next reads see the new contents. Sources + /// without a cache ignore it. + fn forget(&self, _path: &str) {} /// Settings ▸ Disk ▸ Conformed Audio Folder: where decoded audio is kept between reads /// (`None` = off); sources that don't decode audio ignore it. fn set_conform_folder(&self, _folder: Option) {} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 82be8a32..8da305f7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1544,6 +1544,34 @@ impl EffectcraftApp { } ctx.request_repaint_after(std::time::Duration::from_secs(10)); } + self.tick_footage_reload(ctx, now); + } + + /// Settings ▸ Import ▸ Reload Footage Changed on Disk: every two seconds, and as soon as the + /// window comes back to the front (after saving in another app), stamp the footage files on a + /// worker thread and reload the ones that changed. + fn tick_footage_reload(&mut self, ctx: &egui::Context, now: f64) { + use effectcraft_engine::footage_reload; + if let Some(Err(e)) = footage_reload::poll_scan(&mut self.session) { + self.ui.status = e.to_string(); + } + let (last_key, focus_key) = (egui::Id::new("footageReload.lastScan"), egui::Id::new("footageReload.focused")); + let focused = ctx.input(|i| i.focused); + let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused); + ctx.data_mut(|d| d.insert_temp(focus_key, focused)); + if !self.session.prefs.import.reload_changed_footage || self.session.project.items.is_empty() { + return; + } + let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(0.0); + if now - last >= 2.0 || (focused && !was_focused) { + ctx.data_mut(|d| d.insert_temp(last_key, now)); + footage_reload::start_scan(&mut self.session); + } + if self.session.footage_scan.is_some() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } else { + ctx.request_repaint_after(std::time::Duration::from_secs(2)); + } } /// Take the pending synthetic input (from `ui.click`, `ui.key`, …). Hosts that don't call diff --git a/docs/preferences.md b/docs/preferences.md index 9e3d4147..75c606b7 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -109,6 +109,13 @@ Commands (CLI, MCP, control channel): - `type.fontNamesInEnglish`: off, fonts with a native-language family name show it - `import.reportMissingFrames`: gaps in an image sequence's numbering are reported on import (they show as colour-bar placeholders, as in After Effects; see [footage.md](footage.md)) +- `import.reloadChangedFootage`: Reload Footage Changed on Disk (on by default). Every two seconds, + and as soon as the window comes back to the front, the desktop app compares each footage file's + size and modification time with what they were, and reloads what another app saved: a + Photoshop file edited in PhotoCraft or Photoshop shows its new pixels without File ▸ Reload + Footage. It is not an undo step and doesn't modify the project; a layer item stays on its layer + (found again by name), and a file still being written is tried again on the next check. + `footage.reloadChanged` runs the same check once (CLI, agents) - `import.unlabeledAlpha`: alpha of TGA / TIFF / movie footage (Ask opens Interpret Footage; Guess takes premultiplied for movies, straight for stills) - `import.dragImportAs`: layered files dropped on the window import as footage or a comp