diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index 10592444..55d4b846 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -76,7 +76,7 @@ SUBCOMMANDS import ... import media into the project export [--preset name] [--format f] [--range r] [--start s --end s] [--settings json] [--gpu-rendering auto|off] - [--scale f] [--quality 0-100] [--no-audio] [--queue] + [--scale f] [--quality 0-100] [--no-audio] [--queue] [--allow-offline] export the active sequence and wait for it to finish: with an export preset (`export --list-presets`; built-in or the user's), or a format (h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff|aac, guessed @@ -85,9 +85,12 @@ SUBCOMMANDS preset; --scale renders at a fraction of the frame size (0.5 = half); --quality 0-100 for the formats that take one; --no-audio leaves the sound out; --queue adds to the export queue and runs it - instead + instead. Offline media (missing, made offline, unreadable) renders + as placeholder frames: the export then lists it in `offlineMedia`, + warns on stderr and exits 1, unless --allow-offline accepts it export --list-presets [query] list export presets (name, category, format) as JSON - render --seconds S --out f.png [--scale 0.5] render one Program frame to PNG + render --seconds S --out f.png [--scale 0.5] [--allow-offline] + render one Program frame to PNG (offline media: as for export) probe [--image-sequence] media info as JSON, with MXF / Ogg / BWF details; with --image-sequence is the first numbered still of a sequence @@ -113,7 +116,8 @@ VALUES Times: 254016000000 ticks per second; most commands also take seconds=, frame= or timecode=. EXIT STATUS - 0 success · 1 a command failed · 2 usage error + 0 success · 1 a command failed, or export/render drew offline media without --allow-offline + · 2 usage error "; /// Writes one diagnostic line to `w`, dropping any write error. `eprintln!` panics (exit 101) when stderr's @@ -442,7 +446,9 @@ async fn cli() { match r { Ok(v) => { print(&a, &v); - diag(format_args!("exported {} in {:.1}s", written_paths(&v, out).join(", "), t0.elapsed().as_secs_f64())); + let written = written_paths(&v, out).join(", "); + check_offline(&a, &offline_media(&v), &format!("export: {written}")); + diag(format_args!("exported {written} in {:.1}s", t0.elapsed().as_secs_f64())); } Err(e) => fail(format!("export: {e}")), } @@ -457,10 +463,12 @@ async fn cli() { let out = a.opt("--out").unwrap_or("frame.png"); s.set_playhead(filmcraft_time::Tick::from_seconds_f64(secs)); let t0 = std::time::Instant::now(); - let img = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e)); + let (img, offline) = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e)); let dt = t0.elapsed(); let png = filmcraft_automation::png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), 0).unwrap_or_else(|e| fail(e)); std::fs::write(out, png).unwrap_or_else(|e| fail(format!("{out}: {e}"))); + let offline: Vec = offline.iter().filter_map(|o| serde_json::to_value(o).ok()).collect(); + check_offline(&a, &offline, &format!("render: {out}")); diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0)); } "mcp" => { @@ -500,9 +508,46 @@ fn written_paths(result: &Value, requested: &str) -> Vec { if paths.is_empty() { vec![requested.to_string()] } else { paths } } -/// The active sequence at the playhead, or the reason it cannot be rendered (no sequence, bad scale). -fn render_at_playhead(s: &Session, scale: f32) -> Result { - s.try_render_program_at(scale, s.playhead()).map_err(|e| e.to_string()) +/// The offline items an export result reports (`offlineMedia` of a direct export, or of each +/// queued item), once each. +fn offline_media(result: &Value) -> Vec { + let lists = std::iter::once(result).chain(result.get("items").and_then(Value::as_array).into_iter().flatten()); + let mut out: Vec = Vec::new(); + for o in lists.filter_map(|r| r.get("offlineMedia").and_then(Value::as_array)).flatten() { + if !out.iter().any(|x| x.get("item") == o.get("item")) { + out.push(o.clone()); + } + } + out +} + +/// One stderr line per offline item that rendered as placeholder frames (#1054). +fn offline_warning(offline: &[Value]) -> String { + let s = |o: &Value, k: &str| o.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let mut msg = format!("filmcraft-cli: warning: {} offline media item(s) rendered as placeholder frames:", offline.len()); + for o in offline { + let id = o.get("item").and_then(Value::as_u64).map(|i| i.to_string()).unwrap_or_default(); + msg.push_str(&format!("\n item {id} {} ({}): {}", s(o, "name"), s(o, "status"), s(o, "path"))); + } + msg +} + +/// Warn about offline media on stderr; without `--allow-offline` that fails the run (exit 1), after +/// the output (`what`) was written, so scripts don't take placeholder frames for the real picture. +fn check_offline(a: &Args, offline: &[Value], what: &str) { + if offline.is_empty() { + return; + } + diag(format_args!("{}", offline_warning(offline))); + if !a.flag("--allow-offline") { + fail(format!("{what} has placeholder frames for offline media; relink it (exec media.relink …) or pass --allow-offline")); + } +} + +/// The active sequence at the playhead with the offline items it drew, or the reason it cannot be +/// rendered (no sequence, bad scale). +fn render_at_playhead(s: &Session, scale: f32) -> Result<(filmcraft_engine::render::Image, Vec), String> { + s.try_render_program_with_offline_at(scale, s.playhead()).map_err(|e| e.to_string()) } /// `--start` / `--end` ask for a custom range. A lone bound is forwarded too, so the engine reports the missing one @@ -531,7 +576,7 @@ mod format_tests { let e = super::render_at_playhead(&s, scale).unwrap_err(); assert!(e.contains("finite and positive"), "{e}"); } - assert_eq!(super::render_at_playhead(&s, 0.5).unwrap().w, 8); + assert_eq!(super::render_at_playhead(&s, 0.5).unwrap().0.w, 8); } /// The CLI (and MCP / headless runs, which share its entry point) registers the hardware @@ -573,6 +618,19 @@ mod format_tests { assert_eq!(super::written_paths(&json!({"items": []}), "x.mov"), ["x.mov"]); } + #[test] + fn offline_media_from_direct_and_queued_results() { + use serde_json::json; + let a = json!({"item": 8, "name": "a.mp4", "path": "/m/a.mp4", "status": "missing", "error": ""}); + let b = json!({"item": 9, "name": "b.mov", "path": "/m/b.mov", "status": "offline", "error": ""}); + assert_eq!(super::offline_media(&json!({"path": "x.mp4", "offlineMedia": [a]})), std::slice::from_ref(&a)); + let queued = json!({"items": [{"offlineMedia": [a, b]}, {"offlineMedia": [a]}, {"offlineMedia": []}]}); + assert_eq!(super::offline_media(&queued), [a.clone(), b.clone()]); + assert!(super::offline_media(&json!({"path": "x.mp4"})).is_empty()); + let w = super::offline_warning(&[a, b]); + assert!(w.contains("2 offline media item(s)") && w.contains("item 8 a.mp4 (missing): /m/a.mp4") && w.contains("item 9 b.mov (offline)"), "{w}"); + } + #[test] fn export_lone_bound_is_forwarded_as_custom_range() { use serde_json::json; diff --git a/apps/filmcraft-cli/tests/cli.rs b/apps/filmcraft-cli/tests/cli.rs index 8552cc64..6cafe0e9 100644 --- a/apps/filmcraft-cli/tests/cli.rs +++ b/apps/filmcraft-cli/tests/cli.rs @@ -291,3 +291,55 @@ fn closed_stdout_still_runs_the_script_and_saves() { assert_eq!(out.status.code(), Some(1)); let _ = std::fs::remove_dir_all(&dir); } + +/// #1054: exporting or rendering a project whose media is missing fails (exit 1) and names the +/// offline items on stderr and in the result's `offlineMedia`, unless `--allow-offline` accepts the +/// placeholder frames; then it still warns. +#[test] +fn export_with_missing_media_fails_unless_allowed() { + let dir = std::env::temp_dir().join(format!("fc-cli-1054-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let f = |n: &str| dir.join(n).to_str().unwrap().to_string(); + let (still, proj) = (f("still.png"), f("p.fcproj")); + let o = cli(&["--demo", "render", "--seconds", "1", "--scale", "0.1", "--out", &still]); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + json_out(&cli(&["exec", "file.newSequence", "name=s", "width=64", "height=36", "fps=24", "--save-as", &proj])); + let paths = format!("paths=[\"{}\"]", still.replace('\\', "\\\\")); + let item = json_out(&cli(&["--project", &proj, "--save", "exec", "file.import", &paths]))["items"][0].as_u64().unwrap(); + json_out(&cli(&["--project", &proj, "--save", "exec", "timeline.place", &format!("item={item}"), "track=V1", "frame=0", "audio=false"])); + let export = |out: &str, extra: &[&str]| { + let mut args = vec!["--project", proj.as_str(), "--compact", "export", out, "--format", "png", "--start", "0", "--end", "0.05"]; + args.extend_from_slice(extra); + cli(&args) + }; + + let on = export(&f("on.png"), &[]); + let v = json_out(&on); + assert_eq!(v["offlineMedia"], serde_json::json!([]), "{v}"); + assert!(!String::from_utf8_lossy(&on.stderr).contains("warning"), "{}", String::from_utf8_lossy(&on.stderr)); + + std::fs::remove_file(&still).unwrap(); + let off = export(&f("off.png"), &[]); + let err = String::from_utf8_lossy(&off.stderr); + assert_eq!(off.status.code(), Some(1), "{err}"); + assert!(err.contains("still.png") && err.contains("missing") && err.contains("--allow-offline"), "{err}"); + let v: Value = serde_json::from_slice(&off.stdout).expect("the result JSON is still printed"); + assert_eq!(v["offlineMedia"][0]["item"], item, "{v}"); + assert_eq!(v["offlineMedia"][0]["status"], "missing", "{v}"); + + let allowed = export(&f("allowed.png"), &["--allow-offline"]); + let err = String::from_utf8_lossy(&allowed.stderr); + assert!(allowed.status.success(), "{err}"); + assert!(err.contains("still.png") && err.contains("missing"), "still warns: {err}"); + assert_eq!(json_out(&allowed)["offlineMedia"][0]["status"], "missing"); + + let render = cli(&["--project", &proj, "render", "--seconds", "0", "--out", &f("r.png")]); + let err = String::from_utf8_lossy(&render.stderr); + assert_eq!(render.status.code(), Some(1), "{err}"); + assert!(err.contains("still.png") && err.contains("--allow-offline"), "{err}"); + let render = cli(&["--project", &proj, "render", "--seconds", "0", "--out", &f("r.png"), "--allow-offline"]); + assert!(render.status.success(), "{}", String::from_utf8_lossy(&render.stderr)); + assert!(String::from_utf8_lossy(&render.stderr).contains("still.png")); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/src/bridge.rs b/crates/automation/src/bridge.rs index b7d3e197..5fdc2f0d 100644 --- a/crates/automation/src/bridge.rs +++ b/crates/automation/src/bridge.rs @@ -47,14 +47,14 @@ impl BridgeClient { } let mut start = self.call("engine.execute", json!({"command": id, "params": params})).await?; let Some(job) = start.get("job").and_then(Value::as_u64) else { return Ok(start) }; - let result = loop { + let (result, offline) = loop { tokio::time::sleep(JOB_POLL).await; let jobs = self.call("engine.execute", json!({"command": "jobs.list", "params": {}})).await?; let Some(j) = jobs.as_array().and_then(|a| a.iter().find(|j| j["id"].as_u64() == Some(job))).cloned() else { - break Value::Null; + break (Value::Null, None); }; if j["finished"].as_bool() == Some(true) { - break j["result"].clone(); + break (j["result"].clone(), j.get("offlineMedia").cloned()); } }; if let Some(e) = result.get("error").and_then(Value::as_str) { @@ -62,6 +62,9 @@ impl BridgeClient { } if let Some(o) = start.as_object_mut() { o.insert("result".into(), result); + if let Some(off) = offline { + o.insert("offlineMedia".into(), off); + } } Ok(start) } @@ -121,6 +124,11 @@ mod tests { /// A stand-in for the app's control server: the export starts job 7, which `jobs.list` reports /// running twice and then finished with `result`. Every request is recorded. async fn fake_app(result: Value) -> (String, Arc>>) { + fake_app_with(result, None).await + } + + /// [`fake_app`] whose finished job also lists `offline` as its `offlineMedia`. + async fn fake_app_with(result: Value, offline: Option) -> (String, Arc>>) { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap().to_string(); let seen = Arc::new(Mutex::new(Vec::new())); @@ -138,7 +146,11 @@ mod tests { Some("jobs.list") => { polls += 1; let finished = polls >= 3; - json!([{"id": 7, "finished": finished, "result": if finished { result.clone() } else { Value::Null }}]) + let mut j = json!({"id": 7, "finished": finished, "result": if finished { result.clone() } else { Value::Null }}); + if let Some(o) = offline.clone().filter(|_| finished) { + j["offlineMedia"] = o; + } + json!([j]) } _ => json!(null), }; @@ -165,6 +177,16 @@ mod tests { assert_eq!(seen.iter().filter(|q| q["params"]["command"] == "jobs.list").count(), 3); } + /// The offline media a polled export drew comes back as in a blocking export's reply (#1054). + #[tokio::test] + async fn a_polled_export_reports_its_offline_media() { + let off = json!([{"item": 8, "status": "missing"}]); + let (addr, _) = fake_app_with(json!({"frames": 3}), Some(off.clone())).await; + let b = BridgeClient::new(addr).unwrap(); + let r = b.execute("file.exportMedia", json!({"path": "/tmp/out.mov", "wait": true})).await.unwrap(); + assert_eq!(r["offlineMedia"], off, "{r}"); + } + #[tokio::test] async fn a_failed_blocking_export_is_an_error() { let (addr, _) = fake_app(json!({"error": "encode: disk full"})).await; diff --git a/crates/automation/src/long_job.rs b/crates/automation/src/long_job.rs index 62549b90..bdc756cf 100644 --- a/crates/automation/src/long_job.rs +++ b/crates/automation/src/long_job.rs @@ -72,6 +72,7 @@ impl FilmcraftMcp { rep.report(done, total, j["status"].as_str().unwrap_or_default()).await; } if finished { + let offline = state.as_ref().and_then(|j| j.get("offlineMedia").cloned()); let result = state.map(|j| j["result"].clone()).unwrap_or(Value::Null); if let Some(e) = result.get("error").and_then(Value::as_str) { return CallToolResult::error(vec![Content::text(format!("export failed: {e}"))]); @@ -79,6 +80,9 @@ impl FilmcraftMcp { let mut out = start; if let Some(o) = out.as_object_mut() { o.insert("result".into(), result); + if let Some(off) = offline { + o.insert("offlineMedia".into(), off); + } } return CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(&out).unwrap_or_default())]); } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6609afdd..7c58a1ed 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1289,7 +1289,7 @@ fn build() -> Vec { has_seq, crate::export_tools::export_media ), - query!("jobs.list", "List Jobs", "{}", |s, _| Ok(Value::Array(s.jobs.iter().map(crate::Job::to_json).collect()))), + query!("jobs.list", "List Jobs", "{}", |s, _| Ok(Value::Array(s.jobs.iter().map(|j| crate::export_tools::job_json(s, j)).collect()))), query!("perf.stats", "Performance Statistics", "{}", |s, _| Ok(crate::perf::stats(s))), cmd!("jobs.cancel", "Cancel Job", [], None, r#"{"job":id}"#, always, |s, p| { let id = u64_p(p, "job").ok_or_else(|| bad("jobs.cancel", "need `job`"))?; diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index b714e284..24840754 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -440,8 +440,9 @@ pub fn spawn_export(s: &mut Session, project: Arc, seq: ItemId, mut set let id = s.jobs.iter().map(|j| j.id).max().unwrap_or(0) + 1; let job = crate::Job { id, label, progress: Default::default(), result: Default::default() }; // Export always renders full-resolution media, whatever the proxy toggle says. - let provider = s.media.full_res_provider(project.clone(), s.services.clone()); + let provider = s.media.full_res_provider(project.clone(), s.services.clone()).recording(); let (prog, res) = (job.progress.clone(), job.result.clone()); + let offline = s.export_offline.entry(id).or_default().clone(); if cfg!(target_arch = "wasm32") && !wait && filmcraft_export::stepped(format) { // No threads: the host advances the export between UI frames (`Session::pump_jobs`). let mut exporter = filmcraft_export::Exporter::new(project, seq, &settings, &prog).map_err(|e| EngineError::Other(e.to_string()))?; @@ -452,6 +453,7 @@ pub fn spawn_export(s: &mut Session, project: Arc, seq: ItemId, mut set } let run = move || { let r = filmcraft_export::export(&project, seq, &settings, &provider, &prog).map_err(|e| e.to_string()); + *offline.lock().unwrap_or_else(|x| x.into_inner()) = provider.offline_used(); if let Err(e) = &r { *prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone()); prog.finished.store(true, Ordering::Relaxed); @@ -514,6 +516,7 @@ pub fn export_media(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other(format!("export failed: {e}"))); } out["result"] = v["result"].clone(); + out["offlineMedia"] = offline_media_json(s, id); } Ok(out) } @@ -568,6 +571,22 @@ impl ExportQueue { } } +/// A `jobs.list` entry: [`crate::Job::to_json`], plus `offlineMedia` for an export job, so callers +/// that poll an export (the bridge, MCP long exports) can report it like `file.exportMedia` does. +pub(crate) fn job_json(s: &Session, j: &crate::Job) -> Value { + let mut v = j.to_json(); + if s.export_offline.contains_key(&j.id) { + v["offlineMedia"] = offline_media_json(s, j.id); + } + v +} + +/// The offline items export job `job` drew the slate for, as JSON (`[]` while it runs or when none). +fn offline_media_json(s: &Session, job: u64) -> Value { + let list = s.export_offline.get(&job).map(|o| o.lock().unwrap_or_else(|e| e.into_inner()).clone()).unwrap_or_default(); + serde_json::to_value(list).unwrap_or_else(|_| json!([])) +} + fn item_json(s: &Session, it: &QueueItem) -> Value { let job = it.job.and_then(|id| s.jobs.iter().find(|j| j.id == id)); let (progress, status_text) = match job { @@ -588,6 +607,7 @@ fn item_json(s: &Session, it: &QueueItem) -> Value { "statusText": status_text, "job": it.job, "error": it.error, + "offlineMedia": it.job.map_or_else(|| json!([]), |j| offline_media_json(s, j)), }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a2ed9e6a..b0454caa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -357,6 +357,9 @@ pub struct Session { pub journal: Vec<(String, Value)>, /// Background jobs (exports, render previews…). pub jobs: Vec, + /// Export job → the offline items it drew the slate for, filled when the export finishes + /// (#1054; `offlineMedia` in `file.exportMedia` and queue results). + pub export_offline: std::collections::HashMap>>>, /// User preferences (`prefs.*` commands) and where they persist (None = not persisted). pub prefs: autosave::Preferences, pub prefs_path: Option, @@ -476,6 +479,7 @@ impl Session { events: Vec::new(), journal: Vec::new(), jobs: Vec::new(), + export_offline: Default::default(), prefs: Default::default(), prefs_path: None, persistence: None, @@ -1024,11 +1028,22 @@ impl Session { /// [`Session::render_program_at`], with the reason when nothing can be rendered. pub fn try_render_program_at(&self, scale: f32, t: Tick) -> Result { + self.render_program_with(scale, t, &self.media.provider(self.project.clone(), self.services.clone())) + } + + /// [`Session::try_render_program_at`], also naming the offline items the frame drew the slate + /// for (#1054). + pub fn try_render_program_with_offline_at(&self, scale: f32, t: Tick) -> Result<(filmcraft_render::Image, Vec)> { + let provider = self.media.provider(self.project.clone(), self.services.clone()).recording(); + let img = self.render_program_with(scale, t, &provider)?; + Ok((img, provider.offline_used())) + } + + fn render_program_with(&self, scale: f32, t: Tick, provider: &dyn filmcraft_render::SourceProvider) -> Result { let seq = self.renderable_sequence(scale)?; let t = self.sequence_rate().snap(t.max(Tick::ZERO)); - let provider = self.media.provider(self.project.clone(), self.services.clone()); let opts = filmcraft_render::RenderOptions { scale, captions: true, ..Default::default() }; - Ok(filmcraft_render::render_sequence(&self.project, seq, t, opts, &provider)) + Ok(filmcraft_render::render_sequence(&self.project, seq, t, opts, provider)) } /// The active sequence, if a frame of it at `scale` is within the image size limits. diff --git a/crates/engine/src/media_pool.rs b/crates/engine/src/media_pool.rs index ac849710..ee8a1f01 100644 --- a/crates/engine/src/media_pool.rs +++ b/crates/engine/src/media_pool.rs @@ -12,7 +12,7 @@ //! compositor derives its pixel scale from the frame it gets, so effects and Motion render the //! same picture at lower resolution. Export asks for [`MediaPool::full_res_provider`]. -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; @@ -32,6 +32,17 @@ pub struct OfflineStatus { pub error: String, } +/// An offline item an export or a rendered frame drew the slate for (#1054). `status` is the +/// word `media.status` uses: `missing`, `offline` (made offline) or `unreadable`. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct OfflineMedia { + pub item: u64, + pub name: String, + pub path: String, + pub status: &'static str, + pub error: String, +} + pub struct MediaPool { /// item → (reference key, source) sources: RwLock>, @@ -316,12 +327,12 @@ impl MediaPool { /// A render-side provider bound to a project snapshot (proxies when enabled). pub fn provider(self: &Arc, project: Arc, services: Arc) -> PoolProvider { - PoolProvider { pool: self.clone(), project, services, full_res: false } + PoolProvider { pool: self.clone(), project, services, full_res: false, used: None } } /// A provider that always reads full-resolution media (export). pub fn full_res_provider(self: &Arc, project: Arc, services: Arc) -> PoolProvider { - PoolProvider { pool: self.clone(), project, services, full_res: true } + PoolProvider { pool: self.clone(), project, services, full_res: true, used: None } } } @@ -332,10 +343,45 @@ pub struct PoolProvider { pub services: Arc, /// Ignore proxies (export always renders full resolution). pub full_res: bool, + /// Items asked for so far, when recording ([`PoolProvider::recording`]). + used: Option>>, +} + +impl PoolProvider { + /// This provider, remembering the items it is asked for so [`PoolProvider::offline_used`] can + /// tell which of them rendered offline. + pub fn recording(mut self) -> Self { + self.used = Some(Mutex::new(BTreeSet::new())); + self + } + + /// The offline items among those asked for since [`PoolProvider::recording`], once each, in + /// item order (subclips count as their media). Empty when not recording. + pub fn offline_used(&self) -> Vec { + let Some(used) = &self.used else { return Vec::new() }; + let used = used.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let media: BTreeSet = used.into_iter().filter_map(|i| self.project.resolve_media(i).map(|(id, _, _)| id)).collect(); + media + .into_iter() + .filter_map(|id| { + let st = self.pool.offline_status(id)?; + let status = match st.reason { + OfflineReason::Missing => "missing", + OfflineReason::MadeOffline => "offline", + OfflineReason::Unreadable => "unreadable", + }; + let name = self.project.item(id).map(|i| i.name.clone()).unwrap_or_default(); + Some(OfflineMedia { item: id.0, name, path: st.path, status, error: st.error }) + }) + .collect() + } } impl filmcraft_render::SourceProvider for PoolProvider { fn source(&self, item: ItemId) -> Option { + if let Some(used) = &self.used { + used.lock().unwrap_or_else(|e| e.into_inner()).insert(item); + } if self.full_res { self.pool.full_res_source(&self.project, item, &*self.services) } else { self.pool.source_for(&self.project, item, &*self.services) } } } diff --git a/crates/engine/src/relink_tests.rs b/crates/engine/src/relink_tests.rs index b6475c0d..d25dda91 100644 --- a/crates/engine/src/relink_tests.rs +++ b/crates/engine/src/relink_tests.rs @@ -201,3 +201,72 @@ fn explicit_folder_remaps_normalize_both_prefixes_and_respect_boundaries() { assert_eq!(apply_remap("C:/shoot/Media/a.mov", r"C:\shoot\Media", r"D:\archive\Media"), Some("D:/archive/Media/a.mov".into())); assert_eq!(apply_remap("C:/shoot/Media2/a.mov", r"C:\shoot\Media", r"D:\archive\Media"), None); } + +/// #1054: an export (or a rendered frame) that draws the offline slate names the offline items it +/// used, with their status, so a headless caller can tell placeholder frames from real ones. Media +/// outside the exported range is not reported. +#[test] +fn export_names_the_offline_media_it_rendered() { + let root = tmp_dir("relink-export-offline"); + let (path, items) = saved_project(&root); + let out = |name: &str| root.join(name).to_string_lossy().into_owned(); + let mut s = open(&path); + let r = s + .execute("file.exportMedia", json!({"path": out("on.png"), "format": "png", "range": "custom", "startSeconds": 0, "endSeconds": 0.05, "wait": true})) + .unwrap(); + assert_eq!(r["offlineMedia"], json!([]), "{r}"); + std::fs::remove_file(root.join("Media/a.mov")).unwrap(); + let mut s = open(&path); + // b.mov (frames 12..24) is still there + let r = s + .execute("file.exportMedia", json!({"path": out("b.png"), "format": "png", "range": "custom", "startSeconds": 0.6, "endSeconds": 0.65, "wait": true})) + .unwrap(); + assert_eq!(r["offlineMedia"], json!([]), "{r}"); + let r = s.execute("file.exportMedia", json!({"path": out("all.wav"), "format": "wav", "wait": true})).unwrap(); + let off = r["offlineMedia"].as_array().unwrap(); + assert_eq!(off.len(), 1, "{r}"); + assert_eq!(off[0]["item"], json!(items[0].0)); + assert_eq!(off[0]["name"], json!("a.mov")); + assert_eq!(off[0]["status"], json!("missing")); + assert!(off[0]["path"].as_str().unwrap().ends_with("a.mov"), "{r}"); + // made offline on purpose + s.execute("media.makeOffline", json!({"items": [items[1].0]})).unwrap(); + let r = s + .execute("file.exportMedia", json!({"path": out("b2.png"), "format": "png", "range": "custom", "startSeconds": 0.6, "endSeconds": 0.65, "wait": true})) + .unwrap(); + assert_eq!( + r["offlineMedia"].as_array().map(|a| a.iter().map(|o| (o["item"].clone(), o["status"].clone())).collect::>()), + Some(vec![(json!(items[1].0), json!("offline"))]), + "{r}" + ); + // the queue reports it per item + let r = s + .execute( + "export.queue.add", + json!({"path": out("q.png"), "format": "png", "range": "custom", "startSeconds": 0.6, "endSeconds": 0.65, "start": true, "wait": true}), + ) + .unwrap(); + assert_eq!(r["items"][0]["offlineMedia"][0]["status"], json!("offline"), "{r}"); + // one rendered frame + let (_, off) = s.try_render_program_with_offline_at(0.5, s.sequence_rate().tick_of(3)).unwrap(); + assert_eq!(off.iter().map(|o| (o.item, o.status)).collect::>(), [(items[0].0, "missing")]); + let (_, off) = s.try_render_program_with_offline_at(0.5, s.sequence_rate().tick_of(30)).unwrap(); + assert!(off.is_empty(), "nothing on the timeline there: {off:?}"); + let _ = std::fs::remove_dir_all(&root); +} + +/// #1054: `jobs.list` carries an export job's `offlineMedia`, for callers that poll the job (the +/// bridge and MCP long exports) instead of waiting in `file.exportMedia`. +#[test] +fn jobs_list_reports_an_exports_offline_media() { + let root = tmp_dir("relink-jobs-offline"); + let (path, items) = saved_project(&root); + std::fs::remove_file(root.join("Media/b.mov")).unwrap(); + let mut s = open(&path); + let out = root.join("all.wav").to_string_lossy().into_owned(); + let job = s.execute("file.exportMedia", json!({"path": out, "format": "wav", "wait": true})).unwrap()["job"].clone(); + let jobs = s.execute("jobs.list", json!({})).unwrap(); + let j = jobs.as_array().unwrap().iter().find(|j| j["id"] == job).unwrap(); + assert_eq!(j["offlineMedia"][0]["item"], json!(items[1].0), "{j}"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/docs/agents.md b/docs/agents.md index 50b4e34e..b10ff5f2 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -110,6 +110,9 @@ progress and stop it: - The export runs as the same background job `jobs.list` shows. The session is locked only for short polls, so other requests (`ping`, `doc_inspect`, `jobs.list`, …) are answered while it encodes. +- `file.exportMedia` with `wait: true`, an export job in `jobs.list` and each `export.queue.list` + item carry `offlineMedia`: the offline items the export drew the placeholder for (see §2b), `[]` + when none. The export itself still succeeds; deciding what to do about it is up to the caller. - Every job in `jobs.list`, and every encoding item of `export.queue.list`, carries `etaSeconds`: the time left at the job's speed over the last 15 seconds, `null` until a second of progress has been measured (a loudness pass, a seek) and once it is done. The app shows it as `37% · 2:05 left` in the @@ -213,6 +216,15 @@ strings; dotted keys nest (`color.r=1`). `run` prints one JSON line per command (`{"line","id","ok","result"|"error"}`) and stops at the first failure unless `--keep-going`. `--save` writes back to `--project`; `--save-as path` writes elsewhere. +Media that is offline when it is needed (the file is missing, was made offline, or can't be read) +renders as the "media not found" placeholder. `export` and `render` then name each such item on +stderr (`item 8 a.mp4 (missing): /path/a.mp4`) and exit 1 after writing the output, so a moved or +unmounted media folder doesn't pass as a good render. `--allow-offline` accepts the placeholder +frames: the warning stays and the exit status is 0. The export result lists the same items as +`offlineMedia` (`[{item, name, path, status, error}]`, `status` as in `media.status`: `missing`, +`offline` or `unreadable`), and `[]` when every clip it drew was online; check before exporting with +`exec media.status` or `exec media.findMissing`. + `markers.exportCsv {path}` exports all markers in the active sequence, including colours hidden in the UI and markers outside In/Out, sorted by start then id. The UTF-8 CSV has a header and CRLF records; quotes, commas and embedded newlines are escaped. Columns are Sequence, Marker ID, Name,