Keep a composition work area inside the composition and at least one frame long.

Setting the start past the current end, or shortening the composition, stored an inverted interval. Trimming that interval then saved a negative duration.
This commit is contained in:
Vikram Bhaskaran
2026-10-10 21:38:57 +11:00
parent 813c7c4650
commit da67ea9553
2 changed files with 89 additions and 0 deletions
+25
View File
@@ -76,6 +76,8 @@ fn apply_settings(c: &mut Comp, p: &Value) {
c.work_area.1 = c.duration;
}
c.work_area.1 = c.work_area.1.min(c.duration);
// A shorter composition must not leave the start past the end.
keep_work_area_ordered(c);
}
if let Some(pa) = f_p(p, "pixelAspect") {
c.pixel_aspect = pa.max(0.1);
@@ -196,17 +198,40 @@ fn work_area(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(e) = end {
c.work_area.1 = e.clamp(c.work_area.0 + fd, c.duration);
}
// A start on its own can land after the current end. Pull it back so the interval
// still covers one frame, the same rule as `set: "begin"`.
keep_work_area_ordered(c);
Ok(())
})?;
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
Ok(json!({"start": c.work_area.0.seconds(), "end": c.work_area.1.seconds()}))
}
/// Keep `work_area` inside the composition and at least one frame long.
fn keep_work_area_ordered(c: &mut Comp) {
let fd = c.frame_duration();
c.work_area.1 = c.work_area.1.min(c.duration);
if c.work_area.0 < Tick::ZERO {
c.work_area.0 = Tick::ZERO;
}
if c.work_area.0 + fd > c.work_area.1 {
c.work_area.0 = (c.work_area.1 - fd).max(Tick::ZERO);
}
if c.work_area.0 + fd > c.work_area.1 {
c.work_area.1 = (c.work_area.0 + fd).min(c.duration);
}
}
fn trim_to_wa(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
s.edit("Trim Comp to Work Area", None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let (a, b) = c.work_area;
let fd = c.frame_duration();
// An interval that is already inverted must not become a negative duration.
if a < Tick::ZERO || b > c.duration || a + fd > b {
return Err(bad("comp.trimToWorkArea", "the work area must cover at least one frame inside the composition"));
}
for l in &mut c.layers {
l.start_time -= a;
l.in_point = (l.in_point - a).max(Tick::ZERO);
+64
View File
@@ -3,6 +3,7 @@
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{FrameBlend, ItemKind, MatteKind, Quality, Sampling};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use crate::{EngineError, Event, Session};
@@ -253,6 +254,69 @@ fn lift_and_extract_work_area() {
assert!((spans[1].0 - 2.0).abs() < 1e-6 && (spans[1].1 - 8.0).abs() < 1e-6, "{spans:?}");
}
#[test]
fn work_area_stays_ordered_when_the_start_passes_the_end() {
let ordered = |s: &Session| {
let c = s.active_comp().unwrap();
let fd = c.frame_duration();
assert!(c.duration >= fd, "duration {}", c.duration.seconds());
assert!(c.work_area.0 >= Tick::ZERO);
assert!(c.work_area.0 + fd <= c.work_area.1, "start {} end {}", c.work_area.0.seconds(), c.work_area.1.seconds());
assert!(c.work_area.1 <= c.duration);
(c.duration, c.work_area)
};
let one_frame_at_two = |s: &Session| {
let end = Tick::from_seconds_f64(2.0);
(end - s.active_comp().unwrap().frame_duration(), end)
};
let mut s = comp();
s.execute("comp.workArea", json!({"end": 2.0})).unwrap();
s.execute("comp.workArea", json!({"start": 8.0})).unwrap();
assert_eq!(ordered(&s).1, one_frame_at_two(&s));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(ordered(&s).1, (Tick::ZERO, Tick::from_seconds_f64(2.0)));
s.execute("comp.workArea", json!({"start": 0.0, "end": 2.0})).unwrap();
s.execute("comp.trimToWorkArea", json!({})).unwrap();
assert_eq!(ordered(&s), (Tick::from_seconds_f64(2.0), (Tick::ZERO, Tick::from_seconds_f64(2.0))));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(ordered(&s).0, Tick::from_seconds_f64(10.0));
let mut s = comp();
s.execute("comp.workArea", json!({"start": 9.0, "end": 10.0})).unwrap();
let before = ordered(&s);
s.execute("comp.settings", json!({"duration": 2.0})).unwrap();
let (dur, wa) = ordered(&s);
assert_eq!(dur, Tick::from_seconds_f64(2.0));
assert_eq!(wa, one_frame_at_two(&s));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(ordered(&s), before);
let mut s = comp();
s.execute("comp.workArea", json!({"end": 2.0})).unwrap();
s.execute("time.set", json!({"time": 8.0})).unwrap();
s.execute("comp.workArea", json!({"set": "begin"})).unwrap();
assert_eq!(ordered(&s).1, one_frame_at_two(&s));
let path = tmp("WorkArea.ecproj");
s.execute("file.saveAs", json!({"path": path})).unwrap();
s.execute("file.open", json!({"path": path})).unwrap();
assert_eq!(ordered(&s).1, one_frame_at_two(&s));
// A project that already has an inverted interval must not trim into a negative duration.
let mut s = comp();
let cid = s.state.active_comp.unwrap();
{
let c = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap();
c.work_area = (Tick::from_seconds_f64(8.0), Tick::from_seconds_f64(2.0));
}
let err = s.execute("comp.trimToWorkArea", json!({})).unwrap_err();
assert!(err.to_string().contains("work area"), "{err}");
let c = s.active_comp().unwrap();
assert_eq!(c.duration, Tick::from_seconds_f64(10.0));
assert_eq!(c.work_area, (Tick::from_seconds_f64(8.0), Tick::from_seconds_f64(2.0)));
}
#[test]
fn label_group_purge_and_edit_original() {
crate::tests_roto::hold_roto_cache_test_lock();