fix(cli): fail export/render that drew offline media unless --allow-offline
Exports and rendered frames now record which offline items (missing, made offline, unreadable) they drew the placeholder slate for. The engine reports them as `offlineMedia` in file.exportMedia (wait), jobs.list export entries and export.queue items; the bridge and MCP long exports pass it through. filmcraft-cli export and render name each item on stderr and exit 1, unless --allow-offline accepts the placeholders. Fixes #1054 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4e2d0bc2d3
commit
e394061827
|
|
@ -76,7 +76,7 @@ SUBCOMMANDS
|
|||
import <file>... import media into the project
|
||||
export <out> [--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 <media> [--image-sequence]
|
||||
media info as JSON, with MXF / Ogg / BWF details; with
|
||||
--image-sequence <media> 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<Value> = 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<String> {
|
|||
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<filmcraft_engine::render::Image, String> {
|
||||
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<Value> {
|
||||
let lists = std::iter::once(result).chain(result.get("items").and_then(Value::as_array).into_iter().flatten());
|
||||
let mut out: Vec<Value> = 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<filmcraft_engine::media_pool::OfflineMedia>), 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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Mutex<Vec<Value>>>) {
|
||||
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<Value>) -> (String, Arc<Mutex<Vec<Value>>>) {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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())]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1289,7 +1289,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
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`"))?;
|
||||
|
|
|
|||
|
|
@ -440,8 +440,9 @@ pub fn spawn_export(s: &mut Session, project: Arc<Project>, 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<Project>, 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<Value> {
|
|||
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)),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -357,6 +357,9 @@ pub struct Session {
|
|||
pub journal: Vec<(String, Value)>,
|
||||
/// Background jobs (exports, render previews…).
|
||||
pub jobs: Vec<Job>,
|
||||
/// 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<u64, Arc<std::sync::Mutex<Vec<media_pool::OfflineMedia>>>>,
|
||||
/// User preferences (`prefs.*` commands) and where they persist (None = not persisted).
|
||||
pub prefs: autosave::Preferences,
|
||||
pub prefs_path: Option<std::path::PathBuf>,
|
||||
|
|
@ -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<filmcraft_render::Image> {
|
||||
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<media_pool::OfflineMedia>)> {
|
||||
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<filmcraft_render::Image> {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<HashMap<ItemId, (String, SharedSource)>>,
|
||||
|
|
@ -316,12 +327,12 @@ impl MediaPool {
|
|||
|
||||
/// A render-side provider bound to a project snapshot (proxies when enabled).
|
||||
pub fn provider(self: &Arc<Self>, project: Arc<Project>, services: Arc<dyn Services>) -> 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<Self>, project: Arc<Project>, services: Arc<dyn Services>) -> 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<dyn Services>,
|
||||
/// Ignore proxies (export always renders full resolution).
|
||||
pub full_res: bool,
|
||||
/// Items asked for so far, when recording ([`PoolProvider::recording`]).
|
||||
used: Option<Mutex<BTreeSet<ItemId>>>,
|
||||
}
|
||||
|
||||
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<OfflineMedia> {
|
||||
let Some(used) = &self.used else { return Vec::new() };
|
||||
let used = used.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let media: BTreeSet<ItemId> = 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<SharedSource> {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>()),
|
||||
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::<Vec<_>>(), [(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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue