diff --git a/crates/automation/tests/qa/lifecycle.rs b/crates/automation/tests/qa/lifecycle.rs new file mode 100644 index 00000000..97931928 --- /dev/null +++ b/crates/automation/tests/qa/lifecycle.rs @@ -0,0 +1,136 @@ +//! Scenario 6 — project lifecycle: save and reopen, Save a Copy As XML, Collect Files, Reduce +//! Project, undo/redo across 50 operations, a jump across history branches, render templates +//! and a watch folder. + +use serde_json::json; + +use crate::harness::{Qa, mean_diff}; + +#[test] +fn project_lifecycle() { + let mut qa = Qa::new("lifecycle"); + // Footage to collect later. + std::fs::create_dir_all(qa.dir.join("media")).unwrap(); + image::RgbaImage::from_pixel(32, 32, image::Rgba([10, 200, 90, 255])).save(qa.dir.join("media/logo.png")).unwrap(); + let logo = qa.exec("file.import", json!({"paths": [qa.path("media/logo.png")]}))["items"][0].clone(); + + let main = qa.exec("comp.new", json!({"name": "Main", "width": 96, "height": 54, "frameRate": 12, "duration": 1}))["comp"].clone(); + qa.exec("layer.addItem", json!({"item": logo.clone()})); + let unused = qa.exec("comp.new", json!({"name": "Unused", "width": 64, "height": 64}))["comp"].clone(); + qa.exec("layer.newSolid", json!({"name": "Junk", "color": "#ff0000"})); + qa.exec("comp.open", json!({"comp": main.clone()})); + + // 50 edits, one undo step each. Levels of Undo defaults to 32 (as in After Effects). + assert_eq!(qa.exec("prefs.get", json!({"key": "general.undoLevels"})), json!(32)); + qa.exec("prefs.set", json!({"key": "general.undoLevels", "value": 99})); + let box_id = qa.exec("layer.newSolid", json!({"name": "Box", "color": "#2040ff", "width": 20, "height": 20}))["layer"].clone(); + let steps_before = qa.tool("get_project", json!({}))["undo"].as_array().unwrap().len(); + for i in 0..50 { + qa.tool("set_property", json!({"layer": box_id.clone(), "path": "transform/position", "value": [10 + i, 27]})); + } + let s = qa.tool("get_project", json!({})); + let n = s["undo"].as_array().unwrap().len(); + assert_eq!(n, (steps_before + 50).min(99), "50 more undo steps recorded"); + let end = qa.frame(None, 0.0); + let u = qa.tool("undo", json!({"steps": 50})); + assert_eq!(u["steps"], 50); + let pos = qa.tool("get_property", json!({"layer": box_id.clone(), "path": "transform/position"}))["value"].clone(); + assert_eq!(pos, json!([48.0, 27.0, 0.0]), "back to the solid's default position"); + let r = qa.tool("redo", json!({"steps": 50})); + assert_eq!(r["steps"], 50); + assert!(mean_diff(&end, &qa.frame(None, 0.0)) < 1e-9, "redo restores the same frame"); + + // Branching history: undo 5, make a different edit, then jump back to the old branch's tip. + qa.tool("undo", json!({"steps": 5})); + qa.tool("set_property", json!({"layer": box_id.clone(), "path": "transform/opacity", "value": 25})); + let h = qa.tool("history", json!({})); + let nodes = h.as_array().unwrap_or_else(|| h["states"].as_array().expect("history list")); + let branch_tip = nodes.iter().filter(|n| n["depth"].as_u64().unwrap_or(0) > 0).max_by_key(|n| n["index"].as_u64()).expect("a branch").clone(); + qa.tool("history", json!({"goto": branch_tip["id"].clone()})); + let pos = qa.tool("get_property", json!({"layer": box_id.clone(), "path": "transform/position"}))["value"].clone(); + assert_eq!(pos, json!([59.0, 27.0, 0.0]), "the old branch's last edit is back"); + let op = qa.tool("get_property", json!({"layer": box_id.clone(), "path": "transform/opacity"}))["value"].as_f64().unwrap(); + assert_eq!(op, 100.0, "and the other branch's edit is not on this line"); + + // Save, reopen, compare. + let proj = qa.path("show.ecproj"); + let saved = qa.tool("save_project", json!({"path": proj})); + assert_eq!(saved["dirty"], false); + let before = qa.frame(Some(main.clone()), 0.0); + qa.tool("open_project", json!({"new": true})); + let reopened = qa.tool("open_project", json!({"path": proj})); + assert_eq!(reopened["path"], json!(proj)); + let main2 = reopened["items"].as_array().unwrap().iter().find(|i| i["name"] == "Main").unwrap()["id"].clone(); + assert!(mean_diff(&before, &qa.frame(Some(main2.clone()), 0.0)) < 1e-9, "the reopened project renders the same"); + + // Save a Copy As XML: reopens to the same frame and leaves the session's path alone. + let xml = qa.path("show.ecprojx"); + qa.exec("file.saveCopyAsXml", json!({"path": xml})); + assert!(std::fs::read_to_string(&xml).unwrap().trim_start().starts_with('<')); + assert_eq!(qa.tool("get_project", json!({}))["path"], json!(proj)); + qa.tool("open_project", json!({"path": xml})); + assert!(mean_diff(&before, &qa.frame(Some(main2.clone()), 0.0)) < 1e-9, "the XML copy renders the same"); + qa.tool("open_project", json!({"path": proj})); + + // Collect Files: the project and its footage in one folder that opens on its own. + let collected = qa.path("collected"); + let c = qa.exec("file.collectFiles", json!({"folder": collected})); + assert_eq!((c["copied"].clone(), c["errors"].clone()), (json!(1), json!([])), "Collect Files makes its folder: {c}"); + let files: Vec = walk(&qa.dir.join("collected")); + assert!(files.iter().any(|f| f.ends_with(".ecproj")) && files.iter().any(|f| f.ends_with("logo.png")), "{files:?}"); + std::fs::remove_file(qa.dir.join("media/logo.png")).unwrap(); + let cproj = files.iter().find(|f| f.ends_with(".ecproj")).unwrap().clone(); + let reo = qa.tool("open_project", json!({"path": cproj})); + let cm = reo["items"].as_array().unwrap().iter().find(|i| i["name"] == "Main").unwrap()["id"].clone(); + assert!(mean_diff(&before, &qa.frame(Some(cm), 0.0)) < 1e-9, "the collected project finds its footage"); + + // Reduce Project to Main: Unused goes, the footage Main uses stays. + qa.exec("project.select", json!({"items": [main2.clone()]})); + qa.exec("file.reduceProject", json!({})); + let names: Vec = qa.tool("get_project", json!({}))["items"].as_array().unwrap().iter().map(|i| i["name"].as_str().unwrap().to_string()).collect(); + assert!(names.contains(&"Main".to_string()) && names.contains(&"logo.png".to_string()) && !names.contains(&"Unused".to_string()), "{names:?}"); + let _ = unused; + + // Render templates: save an output-module template, queue with it, render. + qa.exec("renderQueue.saveTemplate", json!({"kind": "outputModule", "name": "QA PNG", "params": {"format": "png", "channels": "rgba"}})); + let tpl = qa.exec("renderQueue.templates", json!({})); + assert!(tpl.to_string().contains("QA PNG"), "{tpl}"); + qa.exec("renderQueue.add", json!({"comp": "Main", "output": qa.path("out/main_[##].png")})); + qa.exec("renderQueue.applyTemplate", json!({"index": 1, "outputModule": "QA PNG"})); + let list = qa.exec("renderQueue.list", json!({})); + assert_eq!(list["items"][0]["output"]["format"], "PngSequence", "{list}"); + assert_eq!(list["items"][0]["output"]["channels"], "Rgba"); + let done = qa.exec("renderQueue.render", json!({"wait": true})); + assert_eq!(done["items"][0]["status"], "Done", "{done}"); + assert_eq!(walk(&qa.dir.join("out")).iter().filter(|f| f.ends_with(".png")).count(), 12); + + // Watch folder: a project with a queued render dropped into the folder is rendered. + let watch = qa.dir.join("watch"); + std::fs::create_dir_all(&watch).unwrap(); + qa.exec("renderQueue.setRender", json!({"index": 1, "render": true})); + qa.exec("renderQueue.setOutput", json!({"index": 1, "path": watch.join("w_[##].png").to_string_lossy()})); + qa.tool("save_project", json!({"path": watch.join("job.ecproj").to_string_lossy()})); + // Starting the watch renders what is already there; a poll then finds nothing new. + let started = qa.exec("file.watchFolder", json!({"folder": watch.to_string_lossy()})); + assert_eq!(started["rendered"][0]["state"], "done", "{started}"); + let polled = qa.exec("file.watchFolder.poll", json!({})); + assert_eq!(polled["rendered"], json!([]), "{polled}"); + assert!(watch.join("job.ecproj.status.json").exists() || walk(&watch).iter().any(|f| f.ends_with(".status.json"))); + assert_eq!(walk(&watch).iter().filter(|f| f.ends_with(".png")).count(), 12, "{:?}", walk(&watch)); + qa.exec("file.watchFolder", json!({"folder": watch.to_string_lossy(), "stop": true})); +} + +fn walk(dir: &std::path::Path) -> Vec { + let mut out = vec![]; + if let Ok(rd) = std::fs::read_dir(dir) { + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + out.extend(walk(&p)); + } else { + out.push(p.to_string_lossy().into_owned()); + } + } + } + out +} diff --git a/crates/automation/tests/qa/main.rs b/crates/automation/tests/qa/main.rs index 99741ef3..0240a3d0 100644 --- a/crates/automation/tests/qa/main.rs +++ b/crates/automation/tests/qa/main.rs @@ -5,5 +5,6 @@ mod compositing; mod harness; mod kinetic; +mod lifecycle; mod shapes; mod tracking; diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 9b0c2039..26447200 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -313,6 +313,9 @@ fn find_missing(s: &mut Session, p: &Value) -> Result { fn collect_files(s: &mut Session, p: &Value) -> Result { let folder = str_p(p, "folder").ok_or_else(|| bad("file.collectFiles", "missing `folder`"))?.trim_end_matches('/').to_string(); + // Collect Files makes its folder (and the footage folder inside it). + #[cfg(not(target_arch = "wasm32"))] + std::fs::create_dir_all(format!("{folder}/(Footage)")).map_err(|e| EngineError::Other(format!("cannot create {folder}: {e}")))?; let mut proj = (*s.project).clone(); let mut copied = 0; let mut errors = vec![]; diff --git a/crates/engine/src/commands/watch_folder.rs b/crates/engine/src/commands/watch_folder.rs index b10cf8d5..fd41071e 100644 --- a/crates/engine/src/commands/watch_folder.rs +++ b/crates/engine/src/commands/watch_folder.rs @@ -146,7 +146,7 @@ pub fn specs() -> Vec { "Poll Watch Folder", [], None, - "{folder? (default: the watched one)} → renders new projects now: [{project, state: done|failed, items: [{comp, status, output}]}]", + "{folder? (default: the watched one)} → renders new projects now: {watching, rendered: [{project, state: done|failed, items: [{comp, status, output}]}]}", |s: &Session| if s.state.watch_folder.is_some() { Ok(()) } else { Err("no folder is being watched".into()) }, poll_cmd ), diff --git a/docs/agents.md b/docs/agents.md index 330f24a9..7985cf2e 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -55,9 +55,10 @@ The server speaks JSON-RPC 2.0 over stdio, one message per line, and supports MC | `get_property {layer, path, comp?, time?}` | Value at a time, keyframes and expression. | | `set_property {layer, path, value?, time?, expression?, comp?}` | Sets a static value. With `time` it sets a keyframe; with `expression` it sets an expression. | | `add_keyframe {layer, path, time+value \| keys:[...], interpolation?}` | Adds keys, then optionally applies linear/bezier/hold/easyEase. | -| `render_frame {comp?, time?, max_side?, path?, inline?}` | Returns a PNG image of a frame. | +| `render_frame {comp?, time?, max_side?, path?, inline?, transparent?}` | Returns a PNG image of a frame; `transparent: true` keeps the alpha (as an RGB + Alpha render writes it) instead of compositing over the comp background. | | `open_project {path \| demo \| new}` / `save_project {path?}` | Open and save files. | | `undo {steps?}` / `redo {steps?}` | History. | +| `batch {steps: [{command, params}], label?, atomic?}` | Several commands in one call and one undo step (`engine.batch`); `"$N.key"` in a param is step N's result (`"$1.layer"`). A failing step rolls the batch back and names the step. | | *(bridge)* `screenshot {panel?, id?}`, `ui_inspect`, `ui_elements {prefix?}`, `ui_click`, `ui_drag`, `ui_key`, `ui_type`, `ui_set`, `control {method, params}` | Look at and operate the live window. | Layers are referenced by id (from `get_comp`), `"#n"` (1-based index from the top) or name. Comps are @@ -78,6 +79,39 @@ instead of being ignored. 4. `execute_command {"command":"effect.apply","params":{"layer":2,"effect":"Gaussian Blur"}}`, then `set_property {"layer":2,"path":"effects/#1/blurriness","value":8}` 5. `render_frame {"time":1.0}` lets you look at the result, and `save_project {"path":"intro.ecproj"}` saves it. +### Cookbook (from end-to-end QA) + +The scenarios in `crates/automation/tests/qa/` and `apps/effectcraft-cli/tests/qa_template.rs` +build real projects through these interfaces; they are worked examples of everything below. + +* **Name things when you create them**: `layer.newText`, `layer.newShape`, `layer.newSolid`, + `layer.newNull`, `layer.newCamera` and `layer.newLight` take `name`, so later calls can say + `"layer": "Title"`. A reference that matches no layer is an error, never a silent no-op. +* **Use the paths replies give you**: `effect.apply` returns `{effects: [uid], paths: + ["effects/#2"]}`; `layer.addShapeItem {kind, group?: uid|path}` returns `{uid, path}` (e.g. + `contents/group/contents/gfill`), ready for `set_property`. `@uid` works as a whole path. +* **One call, one undo step**: `batch {"steps":[{"command":"layer.newShape","params":{"kind":"ellipse","name":"Ring"}},{"command":"layer.addShapeItem","params":{"layer":"$1.layer","kind":"trim"}},{"command":"prop.addKey","params":{"layer":"$1.layer","path":"transform/opacity","time":0,"value":0}}]}` + (a string param that is exactly `"$N"` or `"$N.key"` is replaced; `$2.path` is the trim's path). +* **Expressions**: `set_property`/`get_property` replies carry `evaluated` (what renders) next to + `value` (the keyframed value) and `expressionError` when the expression fails. +* **Easing with Keyframe Velocity**: select keys by path, `keys.select {"keys":[{"layer":"Ring","path":"contents/trim/end","time":0}]}`, + then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`. +* **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or + `{"colors":[[0,"#0080ff"],[1,[1,1,0]]],"opacities":[[0,1],[1,0.5]]}`. +* **Image sequences** import at 30 fps (Settings ▸ Import); `file.interpretFootage {"items":[id], + "frameRate":12}` conforms them (the item and layers that ran to its end get the new length). +* **Masks**: `layer.addMask {layer, shape: rect|ellipse, rect}` first, then `layer.mask.set + {field: feather|expansion|opacity, value}`. +* **Renders**: `renderQueue.add {comp, format: h264|prores|webm|png…, channels: rgba, + proresProfile: 4444, output}` then `renderQueue.render {"wait": true}`. Check a render by + importing it (`file.import`, `file.newCompFromSelection`) and `render_frame {transparent: true}` + for its alpha. (WebM VP9 alpha is written, but read back opaque for now.) +* **Essential Graphics**: controls can be addressed by name (`essential.set {"layer":"#1", + "control":"Title","value":"John Smith"}`); a command that targets an explicit `comp` runs even + when the active comp would disable it (`essential.exportTemplate {"comp":"Lower Third", …}`). +* **History**: Levels of Undo defaults to 32 (`prefs.set {"key":"general.undoLevels","value":99}`); + `history` lists branches and `history {"goto": id}` jumps between them. + ### Motion tracking Trackers live on the tracked layer (`Motion Trackers ▸ Tracker n ▸ Track Point n`) and are driven