mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
1 parent
813c7c4650
commit
e6d37c2fa6
10 files changed
+522
-37
No files matched your search
@@ -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...",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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>) {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user