//! Menu entries that used to be disabled stubs (M3.8 burn-down): mask and shape path commands, //! mask options, Look at Layers, view layouts, Create Camera from 3D View, output modules, //! Pre-render and Save Current Preview. use std::sync::{Arc, Mutex}; use effectcraft_keyframe::Value as KV; use effectcraft_project::render_queue::{OutputFormat, PostRenderAction}; use effectcraft_project::{FeatherFalloff, Footage, FootageKind, GroupKind, LayerId, LayerSource, MaskMotionBlur}; use effectcraft_time::{FrameRate, Tick}; use serde_json::json; use crate::{Event, ExportJob, ExportResult, Exporter, Importer, Session}; fn comp() -> Session { let mut s = Session::default(); s.execute("comp.new", json!({"name": "Main", "width": 400, "height": 300, "frameRate": 30, "duration": 4})).unwrap(); s } fn layer(s: &Session, id: u64) -> effectcraft_project::Layer { s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone() } /// A solid with one closed square mask; returns (layer id, mask uid). fn masked(s: &mut Session) -> (u64, u64) { let l = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 400, "height": 300})).unwrap()["layer"].as_u64().unwrap(); s.execute("mask.new", json!({"layer": l, "vertices": [[100, 100], [300, 100], [300, 200], [100, 200]], "closed": true})).unwrap(); let m = layer(s, l).masks().unwrap().groups().next().unwrap().uid; (l, m) } fn mask_path(s: &Session, l: u64, m: u64) -> effectcraft_keyframe::ShapePath { let ly = layer(s, l); let g = ly.masks().unwrap().find_group(m).unwrap(); g.get("path").unwrap().value.as_path().unwrap().clone() } fn mask_kind(s: &Session, l: u64) -> GroupKind { layer(s, l).masks().unwrap().groups().next().unwrap().kind.clone() } #[test] fn roto_bezier_and_convert_to_bezier() { let mut s = comp(); let (l, m) = masked(&mut s); assert!(mask_path(&s, l, m).out_tangents.iter().all(|t| *t == [0.0, 0.0])); s.execute("path.rotoBezier", json!({"layer": l})).unwrap(); assert!(matches!(mask_kind(&s, l), GroupKind::Mask { roto_bezier: true, .. })); let p = mask_path(&s, l, m); // Vertex 0 at (100,100): neighbours (100,200) and (300,100) → tangent (200,-100)/6. assert!((p.out_tangents[0][0] - 200.0 / 6.0).abs() < 1e-9 && (p.out_tangents[0][1] + 100.0 / 6.0).abs() < 1e-9, "{:?}", p.out_tangents); s.execute("layer.select", json!({"layers": [l]})).unwrap(); assert_eq!(crate::menus::checked(&s, "path.rotoBezier", &json!({})), Some(true)); // Moving a vertex keeps the tangents automatic. s.execute("mask.selectVertices", json!({"vertices": [{"layer": l, "mask": m, "index": 2}]})).unwrap(); s.execute("mask.moveVertices", json!({"delta": [50, 50]})).unwrap(); let q = mask_path(&s, l, m); assert_ne!(q.out_tangents[1], p.out_tangents[1]); s.execute("path.convertToBezier", json!({"layer": l})).unwrap(); assert!(matches!(mask_kind(&s, l), GroupKind::Mask { roto_bezier: false, .. })); assert_eq!(mask_path(&s, l, m), q, "convert keeps the tangents"); s.execute("edit.undo", json!({})).unwrap(); assert!(matches!(mask_kind(&s, l), GroupKind::Mask { roto_bezier: true, .. })); } #[test] fn set_first_vertex_and_free_transform() { let mut s = comp(); let (l, m) = masked(&mut s); s.state.selected_vertices.clear(); assert!(!s.is_enabled("path.setFirstVertex")); s.execute("mask.selectVertices", json!({"vertices": [{"layer": l, "mask": m, "index": 2}]})).unwrap(); assert!(s.is_enabled("path.setFirstVertex")); s.execute("path.setFirstVertex", json!({})).unwrap(); let p = mask_path(&s, l, m); assert_eq!(p.vertices[0], [300.0, 200.0]); assert_eq!(p.vertices[3], [300.0, 100.0]); assert_eq!(s.state.selected_vertices[0].index, 0); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(mask_path(&s, l, m).vertices[0], [100.0, 100.0]); // Free Transform the whole mask: scale 50 % about its centre, then move. s.state.selected_vertices.clear(); s.execute("path.freeTransform", json!({"layer": l, "scale": 50, "offset": [10, 0]})).unwrap(); let p = mask_path(&s, l, m); assert_eq!(p.vertices[0], [160.0, 125.0]); assert_eq!(p.vertices[2], [260.0, 175.0]); // Rotation of selected vertices only. s.execute("mask.selectVertices", json!({"vertices": [{"layer": l, "mask": m, "index": 0}, {"layer": l, "mask": m, "index": 1}]})).unwrap(); s.execute("path.freeTransform", json!({"rotation": 180})).unwrap(); let q = mask_path(&s, l, m); assert!((q.vertices[0][0] - 260.0).abs() < 1e-9 && (q.vertices[1][0] - 160.0).abs() < 1e-9); assert_eq!(q.vertices[2], p.vertices[2]); assert!(s.execute("path.freeTransform", json!({})).is_err()); } #[test] fn group_and_ungroup_shapes() { let mut s = comp(); let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(); let contents = layer(&s, l).props.sub("contents").unwrap().clone(); let outer = contents.groups().next().unwrap().clone(); let inner: Vec = outer.sub("contents").unwrap().groups().map(|g| g.uid).collect(); assert!(inner.len() >= 2, "{outer:?}"); s.execute("layer.select", json!({"layers": [l]})).unwrap(); s.state.selected_props = inner.iter().map(|u| (LayerId(l), *u)).collect(); assert!(s.is_enabled("path.groupShapes")); let g = s.execute("path.groupShapes", json!({})).unwrap()["group"].as_u64().unwrap(); let ly = layer(&s, l); let o = ly.props.sub("contents").unwrap().find_group(outer.uid).unwrap(); let kids: Vec<&str> = o.sub("contents").unwrap().groups().map(|x| x.match_id.as_str()).collect(); assert_eq!(kids, ["group"]); assert_eq!(o.find_group(g).unwrap().sub("contents").unwrap().groups().count(), inner.len()); let before = s.render_rgba8(s.active_comp_id().unwrap(), Tick::ZERO, 200).unwrap(); // Move the new group, then ungroup: the offset is baked into its items. let gp = ly.props.sub("contents").unwrap().find_group(g).unwrap().sub("transform").unwrap().get("position").unwrap().uid; s.edit("t", None, |proj, _| { let cid = proj.comps().next().map(|(i, _)| *i).unwrap(); let l = proj.comp_mut(cid).unwrap().layer_mut(LayerId(l)).unwrap(); l.props.find_mut(gp).unwrap().value = KV::Vec2([0.0, 0.0]); Ok(()) }) .unwrap(); s.state.selected_props = vec![(LayerId(l), g)]; s.execute("path.ungroupShapes", json!({})).unwrap(); let ly = layer(&s, l); let o = ly.props.sub("contents").unwrap().find_group(outer.uid).unwrap(); assert_eq!(o.sub("contents").unwrap().groups().count(), inner.len()); assert_eq!(s.render_rgba8(s.active_comp_id().unwrap(), Tick::ZERO, 200).unwrap(), before); s.execute("edit.undo", json!({})).unwrap(); assert!(layer(&s, l).props.find_group(g).is_some()); } #[test] fn mask_motion_blur_feather_falloff_and_hide_locked() { let mut s = comp(); let (l, _) = masked(&mut s); s.execute("layer.mask.set", json!({"layer": l, "field": "feather", "value": 40})).unwrap(); let cid = s.active_comp_id().unwrap(); let smooth = s.render_rgba8(cid, Tick::ZERO, 0).unwrap(); s.execute("layer.mask.featherFalloff", json!({"layer": l, "mode": "linear"})).unwrap(); assert!(matches!(mask_kind(&s, l), GroupKind::Mask { feather_falloff: FeatherFalloff::Linear, .. })); let linear = s.render_rgba8(cid, Tick::ZERO, 0).unwrap(); assert_ne!(smooth, linear); s.execute("layer.select", json!({"layers": [l]})).unwrap(); assert_eq!(crate::menus::checked(&s, "layer.mask.featherFalloff", &json!({"mode": "linear"})), Some(true)); s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "on"})).unwrap(); assert!(matches!(mask_kind(&s, l), GroupKind::Mask { motion_blur: MaskMotionBlur::On, .. })); assert!(s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "sometimes"})).is_err()); // Mask motion blur blurs an animated mask path. s.execute("layer.mask.set", json!({"layer": l, "field": "feather", "value": 0})).unwrap(); let still = s.render_rgba8(cid, Tick::from_seconds_f64(0.5), 0).unwrap(); s.execute("prop.addKey", json!({"layer": l, "path": "masks/#1/path", "time": 0.0})).unwrap(); s.execute("time.set", json!({"time": 1.0})).unwrap(); s.execute("mask.selectVertices", json!({"vertices": []})).unwrap(); s.execute("path.freeTransform", json!({"layer": l, "offset": [80, 0]})).unwrap(); let blurred = s.render_rgba8(cid, Tick::from_seconds_f64(0.5), 0).unwrap(); let soft = |img: &(u32, u32, Vec)| img.2.chunks(4).filter(|p| p[0] > 10 && p[0] < 245).count(); assert!(soft(&blurred) > soft(&still) + 100, "{} vs {}", soft(&blurred), soft(&still)); s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "off"})).unwrap(); let sharp = s.render_rgba8(cid, Tick::from_seconds_f64(0.5), 0).unwrap(); assert!(soft(&sharp) < soft(&blurred)); s.execute("edit.undo", json!({})).unwrap(); assert!(matches!(mask_kind(&s, l), GroupKind::Mask { motion_blur: MaskMotionBlur::On, .. })); // Hide Locked Masks is a viewer toggle. assert!(s.execute("layer.mask.hideLocked", json!({})).unwrap().as_bool().unwrap()); assert_eq!(crate::menus::checked(&s, "layer.mask.hideLocked", &json!({})), Some(true)); } #[test] fn look_at_layers_2d_and_3d() { let mut s = comp(); let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 40, "height": 20})).unwrap()["layer"].as_u64().unwrap(); s.execute("layer.setTransform", json!({"layer": a, "prop": "position", "value": [50, 60]})).unwrap(); s.drain_events(); s.execute("layer.select", json!({"layers": [a]})).unwrap(); let r = s.execute("view.lookAtSelected", json!({})).unwrap(); assert_eq!(r["rect"], json!([30.0, 50.0, 70.0, 70.0])); assert!(s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == "view.lookAt"))); // 3D: a custom view frames the layers; the active camera view moves the camera (undoable). s.execute("layer.setSwitch", json!({"layer": a, "switch": "threeD", "value": true})).unwrap(); s.execute("layer.newCamera", json!({})).unwrap(); let cid = s.active_comp_id().unwrap(); s.execute("view.3d.custom1", json!({})).unwrap(); let r = s.execute("view.lookAtAll", json!({})).unwrap(); let poi = r["poi"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::>(); assert!((poi[0] - 50.0).abs() < 1e-6 && (poi[1] - 60.0).abs() < 1e-6, "{r}"); s.execute("view.3d.activeCamera", json!({})).unwrap(); let cam = s.active_comp().unwrap().layers.iter().find(|l| l.is_camera()).unwrap().id; let before = s.active_comp().unwrap().layer(cam).unwrap().props.prop("transform/position").unwrap().value.clone(); s.execute("layer.select", json!({"layers": [a]})).unwrap(); s.execute("view.lookAtSelected", json!({})).unwrap(); let c = s.project.comp(cid).unwrap().layer(cam).unwrap(); assert_eq!(c.props.prop("transform/poi").unwrap().value.as_vec3()[..2], [50.0, 60.0]); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(s.project.comp(cid).unwrap().layer(cam).unwrap().props.prop("transform/position").unwrap().value, before); } #[test] fn camera_from_view_and_view_layout() { let mut s = comp(); s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); assert!(s.execute("camera.fromView", json!({})).is_err(), "needs a 3D view"); s.execute("view.3d.custom2", json!({})).unwrap(); let cid = s.active_comp_id().unwrap(); let vc = s.state.views3d[&cid].cam(effectcraft_render::three_d::View3D::Custom2, 400.0, 300.0); let r = s.execute("camera.fromView", json!({})).unwrap(); let cam = layer(&s, r["layer"].as_u64().unwrap()); assert!(cam.is_camera()); let pos = cam.props.prop("transform/position").unwrap().value.as_vec3(); assert!((0..3).all(|i| (pos[i] - vc.eye[i]).abs() < 1e-6), "{pos:?} {:?}", vc.eye); assert_eq!(s.state.views3d[&cid].current, effectcraft_render::three_d::View3D::ActiveCamera); s.execute("edit.undo", json!({})).unwrap(); assert!(!s.active_comp().unwrap().layers.iter().any(|l| l.is_camera())); // Switch View Layout. assert_eq!(s.state.view_layout, 1); s.execute("view.layout", json!({"views": 2})).unwrap(); assert_eq!(s.state.view_layout, 2); assert_eq!(crate::menus::checked(&s, "view.layout", &json!({"views": 2})), Some(true)); assert!(s.execute("view.layout", json!({"views": 3})).is_err()); assert!(s.execute("view.shareViewOptions", json!({})).unwrap().as_bool().unwrap()); } struct MockExporter { log: Mutex>, } impl Exporter for MockExporter { fn formats(&self) -> Vec { OutputFormat::ALL.to_vec() } fn export(&self, job: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result { self.log.lock().unwrap().push(format!("{:?}:{}", job.item.output.format, job.path)); Ok(ExportResult { path: job.path.to_string(), frames: 1, ..Default::default() }) } } struct MockImporter; impl Importer for MockImporter { fn probe(&self, path: &str) -> Result { Ok(Footage { path: path.into(), kind: FootageKind::Video, width: 400, height: 300, pixel_aspect: 1.0, frame_rate: FrameRate::FPS_30, native_rate: None, duration: Tick::from_seconds_f64(4.0), has_video: true, has_audio: false, alpha: Default::default(), premul_color: [0.0; 3], loop_count: 1, codec: "mock".into(), missing: false, sequence: vec![], color_profile: None, ..Default::default() }) } } fn rq() -> (Session, Arc) { let mut s = comp(); let ex = Arc::new(MockExporter { log: Default::default() }); s.exporter = Some(ex.clone()); s.importer = Some(Arc::new(MockImporter)); (s, ex) } #[test] fn add_output_module_encodes_twice() { let (mut s, ex) = rq(); assert!(!s.is_enabled("render.addOutputModule")); s.execute("renderQueue.add", json!({"output": "/tmp/om/main.mp4"})).unwrap(); let r = s.execute("render.addOutputModule", json!({"format": "png"})).unwrap(); assert_eq!(r["module"], 2); assert_eq!(r["outputModules"], 2); let extra = r["extraOutputs"][0]["outputPath"].as_str().unwrap().to_string(); assert!(extra.ends_with(".png"), "{extra}"); // Module 2 is editable on its own. s.execute("renderQueue.setOutput", json!({"index": 1, "module": 2, "path": "/tmp/om/second.mov"})).unwrap(); assert!(s.execute("renderQueue.setOutput", json!({"index": 1, "module": 3, "path": "/tmp/x.mov"})).is_err()); s.execute("renderQueue.render", json!({})).unwrap(); assert_eq!(*ex.log.lock().unwrap(), vec!["H264:/tmp/om/main.mp4".to_string(), "ProRes:/tmp/om/second.mov".to_string()]); assert_eq!(s.project.render_queue[0].status.label(), "Done"); assert_eq!(s.project.render_queue[0].last_output.as_deref(), Some("/tmp/om/main.mp4")); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(s.project.render_queue[0].extra_outputs.len(), 1, "undo the Output To edit"); } #[test] fn pre_render_imports_and_replaces() { let (mut s, ex) = rq(); let inner = s.active_comp_id().unwrap(); s.execute("layer.newSolid", json!({"color": "#00ff00"})).unwrap(); s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 300, "frameRate": 30, "duration": 4})).unwrap(); let outer = s.active_comp_id().unwrap(); s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); s.open_comp(inner); let r = s.execute("render.preRender", json!({"output": "/tmp/pre/inner.mov"})).unwrap(); assert_eq!(r["postRenderAction"], "Import & Replace Usage"); assert_eq!(s.project.render_queue[0].post_render, PostRenderAction::ImportAndReplace); s.execute("renderQueue.render", json!({})).unwrap(); assert_eq!(ex.log.lock().unwrap().len(), 1); let l = &s.project.comp(outer).unwrap().layers[0]; let LayerSource::Footage { item } = l.source else { panic!("not replaced: {:?}", l.source) }; assert!(matches!(&s.project.item(item).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if f.path == "/tmp/pre/inner.mov")); s.execute("edit.undo", json!({})).unwrap(); assert!(matches!(s.project.comp(outer).unwrap().layers[0].source, LayerSource::Comp { .. })); } #[test] fn save_current_preview_writes_a_movie() { let (mut s, ex) = rq(); let r = s.execute("render.saveCurrentPreview", json!({"path": "/tmp/prev/clip.mov"})).unwrap(); assert_eq!(r["path"], "/tmp/prev/clip.mov"); assert_eq!(*ex.log.lock().unwrap(), vec!["ProRes:/tmp/prev/clip.mov".to_string()]); assert!(s.project.render_queue.is_empty(), "not queued"); } #[test] fn implemented_stubs_are_enabled_and_workspaces_are_frontend() { let mut s = comp(); for id in ["view.layout", "view.lookAtAll", "camera.fromView", "render.saveCurrentPreview", "render.preRender", "layer.mask.hideLocked"] { assert!(s.is_enabled(id), "{id}"); } for id in ["window.saveWorkspace", "window.saveWorkspaceAs", "window.editWorkspaces"] { s.execute(id, json!({"name": "Mine"})).unwrap(); assert!(s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == id))); } // What remains a stub is still disabled. for id in ["layer.autoTrace", "keys.audioToKeyframes"] { assert!(!s.is_enabled(id), "{id}"); } }