#1743: validate background and bridged exports in their owning session

This commit is contained in:
Kristian Myram
2026-10-30 09:41:38 +01:00
parent a0ad0d5bbd
commit 41148e9b72
15 changed files with 249 additions and 49 deletions
+20 -8
View File
@@ -16,6 +16,8 @@ use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, str_p};
use crate::jobs::TaskCtl;
use crate::{EngineError, Exporter, Result, Session, cmd};
mod staging;
fn format_p(p: &Value, cmd: &str) -> Result<Option<TimelineFormat>> {
match str_p(p, "format") {
None | Some("") | Some("auto") => Ok(None),
@@ -178,7 +180,6 @@ fn export_timeline(s: &mut Session, p: &Value) -> Result<Value> {
let relative_to = Some(dir.to_string_lossy().to_string()).filter(|d| !d.is_empty());
let opts = ExportOptions { format, prerender: prerender_mode, precomps, relative_to };
let project = s.project.clone();
let services = s.services.clone();
let cx = PrerenderCx {
protected: crate::output_guard::Protected::of(s),
exporter: s.exporter.clone(),
@@ -191,17 +192,21 @@ fn export_timeline(s: &mut Session, p: &Value) -> Result<Value> {
// in Window ▸ Progress instead of with the window waiting (#2269).
let wait = !b_p(p, "background").unwrap_or(false);
s.spawn_task("exportTimeline", format!("Export {}", format.label()), wait, move |ctl| {
let (bytes, mut result) = export_timeline_work(&cx, &project, cid, &opts, &dir, &stem, ctl)?;
let (bytes, mut result, staged) = export_timeline_work(&cx, &project, cid, &opts, &dir, &stem, ctl)?;
if let Some(source) = cx.protected.find(std::path::Path::new(&path)) {
return Err(source.refusal(&path, crate::output_guard::Use::Export));
}
services.write_file(&path, &bytes).map_err(|e| format!("cannot write {path}: {e}"))?;
result["path"] = json!(path);
result["toast"] = json!(match format {
TimelineFormat::Fcp7Xml => format!("Exported {path} (Final Cut Pro XML; import it in Premiere Pro with File ▸ Import)"),
_ => format!("Exported {path} ({})", format.label()),
});
Ok(Box::new(move |_: &mut Session| Ok(result)))
Ok(Box::new(move |s: &mut Session| {
s.refuse_export(&path)?;
staging::commit(staged, s)?;
s.write_output(&path, &bytes)?;
Ok(result)
}))
})
}
@@ -215,11 +220,15 @@ fn export_timeline_work(
dir: &std::path::Path,
stem: &str,
ctl: &TaskCtl,
) -> std::result::Result<(Vec<u8>, Value), String> {
) -> std::result::Result<(Vec<u8>, Value, Vec<staging::Movie>), String> {
let mut pre: HashMap<(ItemId, LayerId), Prerendered> = HashMap::new();
let mut rendered = vec![];
let mut errors = vec![];
let mut staged = vec![];
let plan = effectcraft_interchange::plan_prerender(project, cid, opts);
if plan.len() > staging::MAX_MOVIES {
return Err(format!("too many pre-render movies (limit {})", staging::MAX_MOVIES));
}
let n = plan.len() as u64;
for (i, (c, l)) in (0u64..).zip(plan) {
let comp_name = project.item(c).map(|it| it.name.clone()).unwrap_or_default();
@@ -233,10 +242,13 @@ fn export_timeline_work(
}
// Progress over all the layers, each one's frames a share of it.
let mut progress = |done: u64, total: u64| ctl.progress(i * 1000 + done.saturating_mul(1000) / total.max(1), n * 1000);
match prerender(cx, project, c, l, &file, &mut progress) {
Ok(r) => {
let movie = staging::Movie::new(&file)?;
match prerender(cx, project, c, l, &movie.path, &mut progress) {
Ok(mut r) => {
r.path = file;
rendered.push(r.path.clone());
pre.insert((c, l), r);
staged.push(movie);
}
Err(_) if ctl.cancelled() => return Err(crate::render_queue::CANCELLED.into()),
Err(e) => errors.push(format!("{comp_name} ▸ {layer_name}: {e}")),
@@ -255,7 +267,7 @@ fn export_timeline_work(
"prerendered": rendered,
"warnings": warnings,
});
Ok((out.bytes, result))
Ok((out.bytes, result, staged))
}
/// The formats, for agents and the export dialog.
+86
View File
@@ -0,0 +1,86 @@
//! Native pre-render files stay private until the app can check the current project.
#[cfg(not(target_arch = "wasm32"))]
use crate::EngineError;
use crate::Session;
pub(super) const MAX_MOVIES: usize = 4096;
pub(super) struct Movie {
pub path: String,
#[cfg(not(target_arch = "wasm32"))]
directory: tempfile::TempDir,
#[cfg(not(target_arch = "wasm32"))]
destination: std::path::PathBuf,
}
impl Movie {
pub fn new(path: &str) -> Result<Self, String> {
#[cfg(not(target_arch = "wasm32"))]
{
let path = std::path::Path::new(path);
let parent = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
std::fs::create_dir_all(parent).map_err(|e| format!("cannot create pre-render folder {}: {e}", parent.display()))?;
let directory =
tempfile::Builder::new().prefix(".effectcraft-prerender-").tempdir_in(parent).map_err(|e| format!("cannot stage pre-render: {e}"))?;
let name = path.file_name().ok_or_else(|| format!("pre-render has no file name: {}", path.display()))?;
let staged = directory.path().join(name).to_string_lossy().into_owned();
Ok(Self { path: staged, directory, destination: parent.to_path_buf() })
}
#[cfg(target_arch = "wasm32")]
{
// spawn_task is synchronous on WASM, so session mutations cannot interleave.
Ok(Self { path: path.to_string() })
}
}
}
pub(super) fn commit(movies: Vec<Movie>, session: &Session) -> crate::Result<()> {
#[cfg(not(target_arch = "wasm32"))]
{
const FILES_PER_MOVIE: usize = 8;
if movies.len() > MAX_MOVIES {
return Err(EngineError::Other("too many staged pre-render movies".into()));
}
let mut files = Vec::new();
let mut path_bytes = 0usize;
for movie in &movies {
let entries = std::fs::read_dir(movie.directory.path()).map_err(|e| EngineError::Other(format!("cannot read staged pre-render: {e}")))?;
for (index, entry) in entries.take(FILES_PER_MOVIE + 1).enumerate() {
if index == FILES_PER_MOVIE {
return Err(EngineError::Other("too many files in a staged pre-render".into()));
}
let entry = entry.map_err(|e| EngineError::Other(format!("cannot read staged pre-render entry: {e}")))?;
let kind = entry.file_type().map_err(|e| EngineError::Other(format!("cannot inspect staged pre-render: {e}")))?;
if !kind.is_file() {
return Err(EngineError::Other("a staged pre-render contains an unexpected directory or link".into()));
}
let source = entry.path();
let destination = movie.destination.join(entry.file_name());
path_bytes = path_bytes.saturating_add(source.as_os_str().len()).saturating_add(destination.as_os_str().len());
if path_bytes > 16 * 1024 * 1024 {
return Err(EngineError::Other("staged pre-render paths exceed the 16 MiB limit".into()));
}
files.push((source, destination));
}
}
// If a user imported a private staging file, leave the staging folders intact.
if let Some((source, _)) = files.iter().find(|(source, _)| session.refuse_export(&source.to_string_lossy()).is_err()) {
let source = source.display().to_string();
for movie in movies {
let _kept = movie.directory.keep();
}
return Err(EngineError::Other(format!("staged pre-render {source} is now used by the project; its temporary folder was kept")));
}
// Preflight every destination before replacing any output, including render logs.
for (_, destination) in &files {
session.refuse_export(&destination.to_string_lossy())?;
}
for (source, destination) in files {
std::fs::rename(&source, &destination).map_err(|e| EngineError::Other(format!("cannot publish pre-render {}: {e}", destination.display())))?;
}
}
#[cfg(target_arch = "wasm32")]
let _ = (movies, session);
Ok(())
}
+14 -7
View File
@@ -12,7 +12,9 @@
//! write the project's own file and do not.
use std::collections::{HashMap, HashSet};
use std::path::{Component, Path, PathBuf};
#[cfg(not(target_arch = "wasm32"))]
use std::path::Component;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use effectcraft_project::render_queue::{RenderQueueItem, log_path, numbered_template, sequence_frame, sequence_path};
@@ -180,6 +182,7 @@ fn identity_hash(handle: &same_file::Handle) -> u64 {
}
/// `path` made absolute, with `.` and `..` resolved by name alone (no file is looked at).
#[cfg(not(target_arch = "wasm32"))]
fn lexical(path: &Path) -> PathBuf {
let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
let mut out = PathBuf::new();
@@ -200,11 +203,19 @@ fn lexical(path: &Path) -> PathBuf {
}
/// The name a path is compared by: absolute, resolved by name, without case where volumes ignore it.
#[cfg(not(target_arch = "wasm32"))]
fn key(path: &Path) -> String {
let parts: Vec<String> = lexical(path).components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect();
fold(&parts.join("/"))
}
/// Browser file-table keys distinguish case and literal dot segments, but not a leading slash.
#[cfg(target_arch = "wasm32")]
fn key(path: &Path) -> String {
let path = path.to_string_lossy();
if path.starts_with('/') { path.into_owned() } else { format!("/{path}") }
}
fn fold(name: &str) -> String {
if IGNORES_CASE { name.to_lowercase() } else { name.to_string() }
}
@@ -274,9 +285,6 @@ impl Session {
/// What reads the file at `path`, if the project does.
fn blocking(&self, path: &str) -> Option<Source> {
if cfg!(target_arch = "wasm32") {
return None;
}
self.protected_files().find(Path::new(path)).cloned()
}
@@ -300,9 +308,7 @@ impl Session {
/// [`Session::write_output`] against files already found ([`Session::protected_files`]),
/// for a command that writes many.
pub(crate) fn write_output_with(&self, protected: &Protected, path: &str, bytes: &[u8]) -> crate::Result<()> {
if !cfg!(target_arch = "wasm32")
&& let Some(source) = protected.find(Path::new(path))
{
if let Some(source) = protected.find(Path::new(path)) {
return Err(crate::EngineError::Other(source.refusal(path, Use::Export)));
}
self.services.write_file(path, bytes).map_err(|e| crate::EngineError::Other(format!("cannot write {path}: {e}")))
@@ -452,6 +458,7 @@ mod tests {
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn paths_compare_by_name_once_resolved() {
let here = std::env::current_dir().unwrap();
assert_eq!(key(Path::new("a/b/../c.mp4")), key(Path::new("a/c.mp4")));
+50 -2
View File
@@ -255,8 +255,8 @@ fn a_timeline_export_does_not_pre_render_over_footage() {
}
}
/// `file.checkOutput` answers before anything is written, for callers that write the file
/// themselves (the CLI, MCP's `render_frame`).
/// `file.checkOutput` answers before anything is written. Its reply describes current state;
/// callers still need a guarded write in the owning session, as CLI/MCP frame saves use.
#[test]
fn check_output_names_the_files_an_export_may_not_use() {
let mut f = reading("check-output", "png");
@@ -283,3 +283,51 @@ fn a_timeline_export_does_not_write_over_the_project_file() {
assert!(e.contains("Edit.xml") && e.contains("project"), "{e}");
assert_eq!(f.read("Edit.xml"), saved);
}
#[test]
fn a_background_timeline_export_rechecks_its_document_before_completion() {
let mut f = Fixture::new("timeline-current-project");
f.comp("Main");
let path = f.text("Pending.xml");
let job = f.s.execute("file.exportTimeline", json!({"path": path, "background": true, "prerender": "none"})).unwrap();
assert!(job["job"].is_string());
// The app may save the project while a background export is waiting to finish.
f.s.execute("file.saveAs", json!({"path": path})).unwrap();
let saved = f.read("Pending.xml");
f.s.wait_jobs();
let done = f.s.job_log.last().unwrap();
assert_eq!(done.status, "failed", "the destination became the project: {}", done.message);
assert!(done.message.contains("project"));
assert_eq!(f.read("Pending.xml"), saved);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn background_pre_render_movies_are_checked_again_before_publication() {
for source in ["footage", "project"] {
let mut f = Fixture::new(&format!("timeline-staged-{source}"));
f.comp("Main");
f.s.execute("layer.newSolid", json!({"name": "Red"})).unwrap();
let params = json!({"path": f.text("Main.otio"), "prerender": "unsupported"});
let first = f.s.execute("file.exportTimeline", params.clone()).unwrap();
let movie = first["prerendered"][0].as_str().unwrap().to_string();
let filename = std::path::Path::new(&movie).file_name().unwrap().to_string_lossy().into_owned();
let document = f.read("Main.otio");
let mut background = params;
background["background"] = json!(true);
f.s.execute("file.exportTimeline", background).unwrap();
if source == "footage" {
f.footage(&filename);
} else {
f.s.execute("file.saveAs", json!({"path": movie})).unwrap();
}
let protected = f.read(&filename);
f.s.wait_jobs();
let done = f.s.job_log.last().unwrap();
assert_eq!(done.status, "failed", "{}", done.message);
assert!(done.message.contains(source), "{}", done.message);
assert_eq!(f.read(&filename), protected);
assert_eq!(f.read("Main.otio"), document);
assert!(listing(&f.dir).iter().all(|name| !name.starts_with(".effectcraft-prerender-")));
}
}