Reload Footage shows new pixels and keeps Photoshop layers; Reload Footage Changed on Disk

Reload Footage (Cmd+Alt+L) updated an item's metadata but left the media
pool's decoded frames and the layer cache in place, so the old image kept
rendering until Edit > Purge. It also replaced the footage with a probe of
the merged document, so a Photoshop layer item lost its layer and its
Retain Layer Sizes bounds.

Reload now re-reads only what the file decides (size, a movie's duration,
streams and frame rate, codec, a data file's text), keeps the layer or
page and the interpretation, finds a Photoshop layer again by name, and
makes the media layer forget the file (FootageSource::forget, which also
drops the EXR aux-channel cache) and clears the layer cache.

Settings > Import > Reload Footage Changed on Disk (on by default): the
desktop app stamps footage files (size + modification time) on a worker
thread every two seconds and when the window comes back to the front, and
reloads what changed. Not an undo step and it doesn't modify the project;
a file still being written is retried on the next check.
footage.reloadChanged runs one check (CLI, MCP, agents).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
KattandClaude Opus 5.5 committed 2026-10-10 16:36:50 +05:30
1 parent 813c7c4650
commit e6d37c2fa6
10 files changed
+522 -37

No files matched your search

+10 -35
View File
@@ -911,41 +911,7 @@ fn replace_with_solid(s: &mut Session, p: &Value) -> Result<Value> {
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
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<CommandSpec> {
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...",
+266
View File
@@ -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<Mutex<Option<Vec<(String, Option<Stamp>)>>>>;
/// The files footage items read: each item's file and every frame of an image sequence.
fn watched(s: &Session) -> BTreeSet<String> {
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<usize> {
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<String>)> {
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<String>) {
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<Stamp>)>) -> crate::Result<Value> {
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<ItemId> = 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<Value> {
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<crate::Result<Value>> {
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))
}
+15
View File
@@ -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<String, footage_reload::Stamp>,
/// The stamps being taken on a worker thread ([`footage_reload::start_scan`]).
pub footage_scan: Option<footage_reload::Scan>,
}
/// 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,
}
}
}
+4
View File
@@ -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<Page> {
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",
+170
View File
@@ -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<u8> {
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<ItemId> = 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<u64> = 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());
}
+13 -2
View File
@@ -166,10 +166,21 @@ pub fn layered_image(bytes: &[u8]) -> Option<image::DynamicImage> {
type Cache = Mutex<Vec<(String, Option<Arc<AuxChannels>>)>>;
fn cache() -> &'static Cache {
static CACHE: OnceLock<Cache> = 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<Arc<[u8]>>) -> Option<Arc<AuxChannels>> {
static CACHE: OnceLock<Cache> = 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;
}
+5
View File
@@ -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<usize> {
Some(self.budget())
}
+4
View File
@@ -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<std::path::PathBuf>) {}
+28
View File
@@ -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
+7
View File
@@ -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