diff --git a/crates/engine/src/offload.rs b/crates/engine/src/offload.rs index 135822d57..23cff4da6 100644 --- a/crates/engine/src/offload.rs +++ b/crates/engine/src/offload.rs @@ -992,317 +992,4 @@ pub(crate) fn merge_group(base: &PropGroup, live: &PropGroup, result: &PropGroup } #[cfg(test)] -mod tests { - use super::*; - - fn round Deserialize<'de>>(v: &T) -> String { - let a = serde_json::to_string(v).unwrap(); - let back: T = serde_json::from_str(&a).unwrap(); - let b = serde_json::to_string(&back).unwrap(); - assert_eq!(a, b); - a - } - - /// A group with properties `a` (uid 2) and `b` (uid 3) and a sub-group (uid 4) holding `c` - /// (uid 5). - fn group() -> PropGroup { - use effectcraft_keyframe::Value as KV; - use effectcraft_project::{Node, Property}; - let mut sub = PropGroup::new(4, "sub", "Sub"); - sub.children.push(Node::Prop(Property::new(5, "c", "C", KV::Scalar(0.0)))); - let mut g = PropGroup::new(1, "fx", "Effect"); - g.children.push(Node::Prop(Property::new(2, "a", "A", KV::Scalar(0.0)))); - g.children.push(Node::Prop(Property::new(3, "b", "B", KV::Scalar(0.0)))); - g.children.push(Node::Group(sub)); - g - } - - fn set(g: &mut PropGroup, uid: Uid, v: f64) { - g.find_mut(uid).unwrap().value = effectcraft_keyframe::Value::Scalar(v); - } - - fn get(g: &PropGroup, uid: Uid) -> f64 { - match g.find(uid).unwrap().value { - effectcraft_keyframe::Value::Scalar(v) => v, - ref v => panic!("{v:?}"), - } - } - - #[test] - fn analysis_results_merge_with_edits_made_meanwhile() { - use effectcraft_project::Node; - let base = group(); - // The user changes `a` and switches the effect off; the analysis writes `b` and `c`. - let mut live = base.clone(); - set(&mut live, 2, 1.0); - live.enabled = false; - let mut result = base.clone(); - set(&mut result, 3, 2.0); - set(&mut result, 5, 3.0); - let m = merge_group(&base, &live, &result).unwrap(); - assert_eq!((get(&m, 2), get(&m, 3), get(&m, 5), m.enabled), (1.0, 2.0, 3.0, false)); - // Unchanged on either side: the other side wins whole. - assert_eq!(merge_group(&base, &base, &result).unwrap(), result); - assert_eq!(merge_group(&base, &live, &base).unwrap(), live); - // Both changed `b`, differently: a conflict (nothing is applied). The same way: fine. - let mut both = live.clone(); - set(&mut both, 3, 9.0); - assert!(merge_group(&base, &both, &result).is_none()); - set(&mut both, 3, 2.0); - assert_eq!(get(&merge_group(&base, &both, &result).unwrap(), 3), 2.0); - // The analysis adds a child (a result group) while the user edits `a`: both kept. - let mut added = result.clone(); - added.children.push(Node::Group(PropGroup::new(6, "out", "Output"))); - let m = merge_group(&base, &live, &added).unwrap(); - assert!(m.find_group(6).is_some() && get(&m, 2) == 1.0); - // The analysis drops the sub-group the user didn't touch: dropped. - let mut dropped = result.clone(); - dropped.children.retain(|n| !matches!(n, Node::Group(g) if g.uid == 4)); - let m = merge_group(&base, &live, &dropped).unwrap(); - assert!(m.find_group(4).is_none() && get(&m, 2) == 1.0); - // …but if the user changed something in it, that's a conflict. - let mut touched = live.clone(); - set(&mut touched, 5, 7.0); - assert!(merge_group(&base, &touched, &dropped).is_none()); - } - - #[test] - fn protocol_round_trips() { - let mut s = Session::default(); - s.execute("file.openDemoProject", serde_json::json!({})).unwrap(); - let comp = s.active_comp_id().unwrap(); - 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::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 }, - WorkerJob::Camera { comp, layer: LayerId(3), effect: 9 }, - WorkerJob::RotoPropagate { comp, layer: LayerId(3), effect: 9, direction: crate::roto::Direction::Forward }, - WorkerJob::Track { - comp, - layer: LayerId(1), - tracker: 4, - direction: crate::tracking::Direction::Backward, - settings: TrackerSettings::default(), - points: vec![PointValues { - uid: 5, - spec: effectcraft_track::PointSpec { center: [1.0, 2.0], feature_size: [3.0, 4.0], search_offset: [0.5, 0.0], search_size: [9.0, 9.0] }, - attach_offset: [0.25, -1.0], - }], - times: vec![Tick::ZERO, Tick::from_seconds_f64(0.5)], - }, - WorkerJob::MaskTrack { - comp, - layer: LayerId(2), - mask: 7, - direction: crate::tracking::Direction::Forward, - path: ShapePath::ellipse([10.0, 20.0], 5.0, 6.0), - method: MaskMethod::Perspective, - times: vec![Tick::from_seconds_f64(1.0)], - }, - WorkerJob::ContentFill { - plan: Box::new(crate::commands::content_fill::FillPlan { - comp, - layer: LayerId(2), - name: "Clip".into(), - width: 4, - height: 3, - times: vec![Tick::ZERO], - method: "object".into(), - lighting: 0.5, - expand: 1, - reference: Some((LayerId(1), Tick::ZERO)), - dir: "/Fill/Clip_Fill_1".into(), - }), - }, - ]; - for job in reqs { - let kind = job.kind(); - let req = WorkerRequest { id: 42, project: s.project.to_json(), files: footage_files(&s.project), job }; - let t = round(&req); - assert!(t.contains("\"kind\":"), "{kind:?}"); - let back: WorkerRequest = serde_json::from_str(&t).unwrap(); - assert_eq!(back.job.kind(), kind); - assert_eq!(Project::from_json(&back.project).unwrap(), *s.project); - } - let g = s.active_comp().unwrap().layers[0].props.clone(); - for r in [ - WorkerReply::Render { state: JobState { current: Some(3), done: 4, total: 10, items_total: 1, ..Default::default() } }, - WorkerReply::Item { update: ItemUpdate::Started { id: 3, unix: 1_700_000_000 } }, - WorkerReply::Item { update: ItemUpdate::Finished { id: 3, status: RenderStatus::Failed("x".into()), seconds: 1.5, output: Some("/a.gif".into()) } }, - WorkerReply::Progress { done: 1, total: 2 }, - WorkerReply::WarpPlan { - key: [1, u64::MAX], - summary: Box::new(effectcraft_effects::warp_stab::PlanSummary { - warps: vec![[[1.0, 0.0, 2.5], [0.0, 1.0, -1.0], [0.0, 0.0, 1.0]]], - auto_scale: 1.08, - crop: Some([1.0, 2.0, 300.0, 200.0]), - valid_fraction: 0.9, - }), - }, - WorkerReply::Group { comp, layer: LayerId(1), group: Box::new(g), message: "ok".into() }, - WorkerReply::Segs { segs: vec![SegData::new(5, &effectcraft_track::roto::FrameSeg::empty(4, 3))] }, - WorkerReply::Fill { - plan: Box::new(crate::commands::content_fill::FillPlan { - comp, - layer: LayerId(2), - name: "Clip".into(), - width: 4, - height: 3, - times: vec![Tick::ZERO], - method: "object".into(), - lighting: 0.5, - expand: 1, - reference: Some((LayerId(1), Tick::ZERO)), - dir: "/Fill/Clip_Fill_1".into(), - }), - files: vec!["/Fill/Clip_Fill_1/fill_00000.png".into()], - }, - WorkerReply::Failed { error: "boom".into() }, - WorkerReply::Done, - ] { - let t = round(&r); - assert!(t.starts_with("{\"type\":"), "{t}"); - } - } - - /// An [`Offload`] that runs the job right away in a second session, through JSON (what a - /// Web Worker does, minus the threads). - struct Inline; - impl Offload for Inline { - fn start(&self, req: WorkerRequest, inbox: Arc) -> Result<(), String> { - let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap(); - let mut w = Session { exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })), ..Default::default() }; - let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap())); - run_request(&mut w, req, &post); - Ok(()) - } - fn cancel(&self, _: u64) {} - } - - #[test] - fn render_runs_through_the_offload() { - let mut s = Session { - exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })), - offload: Some(Arc::new(Inline)), - ..Default::default() - }; - s.execute("file.openDemoProject", serde_json::json!({})).unwrap(); - s.execute("renderQueue.add", serde_json::json!({"output": "/x.gif"})).unwrap(); - let ids = s.start_render(false).unwrap(); - // (the inline offload has replied already; replies are applied when polled) - s.poll_render(); - assert!(s.render_job.is_none()); - let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap(); - assert_eq!(it.status, RenderStatus::Done, "{:?}", it.status); - assert!(it.last_output.as_deref().is_some_and(|o| o.ends_with("x.gif"))); - // `wait` renders inline. - s.execute("renderQueue.add", serde_json::json!({"output": "/y.gif"})).unwrap(); - s.start_render(true).unwrap(); - assert!(s.render_job.is_none() || s.render_job.as_ref().unwrap().remote.is_none()); - } - - /// Runs jobs in a session sharing `footage` (the synthetic clip of the tracking tests). - struct InlineWith(Arc); - impl Offload for InlineWith { - fn start(&self, req: WorkerRequest, inbox: Arc) -> Result<(), String> { - let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap(); - let mut w = Session { footage: self.0.clone(), ..Default::default() }; - let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap())); - run_request(&mut w, req, &post); - Ok(()) - } - fn cancel(&self, _: u64) {} - } - - /// Never replies (a job still running); records cancellations. - #[derive(Default)] - struct Hanging(Mutex>); - impl Offload for Hanging { - fn start(&self, _: WorkerRequest, _: Arc) -> Result<(), String> { - Ok(()) - } - fn cancel(&self, id: u64) { - self.0.lock().unwrap().push(id); - } - } - - fn pose(f: u32) -> ([f64; 2], f64, f64) { - ([150.0 + 2.0 * f as f64, 110.0 + 1.0 * f as f64], 0.0, 1.0) - } - - #[test] - fn track_analysis_runs_through_the_offload_as_one_undo_step() { - let (mut s, clip, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose)); - s.offload = Some(Arc::new(InlineWith(s.footage.clone()))); - s.execute("track.motion", serde_json::json!({})).unwrap(); - s.execute("track.setPoint", serde_json::json!({"point": 1, "center": pose(0).0, "featureSize": [36, 36], "searchSize": [80, 80]})).unwrap(); - let undo = s.history.undo.len(); - s.execute("track.analyze", serde_json::json!({"direction": "forward"})).unwrap(); - assert!(s.is_tracking() && s.track_job.is_none(), "offloaded, not on a thread"); - assert!(s.track_progress().is_some()); - assert!(s.poll_offload()); - assert!(!s.is_tracking() && s.offloaded.is_empty()); - let l = s.active_comp().unwrap().layer(clip).unwrap(); - let tp = l.trackers().next().unwrap().0.track_points().next().unwrap().clone(); - let fc = tp.get("featureCenter").unwrap(); - assert_eq!(fc.keys.len(), 25); - let v = fc.value_at(crate::tests_track::frame_time(12)).as_vec2(); - assert!((v[0] - pose(12).0[0]).abs() < 0.3 && (v[1] - pose(12).0[1]).abs() < 0.3, "{v:?}"); - assert_eq!(s.history.undo.len(), undo + 1); - assert_eq!(s.history.undo.last().unwrap().0, "Analyze Track"); - assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, error: false } if message.starts_with("Tracked")))); - assert!(s.undo()); - let l = s.active_comp().unwrap().layer(clip).unwrap(); - assert!(l.trackers().next().unwrap().0.track_points().next().unwrap().get("featureCenter").unwrap().keys.is_empty()); - // `wait` runs inline. - s.redo(); - s.execute("track.analyze", serde_json::json!({"direction": "forward", "wait": true})).ok(); - assert!(s.offloaded.is_empty()); - } - - #[test] - fn cancelling_offloaded_jobs() { - let (mut s, _, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose)); - let off = Arc::new(Hanging::default()); - s.offload = Some(off.clone()); - s.exporter = Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })); - // Analysis: stopping drops the job, nothing is written. - s.execute("track.motion", serde_json::json!({})).unwrap(); - let rev = s.revision; - s.execute("track.analyze", serde_json::json!({})).unwrap(); - assert!(s.is_tracking()); - assert!(s.execute("track.analyze", serde_json::json!({})).is_err(), "one at a time"); - assert!(s.stop_track()); - s.poll_offload(); - assert!(!s.is_tracking()); - assert_eq!(off.0.lock().unwrap().len(), 1); - assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("stopped")))); - assert!(s.revision > rev); - // Render: the queued items become User Stopped. - s.execute("renderQueue.add", serde_json::json!({"output": "/a.gif"})).unwrap(); - let ids = s.start_render(false).unwrap(); - assert!(s.is_rendering()); - assert!(s.stop_render()); - s.poll_render(); - assert!(!s.is_rendering()); - assert_eq!(off.0.lock().unwrap().len(), 2); - let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap(); - assert_eq!(it.status, RenderStatus::UserStopped); - } - - #[test] - fn progress_hook_is_scoped() { - report(1, 2); // no hook: nothing happens - let seen = Rc::new(RefCell::new(vec![])); - let s2 = seen.clone(); - effectcraft_render::passes::block_on(with_hook(Box::new(move |d, t| s2.borrow_mut().push((d, t))), async { - report(1, 3); - report(2, 3); - })); - report(3, 3); - assert_eq!(*seen.borrow(), vec![(1, 3), (2, 3)]); - } -} +mod tests; diff --git a/crates/engine/src/offload/tests.rs b/crates/engine/src/offload/tests.rs new file mode 100644 index 000000000..55c349e1d --- /dev/null +++ b/crates/engine/src/offload/tests.rs @@ -0,0 +1,312 @@ +use super::*; + +fn round Deserialize<'de>>(v: &T) -> String { + let a = serde_json::to_string(v).unwrap(); + let back: T = serde_json::from_str(&a).unwrap(); + let b = serde_json::to_string(&back).unwrap(); + assert_eq!(a, b); + a +} + +/// A group with properties `a` (uid 2) and `b` (uid 3) and a sub-group (uid 4) holding `c` +/// (uid 5). +fn group() -> PropGroup { + use effectcraft_keyframe::Value as KV; + use effectcraft_project::{Node, Property}; + let mut sub = PropGroup::new(4, "sub", "Sub"); + sub.children.push(Node::Prop(Property::new(5, "c", "C", KV::Scalar(0.0)))); + let mut g = PropGroup::new(1, "fx", "Effect"); + g.children.push(Node::Prop(Property::new(2, "a", "A", KV::Scalar(0.0)))); + g.children.push(Node::Prop(Property::new(3, "b", "B", KV::Scalar(0.0)))); + g.children.push(Node::Group(sub)); + g +} + +fn set(g: &mut PropGroup, uid: Uid, v: f64) { + g.find_mut(uid).unwrap().value = effectcraft_keyframe::Value::Scalar(v); +} + +fn get(g: &PropGroup, uid: Uid) -> f64 { + match g.find(uid).unwrap().value { + effectcraft_keyframe::Value::Scalar(v) => v, + ref v => panic!("{v:?}"), + } +} + +#[test] +fn analysis_results_merge_with_edits_made_meanwhile() { + use effectcraft_project::Node; + let base = group(); + // The user changes `a` and switches the effect off; the analysis writes `b` and `c`. + let mut live = base.clone(); + set(&mut live, 2, 1.0); + live.enabled = false; + let mut result = base.clone(); + set(&mut result, 3, 2.0); + set(&mut result, 5, 3.0); + let m = merge_group(&base, &live, &result).unwrap(); + assert_eq!((get(&m, 2), get(&m, 3), get(&m, 5), m.enabled), (1.0, 2.0, 3.0, false)); + // Unchanged on either side: the other side wins whole. + assert_eq!(merge_group(&base, &base, &result).unwrap(), result); + assert_eq!(merge_group(&base, &live, &base).unwrap(), live); + // Both changed `b`, differently: a conflict (nothing is applied). The same way: fine. + let mut both = live.clone(); + set(&mut both, 3, 9.0); + assert!(merge_group(&base, &both, &result).is_none()); + set(&mut both, 3, 2.0); + assert_eq!(get(&merge_group(&base, &both, &result).unwrap(), 3), 2.0); + // The analysis adds a child (a result group) while the user edits `a`: both kept. + let mut added = result.clone(); + added.children.push(Node::Group(PropGroup::new(6, "out", "Output"))); + let m = merge_group(&base, &live, &added).unwrap(); + assert!(m.find_group(6).is_some() && get(&m, 2) == 1.0); + // The analysis drops the sub-group the user didn't touch: dropped. + let mut dropped = result.clone(); + dropped.children.retain(|n| !matches!(n, Node::Group(g) if g.uid == 4)); + let m = merge_group(&base, &live, &dropped).unwrap(); + assert!(m.find_group(4).is_none() && get(&m, 2) == 1.0); + // …but if the user changed something in it, that's a conflict. + let mut touched = live.clone(); + set(&mut touched, 5, 7.0); + assert!(merge_group(&base, &touched, &dropped).is_none()); +} + +#[test] +fn protocol_round_trips() { + let mut s = Session::default(); + s.execute("file.openDemoProject", serde_json::json!({})).unwrap(); + let comp = s.active_comp_id().unwrap(); + 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::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 }, + WorkerJob::Camera { comp, layer: LayerId(3), effect: 9 }, + WorkerJob::RotoPropagate { comp, layer: LayerId(3), effect: 9, direction: crate::roto::Direction::Forward }, + WorkerJob::Track { + comp, + layer: LayerId(1), + tracker: 4, + direction: crate::tracking::Direction::Backward, + settings: TrackerSettings::default(), + points: vec![PointValues { + uid: 5, + spec: effectcraft_track::PointSpec { center: [1.0, 2.0], feature_size: [3.0, 4.0], search_offset: [0.5, 0.0], search_size: [9.0, 9.0] }, + attach_offset: [0.25, -1.0], + }], + times: vec![Tick::ZERO, Tick::from_seconds_f64(0.5)], + }, + WorkerJob::MaskTrack { + comp, + layer: LayerId(2), + mask: 7, + direction: crate::tracking::Direction::Forward, + path: ShapePath::ellipse([10.0, 20.0], 5.0, 6.0), + method: MaskMethod::Perspective, + times: vec![Tick::from_seconds_f64(1.0)], + }, + WorkerJob::ContentFill { + plan: Box::new(crate::commands::content_fill::FillPlan { + comp, + layer: LayerId(2), + name: "Clip".into(), + width: 4, + height: 3, + times: vec![Tick::ZERO], + method: "object".into(), + lighting: 0.5, + expand: 1, + reference: Some((LayerId(1), Tick::ZERO)), + dir: "/Fill/Clip_Fill_1".into(), + }), + }, + ]; + for job in reqs { + let kind = job.kind(); + let req = WorkerRequest { id: 42, project: s.project.to_json(), files: footage_files(&s.project), job }; + let t = round(&req); + assert!(t.contains("\"kind\":"), "{kind:?}"); + let back: WorkerRequest = serde_json::from_str(&t).unwrap(); + assert_eq!(back.job.kind(), kind); + assert_eq!(Project::from_json(&back.project).unwrap(), *s.project); + } + let g = s.active_comp().unwrap().layers[0].props.clone(); + for r in [ + WorkerReply::Render { state: JobState { current: Some(3), done: 4, total: 10, items_total: 1, ..Default::default() } }, + WorkerReply::Item { update: ItemUpdate::Started { id: 3, unix: 1_700_000_000 } }, + WorkerReply::Item { update: ItemUpdate::Finished { id: 3, status: RenderStatus::Failed("x".into()), seconds: 1.5, output: Some("/a.gif".into()) } }, + WorkerReply::Progress { done: 1, total: 2 }, + WorkerReply::WarpPlan { + key: [1, u64::MAX], + summary: Box::new(effectcraft_effects::warp_stab::PlanSummary { + warps: vec![[[1.0, 0.0, 2.5], [0.0, 1.0, -1.0], [0.0, 0.0, 1.0]]], + auto_scale: 1.08, + crop: Some([1.0, 2.0, 300.0, 200.0]), + valid_fraction: 0.9, + }), + }, + WorkerReply::Group { comp, layer: LayerId(1), group: Box::new(g), message: "ok".into() }, + WorkerReply::Segs { segs: vec![SegData::new(5, &effectcraft_track::roto::FrameSeg::empty(4, 3))] }, + WorkerReply::Fill { + plan: Box::new(crate::commands::content_fill::FillPlan { + comp, + layer: LayerId(2), + name: "Clip".into(), + width: 4, + height: 3, + times: vec![Tick::ZERO], + method: "object".into(), + lighting: 0.5, + expand: 1, + reference: Some((LayerId(1), Tick::ZERO)), + dir: "/Fill/Clip_Fill_1".into(), + }), + files: vec!["/Fill/Clip_Fill_1/fill_00000.png".into()], + }, + WorkerReply::Failed { error: "boom".into() }, + WorkerReply::Done, + ] { + let t = round(&r); + assert!(t.starts_with("{\"type\":"), "{t}"); + } +} + +/// An [`Offload`] that runs the job right away in a second session, through JSON (what a +/// Web Worker does, minus the threads). +struct Inline; +impl Offload for Inline { + fn start(&self, req: WorkerRequest, inbox: Arc) -> Result<(), String> { + let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap(); + let mut w = Session { exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })), ..Default::default() }; + let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap())); + run_request(&mut w, req, &post); + Ok(()) + } + fn cancel(&self, _: u64) {} +} + +#[test] +fn render_runs_through_the_offload() { + let mut s = Session { + exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })), + offload: Some(Arc::new(Inline)), + ..Default::default() + }; + s.execute("file.openDemoProject", serde_json::json!({})).unwrap(); + s.execute("renderQueue.add", serde_json::json!({"output": "/x.gif"})).unwrap(); + let ids = s.start_render(false).unwrap(); + // (the inline offload has replied already; replies are applied when polled) + s.poll_render(); + assert!(s.render_job.is_none()); + let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap(); + assert_eq!(it.status, RenderStatus::Done, "{:?}", it.status); + assert!(it.last_output.as_deref().is_some_and(|o| o.ends_with("x.gif"))); + // `wait` renders inline. + s.execute("renderQueue.add", serde_json::json!({"output": "/y.gif"})).unwrap(); + s.start_render(true).unwrap(); + assert!(s.render_job.is_none() || s.render_job.as_ref().unwrap().remote.is_none()); +} + +/// Runs jobs in a session sharing `footage` (the synthetic clip of the tracking tests). +struct InlineWith(Arc); +impl Offload for InlineWith { + fn start(&self, req: WorkerRequest, inbox: Arc) -> Result<(), String> { + let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap(); + let mut w = Session { footage: self.0.clone(), ..Default::default() }; + let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap())); + run_request(&mut w, req, &post); + Ok(()) + } + fn cancel(&self, _: u64) {} +} + +/// Never replies (a job still running); records cancellations. +#[derive(Default)] +struct Hanging(Mutex>); +impl Offload for Hanging { + fn start(&self, _: WorkerRequest, _: Arc) -> Result<(), String> { + Ok(()) + } + fn cancel(&self, id: u64) { + self.0.lock().unwrap().push(id); + } +} + +fn pose(f: u32) -> ([f64; 2], f64, f64) { + ([150.0 + 2.0 * f as f64, 110.0 + 1.0 * f as f64], 0.0, 1.0) +} + +#[test] +fn track_analysis_runs_through_the_offload_as_one_undo_step() { + let (mut s, clip, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose)); + s.offload = Some(Arc::new(InlineWith(s.footage.clone()))); + s.execute("track.motion", serde_json::json!({})).unwrap(); + s.execute("track.setPoint", serde_json::json!({"point": 1, "center": pose(0).0, "featureSize": [36, 36], "searchSize": [80, 80]})).unwrap(); + let undo = s.history.undo.len(); + s.execute("track.analyze", serde_json::json!({"direction": "forward"})).unwrap(); + assert!(s.is_tracking() && s.track_job.is_none(), "offloaded, not on a thread"); + assert!(s.track_progress().is_some()); + assert!(s.poll_offload()); + assert!(!s.is_tracking() && s.offloaded.is_empty()); + let l = s.active_comp().unwrap().layer(clip).unwrap(); + let tp = l.trackers().next().unwrap().0.track_points().next().unwrap().clone(); + let fc = tp.get("featureCenter").unwrap(); + assert_eq!(fc.keys.len(), 25); + let v = fc.value_at(crate::tests_track::frame_time(12)).as_vec2(); + assert!((v[0] - pose(12).0[0]).abs() < 0.3 && (v[1] - pose(12).0[1]).abs() < 0.3, "{v:?}"); + assert_eq!(s.history.undo.len(), undo + 1); + assert_eq!(s.history.undo.last().unwrap().0, "Analyze Track"); + assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, error: false } if message.starts_with("Tracked")))); + assert!(s.undo()); + let l = s.active_comp().unwrap().layer(clip).unwrap(); + assert!(l.trackers().next().unwrap().0.track_points().next().unwrap().get("featureCenter").unwrap().keys.is_empty()); + // `wait` runs inline. + s.redo(); + s.execute("track.analyze", serde_json::json!({"direction": "forward", "wait": true})).ok(); + assert!(s.offloaded.is_empty()); +} + +#[test] +fn cancelling_offloaded_jobs() { + let (mut s, _, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose)); + let off = Arc::new(Hanging::default()); + s.offload = Some(off.clone()); + s.exporter = Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })); + // Analysis: stopping drops the job, nothing is written. + s.execute("track.motion", serde_json::json!({})).unwrap(); + let rev = s.revision; + s.execute("track.analyze", serde_json::json!({})).unwrap(); + assert!(s.is_tracking()); + assert!(s.execute("track.analyze", serde_json::json!({})).is_err(), "one at a time"); + assert!(s.stop_track()); + s.poll_offload(); + assert!(!s.is_tracking()); + assert_eq!(off.0.lock().unwrap().len(), 1); + assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("stopped")))); + assert!(s.revision > rev); + // Render: the queued items become User Stopped. + s.execute("renderQueue.add", serde_json::json!({"output": "/a.gif"})).unwrap(); + let ids = s.start_render(false).unwrap(); + assert!(s.is_rendering()); + assert!(s.stop_render()); + s.poll_render(); + assert!(!s.is_rendering()); + assert_eq!(off.0.lock().unwrap().len(), 2); + let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap(); + assert_eq!(it.status, RenderStatus::UserStopped); +} + +#[test] +fn progress_hook_is_scoped() { + report(1, 2); // no hook: nothing happens + let seen = Rc::new(RefCell::new(vec![])); + let s2 = seen.clone(); + effectcraft_render::passes::block_on(with_hook(Box::new(move |d, t| s2.borrow_mut().push((d, t))), async { + report(1, 3); + report(2, 3); + })); + report(3, 3); + assert_eq!(*seen.borrow(), vec![(1, 3), (2, 3)]); +}