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 <noreply@anthropic.com>
This commit is contained in:
Katt
2026-10-10 16:36:50 +05:30
co-authored by Claude Opus 5.5
parent 813c7c4650
commit e6d37c2fa6
10 changed files with 522 additions and 37 deletions
+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",