Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/export-filesystem.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Export filesystem

on:
push:
paths: ["crates/export/**", "Cargo.lock", ".github/workflows/export-filesystem.yml"]
pull_request:
paths: ["crates/export/**", "Cargo.lock", ".github/workflows/export-filesystem.yml"]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: export-filesystem-${{ github.ref }}
cancel-in-progress: true

jobs:
filesystem:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
env:
CARGO_BUILD_JOBS: "2"
CARGO_PROFILE_RELEASE_LTO: "false"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- uses: dtolnay/rust-toolchain@1.99.1
- uses: Swatinem/rust-cache@v2
- name: Record exact source and toolchain
shell: bash
run: |
git rev-parse HEAD 'HEAD^{tree}'
rustc -Vv
cargo -V
- name: Ordinary export unit and filesystem contracts
# Run the complete library suite so a retired module/filter cannot match zero tests.
run: cargo test --locked --release -p effectcraft-export --lib
- name: Export integration
run: cargo test --locked --release -p effectcraft-export --test render_settings
- name: Ensure tests did not change tracked sources or lockfile
if: always()
run: git diff --exit-code HEAD
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 14 additions & 1 deletion apps/effectcraft-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
//! [--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality 1-100]
//! [--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice]
//! (a relative --out is relative to the working directory; --queue outputs follow the project's own settings)
//! [--overwrite] replace existing outputs (only the reviewed frame range, no trailing-frame deletion)
//! [--progress] report progress on stderr when it is not a terminal, up to four lines a second:
//! `progress <frames done> <frames total> <seconds elapsed> <seconds left, or - when unknown>`
//! effectcraft-cli render --template T.ectemplate [--values V.json] --out FILE
Expand Down Expand Up @@ -108,6 +109,7 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
(formats h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff;
--profile..--keyint: HEVC / AV1, --audio-bitrate/--opus-app: WebM Opus)
a relative --out is relative to the working directory
--overwrite: replace existing outputs; other files and later frames stay
--progress: progress lines on stderr when it is not a terminal
(progress <frames done> <total> <seconds elapsed> <seconds left|->)
render/render-frame --template T.ectemplate [--values V.json] render one template instance
Expand Down Expand Up @@ -194,6 +196,7 @@ const FLAGS: &[&str] = &[
"--adv3d",
"--autosave",
"--progress",
"--overwrite",
];

struct Args {
Expand Down Expand Up @@ -448,6 +451,9 @@ fn emit(v: &Value, json_out: bool) {
}

fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
if args.flag("--overwrite") && cmd != "render" {
return usage_err("--overwrite is only supported by render; engine commands require a preflight confirmation id");
}
if args.flag("--autosave") && cmd != "mcp" {
return usage_err("--autosave is only supported by mcp");
}
Expand Down Expand Up @@ -775,7 +781,14 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
r["outputModuleSummary"].as_str().unwrap_or("")
);
}
s.execute("renderQueue.render", json!({"wait": false})).map_err(err)?;
let mut render_params = json!({"wait": false});
if args.flag("--overwrite") {
// The explicit flag authorizes only the destinations observed in this invocation.
let review = s.execute("renderQueue.preflight", json!({})).map_err(err)?;
render_params["confirmation"] = review["confirmation"].clone();
render_params["overwrite"] = json!(true);
}
s.execute("renderQueue.render", render_params).map_err(err)?;
let tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
// `--progress`: one line per update for a program reading stderr (a terminal gets the live line).
let lines = args.flag("--progress") && !tty;
Expand Down
28 changes: 28 additions & 0 deletions apps/effectcraft-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,34 @@ fn a_failed_render_under_run_and_exec_exits_1() {
assert!(v["error"].as_str().is_some_and(|e| e.starts_with("T: ")), "{v}");
}

#[test]
fn render_overwrite_flag_is_explicit_for_saved_queues() {
let root = tmp("overwrite-confirmation");
std::fs::create_dir_all(&root).unwrap();
let project = root.join("queue.ecproj");
let output = root.join("out.gif");
let add = json!({"format": "gif", "output": output}).to_string();
ok_json(&[
"run",
"--empty",
"comp.new",
r#"{"name":"T","width":4,"height":4,"frameRate":10,"duration":0.2}"#,
"renderQueue.add",
&add,
"--save-as",
project.to_str().unwrap(),
]);
std::fs::write(&output, b"old output").unwrap();
let args = ["render", "--project", project.to_str().unwrap(), "--queue"];
let (code, result) = run_json(&args);
assert_eq!(code, 1, "{result}");
assert!(result["error"].as_str().unwrap().contains("preflight"));
assert_eq!(std::fs::read(&output).unwrap(), b"old output");
ok_json(&[args.as_slice(), &["--overwrite"]].concat());
assert!(std::fs::read(&output).unwrap().starts_with(b"GIF89a"));
std::fs::remove_dir_all(root).unwrap();
}

/// `render --bitrate` (#440) targets the bitrate in VP9 WebM.
#[test]
fn render_bitrate_applies_to_vp9_webm() {
Expand Down
2 changes: 1 addition & 1 deletion crates/automation/src/confine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ const WRITES: &[(&str, &[&str])] = &[
];

/// Commands that write where they are not told: the project's file, the Render Queue's outputs.
const IMPLICIT_WRITES: &[&str] = &["file.save", "file.incrementAndSave", "renderQueue.render", "render.preRender"];
const IMPLICIT_WRITES: &[&str] = &["file.save", "file.incrementAndSave", "renderQueue.render", "renderQueue.preflight", "render.preRender"];

/// What must be looked up before an implicitly writing command may run.
#[derive(Debug, PartialEq, Eq)]
Expand Down
3 changes: 3 additions & 0 deletions crates/automation/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1364,6 +1364,9 @@ fn automation_roots_confine_file_access() {
assert!(queue["items"][0]["outputPath"].as_str().is_some_and(|o| o.starts_with(&*read.to_string_lossy())), "{queue}");
let msg = denied(&mut s, "execute_command", json!({"command": "renderQueue.render"}));
assert!(msg.contains("cannot write"), "{msg}");
// Reviewing destination metadata must obey the same root check before inspecting it.
let msg = denied(&mut s, "execute_command", json!({"command": "renderQueue.preflight"}));
assert!(msg.contains("cannot write"), "{msg}");
call_json(&mut s, "open_project", json!({"path": p(&write, "saved.ecproj")}));
call_json(&mut s, "save_project", json!({}));
// Through a symbolic link out of a root.
Expand Down
31 changes: 29 additions & 2 deletions crates/engine/src/commands/render_queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,14 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {

fn render(s: &mut Session, p: &Value) -> Result<Value> {
let wait = b_p(p, "wait").unwrap_or(true);
let confirmation = match p.get("confirmation") {
None => None,
Some(v) => Some(v.as_u64().ok_or_else(|| bad("renderQueue.render", "confirmation must be a preflight id"))?),
};
let overwrite = match p.get("overwrite") {
None => false,
Some(v) => v.as_bool().ok_or_else(|| bad("renderQueue.render", "overwrite must be a boolean"))?,
};
// Settings ▸ Project ▸ Auto-Save ▸ Save When Starting Render Queue.
if s.prefs.auto_save.enabled
&& s.prefs.auto_save.save_on_render_start
Expand All @@ -678,11 +686,20 @@ fn render(s: &mut Session, p: &Value) -> Result<Value> {
{
log::warn!("{e}");
}
let ids = s.start_render(wait).map_err(EngineError::Other)?;
let ids = s.start_render_reviewed(wait, confirmation, overwrite).map_err(EngineError::Other)?;
let items: Vec<Value> = s.project.render_queue.iter().enumerate().filter(|(_, i)| ids.contains(&i.id)).map(|(k, i)| item_json(s, i, k)).collect();
Ok(json!({"rendering": s.is_rendering(), "items": items}))
}

fn preflight(s: &mut Session, _: &Value) -> Result<Value> {
s.preflight_render().map_err(EngineError::Other)
}

fn cancel_preflight(s: &mut Session, p: &Value) -> Result<Value> {
let id = p.get("confirmation").and_then(Value::as_u64).ok_or_else(|| bad("renderQueue.cancelPreflight", "confirmation must be a preflight id"))?;
Ok(json!({"cancelled": s.cancel_render_preflight(id)}))
}

fn stop(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({"stopped": s.stop_render()}))
}
Expand Down Expand Up @@ -1045,7 +1062,17 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("render.saveCurrentPreview", "Save Current Preview...", ["Composition"], None, "{comp?, path}", has_comp, save_current_preview),
cmd!("renderQueue.move", "Move in Render Queue", [], None, "{item?|index?, to: n (1-based)}", has_items, move_item),
cmd!("renderQueue.duplicate", "Duplicate Render Item", [], None, "{item?|index?}", has_items, duplicate),
cmd!("renderQueue.render", "Render", [], None, "{wait?: bool (default true; the UI renders in the background)}", has_queued, render),
query!("renderQueue.preflight", "Review Render Outputs", "{} → {confirmation, collisions: [path]}", preflight),
query!("renderQueue.cancelPreflight", "Cancel Render Review", "{confirmation}", cancel_preflight),
cmd!(
"renderQueue.render",
"Render",
[],
None,
"{wait?: bool (default true), confirmation?: preflight id, overwrite?: bool (default false; requires the reviewed confirmation)}",
has_queued,
render
),
cmd!("renderQueue.stop", "Stop Rendering", [], None, "{}", rendering, stop),
query!("renderQueue.list", "Render Queue Items", "{}", list),
query!("renderQueue.templates", "Render Templates", "{}", templates),
Expand Down
4 changes: 4 additions & 0 deletions crates/engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,9 @@ pub struct Session {
pub storage_quota: Option<Arc<dyn effectcraft_project::render_queue::StorageQuota>>,
/// The running (or finished, not yet polled) render.
pub render_job: Option<render_queue::RenderJob>,
/// Pending one-shot output review (managed by the render queue).
#[doc(hidden)]
pub render_review: Option<render_queue::RenderReview>,
/// The running (or finished, not yet polled) track analysis.
pub track_job: Option<tracking::TrackJob>,
/// The running (or finished, not yet polled) mask track.
Expand Down Expand Up @@ -574,6 +577,7 @@ impl Default for Session {
exporter: None,
storage_quota: None,
render_job: None,
render_review: None,
track_job: None,
mask_job: None,
warp_job: None,
Expand Down
41 changes: 38 additions & 3 deletions crates/engine/src/offload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ pub enum WorkerJob {
/// output module.
Render {
items: Vec<Vec<(RenderQueueItem, String)>>,
#[serde(default)]
authorizations: Vec<Vec<serde_json::Value>>,
},
Warp {
comp: ItemId,
Expand Down Expand Up @@ -472,7 +474,7 @@ async fn run_job(s: &mut Session, job: WorkerJob, post: Post) -> Result<(), Stri
let kind = job.kind();
let target = job.target();
match job {
WorkerJob::Render { items } => {
WorkerJob::Render { items, authorizations } => {
let exporter = s.exporter.clone().ok_or("export is not available in this build")?;
let shared = JobShared::default();
// Item updates and progress go out as they happen (progress at most every 100 ms).
Expand All @@ -487,7 +489,7 @@ async fn run_job(s: &mut Session, job: WorkerJob, post: Post) -> Result<(), Stri
post(WorkerReply::Render { state: sh.snapshot() });
}
};
crate::render_queue::run_items(s, exporter, items, &shared, &mut |sh| notify(sh, false)).await;
crate::render_queue::run_items(s, exporter, items, authorizations, &shared, &mut |sh| notify(sh, false)).await;
notify(&shared, true);
Ok(())
}
Expand Down Expand Up @@ -1074,7 +1076,7 @@ mod tests {
s.execute("renderQueue.add", serde_json::json!({})).unwrap();
let item = s.project.render_queue[0].clone();
let reqs = [
WorkerJob::Render { items: vec![vec![(item.clone(), "/out.gif".into())]] },
WorkerJob::Render { items: vec![vec![(item.clone(), "/out.gif".into())]], authorizations: vec![] },
WorkerJob::Warp { comp, layer: LayerId(3), effect: 9 },
WorkerJob::WarpPlan { comp, layer: LayerId(3), effect: 9, time: Tick::from_seconds_f64(0.5) },
WorkerJob::RotoFreeze { comp, layer: LayerId(3), effect: 9 },
Expand Down Expand Up @@ -1168,6 +1170,39 @@ mod tests {
}
}

#[test]
fn legacy_worker_render_uses_the_exporters_safe_default() {
struct Legacy(Arc<Mutex<usize>>);
impl crate::Exporter for Legacy {
fn formats(&self) -> Vec<effectcraft_project::render_queue::OutputFormat> {
vec![effectcraft_project::render_queue::OutputFormat::Gif]
}
fn export(&self, _: &crate::ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<crate::ExportResult, String> {
*self.0.lock().unwrap() += 1;
Ok(crate::ExportResult::default())
}
fn export_authorized_async<'a>(
&'a self,
_: &'a crate::ExportJob<'a>,
_: &'a serde_json::Value,
_: &'a mut dyn FnMut(u64, u64) -> bool,
) -> crate::render_queue::LocalFuture<'a, Result<crate::ExportResult, String>> {
Box::pin(async { Err("a legacy request has no authorization".into()) })
}
}
let calls = Arc::new(Mutex::new(0));
let mut s = Session { exporter: Some(Arc::new(Legacy(calls.clone()))), ..Default::default() };
s.execute("comp.new", serde_json::json!({"width": 4, "height": 4, "duration": 0.2})).unwrap();
s.execute("renderQueue.add", serde_json::json!({"format": "gif", "output": "legacy.gif"})).unwrap();
let item = s.project.render_queue[0].clone();
// The old wire format has no authorizations field.
let job: WorkerJob = serde_json::from_value(serde_json::json!({"kind": "render", "items": [[[item, "legacy.gif"]]]})).unwrap();
let req = WorkerRequest { id: 1, project: s.project.to_json(), files: vec![], job };
let post: Post = Rc::new(|_| {});
run_request(&mut s, req, &post);
assert_eq!(*calls.lock().unwrap(), 1);
}

/// An [`Offload`] that runs the job right away in a second session, through JSON (what a
/// Web Worker does, minus the threads).
struct Inline;
Expand Down
Loading