diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 2745f176..aa9e1be9 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -980,7 +980,7 @@ pub fn specs() -> Vec { "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}", + "{} — reload the footage whose file changed on disk since it was last seen (size or modification time; Settings ▸ Import ▸ Automatically Reload Footage runs this in the background; image sequences only with All Footage); files seen for the first time are only remembered. Returns {files, reloaded}", has_items, crate::footage_reload::command ), diff --git a/crates/engine/src/footage_reload.rs b/crates/engine/src/footage_reload.rs index 0d26a45c..3414cc00 100644 --- a/crates/engine/src/footage_reload.rs +++ b/crates/engine/src/footage_reload.rs @@ -29,11 +29,17 @@ 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. +/// The files footage items read: each item's file and every frame of an image sequence. Image +/// sequences count only with Automatically Reload Footage set to All Footage (After Effects' +/// default, Non-Sequence Footage, leaves them alone: thousands of frames on a share add up). fn watched(s: &Session) -> BTreeSet { + let sequences = s.prefs.import.auto_reload_footage == "all"; let mut paths = BTreeSet::new(); for it in s.project.items.values() { if let ItemKind::Footage(f) = &it.kind { + if !f.sequence.is_empty() && !sequences { + continue; + } if !f.path.is_empty() { paths.insert(f.path.clone()); } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index e42af569..39c10315 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -182,9 +182,10 @@ 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, + /// Automatically Reload Footage whose file another app changed (size or modification time), + /// checked every two seconds and when the window comes back to the front: `off`, + /// `nonSequence` (the default, as in After Effects: image sequences are left alone) or `all`. + auto_reload_footage: String = "nonSequence".into(), /// Interpret unlabeled alpha as: `ask`, `guess`, `ignore`, `straight`, `premultiplied`. unlabeled_alpha: String = "ask".into(), /// Default drag import as: `footage`, `comp`, `compLayerSizes`. @@ -1087,7 +1088,12 @@ 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.autoReloadFootage", + "Automatically Reload Footage", + Kind::Choice(&[("Off", "off"), ("Non-Sequence Footage", "nonSequence"), ("All Footage", "all")]), + true, + ), s( "import.unlabeledAlpha", "Interpret Unlabeled Alpha As", diff --git a/crates/host/tests/footage_reload.rs b/crates/host/tests/footage_reload.rs index 21b123c9..109ea236 100644 --- a/crates/host/tests/footage_reload.rs +++ b/crates/host/tests/footage_reload.rs @@ -1,6 +1,6 @@ //! 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 +//! layer, and `footage.reloadChanged` (Settings ▸ Import ▸ Automatically Reload Footage) finds //! changed files by itself. use effectcraft_engine::render::RenderOpts; @@ -168,3 +168,24 @@ fn background_scan_reloads_changed_footage() { assert!(near(pixel(&s, cid, 32, 24), GREEN)); assert!(s.footage_scan.is_none()); } + +#[test] +fn image_sequences_reload_by_themselves_only_with_all_footage() { + let (a, b) = (tmp("seq_0001.psd"), tmp("seq_0002.psd")); + std::fs::write(&a, flat(RED, false)).unwrap(); + std::fs::write(&b, flat(RED, false)).unwrap(); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [&a], "sequence": true})).unwrap(); + let item = ItemId(r["items"][0].as_u64().unwrap()); + assert_eq!(footage(&s, item).sequence.len(), 2, "imported as a sequence"); + // Non-Sequence Footage (the default, as in After Effects): sequences are never stamped. + assert_eq!(s.prefs.import.auto_reload_footage, "nonSequence"); + assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0})); + save(&a, &flat(BLUE, true)); + assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0})); + // All Footage: every frame is watched (first seen now, so only remembered). + s.prefs.import.auto_reload_footage = "all".into(); + assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0})); + save(&b, &flat(GREEN, true)); + assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap()["files"], json!(1)); +} diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index 9257d438..06bda925 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -283,10 +283,8 @@ mod tests { let list: Vec> = chans.iter().map(|c| AnyChannel::new(*c, FlatSamples::F32(vec![v; w * h]))).collect(); Layer::new((w, h), LayerAttributes::named(name), Encoding::FAST_LOSSLESS, AnyChannels::sort(list.into())) }; - let layers = vec![ - part("ViewLayer.Depth", &["ViewLayer.Depth.Z"], 4.0), - part("ViewLayer.Combined", &["ViewLayer.Combined.R", "ViewLayer.Combined.G"], 0.5), - ]; + let layers = + vec![part("ViewLayer.Depth", &["ViewLayer.Depth.Z"], 4.0), part("ViewLayer.Combined", &["ViewLayer.Combined.R", "ViewLayer.Combined.G"], 0.5)]; let image = Image::from_layers(ImageAttributes::with_size((w, h)), layers); let mut bytes = std::io::Cursor::new(Vec::new()); image.write().to_buffered(&mut bytes).unwrap(); diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index fb4f865d..55c75f0b 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -701,6 +701,12 @@ impl Frames { c.bytes = 0; c.gpu_bytes = 0; } + // Disk keys hash the footage files' size and time, but are remembered per revision; a + // file reloaded from disk without a new revision (a re-rendered image sequence) must be + // keyed again, or the disk cache serves the frames from before. + if let Ok(mut k) = self.content_keys.lock() { + k.clear(); + } } /// Queue a prefetch frame (no-op if cached or already queued/rendering). diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a0ab15b7..913ddc5a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1565,7 +1565,7 @@ impl EffectcraftApp { 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() { + if self.session.prefs.import.auto_reload_footage == "off" || self.session.project.items.is_empty() { return; } let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(0.0); diff --git a/docs/preferences.md b/docs/preferences.md index e20e1b04..4031f4da 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -109,7 +109,8 @@ 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, +- `import.autoReloadFootage`: Automatically Reload Footage, `off`, `nonSequence` (the default, as + in After Effects) or `all` (image sequences too). 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