From 5d9bab268d5b5c726c7f285148111e206f1604a8 Mon Sep 17 00:00:00 2001 From: storybored379 Date: Mon, 12 Oct 2026 14:40:10 -0700 Subject: [PATCH] M6.7: reject Roto Brush span edits past trimmed strokes Validate saved stroke frames against the current layer limits before clamping a span edit. Keep project and undo state intact when trimming has left strokes outside the layer. Co-Authored-By: OpenAI Codex --- ROADMAP.md | 5 ++++- crates/engine/src/commands/roto_cmds.rs | 12 +++++++--- crates/engine/src/tests_roto.rs | 29 +++++++++++++++++++++++++ docs/agents.md | 5 ++++- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 72240b5b..79c39dd1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against After Effects 2026 26.5; standard progress-docs layout) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** patch (Roto Brush span validation after trimming) · **Target:** Adobe After Effects 2026 (26.5.0.89) EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written from scratch in Rust. This page is the one-page summary; the evidence is in @@ -117,6 +117,8 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log +- 2026-10-12: Roto Brush span edits report strokes outside a trimmed layer without panicking; + strokes and undo history are preserved so restoring the timing allows the span edit again. - 2026-10-11: Ukrainian panels caught up with the Chinese catalogs. The 95 rows the panel catalog gained since 9 October are translated: the Home screen (recent files, their Kind and Opened columns, the empty and no-match states), the viewer's toolbar tooltips, magnification, @@ -148,6 +150,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges | | 2026-10-10 | minor | Hours to ≈ 95% for each audience | | 2026-10-10 | minor | Headline adds mainstream practitioner (≈ 47%) and essentials user (≈ 52%) | | 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) | diff --git a/crates/engine/src/commands/roto_cmds.rs b/crates/engine/src/commands/roto_cmds.rs index 95326b42..1df62bcb 100644 --- a/crates/engine/src/commands/roto_cmds.rs +++ b/crates/engine/src/commands/roto_cmds.rs @@ -268,16 +268,22 @@ fn span(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "the Roto Brush is frozen: Unfreeze it to change the span")); } let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - let lim = rt::frame_limits(comp, l); + let [lo, hi] = rt::frame_limits(comp, l); let mut d = data_of(g); let base = d.base.ok_or_else(|| bad(cmd, "no strokes yet"))?; let first = d.strokes.iter().map(|s| s.frame).min().unwrap_or(base); let last = d.strokes.iter().map(|s| s.frame).max().unwrap_or(base); + // Trimming or shortening the comp can leave saved strokes outside the layer. No span + // inside its new bounds can contain those strokes; keep them for undo or a later untrim. + if first < lo || last > hi { + return Err(bad(cmd, "strokes outside the layer: restore the layer/comp timing or clear those strokes before changing the span")); + } + let [start, end] = &mut d.span; if let Some(a) = f_p(p, "start") { - d.span[0] = (a.round() as i64).clamp(lim[0], first); + *start = (a.round() as i64).clamp(lo, first); } if let Some(b) = f_p(p, "end") { - d.span[1] = (b.round() as i64).clamp(last, lim[1]); + *end = (b.round() as i64).clamp(last, hi); } write_data(s, "Segmentation Span", cid, lid, uid, &d)?; Ok(json!({"layer": lid.0, "effect": uid, "span": d.span, "base": base})) diff --git a/crates/engine/src/tests_roto.rs b/crates/engine/src/tests_roto.rs index c1cbd2c8..bef0936f 100644 --- a/crates/engine/src/tests_roto.rs +++ b/crates/engine/src/tests_roto.rs @@ -27,6 +27,35 @@ pub(crate) fn hold_roto_cache_test_lock() { } const R: f64 = 40.0; +fn span_after_trimming_strokes(trim: Value, span: Value, expected: [i64; 2]) { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 64, "height": 64, "frameRate": 25, "duration": 1})).unwrap(); + let layer = s.execute("layer.newSolid", json!({"width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap(); + for frame in [0, 20] { + s.execute("roto.stroke", json!({"layer": layer, "frame": frame, "points": [[32, 32]], "radius": 4})).unwrap(); + } + s.execute("layer.timing", trim).unwrap(); + let before = s.project.to_json(); + let error = s.execute("roto.span", span.clone()).unwrap_err().to_string(); + assert!(error.contains("strokes outside the layer"), "{error}"); + assert_eq!(s.project.to_json(), before, "refusing the span must preserve strokes and project state"); + assert!(s.undo(), "the failed command must not add an undo step"); + let result = s.execute("roto.span", span).unwrap(); + assert_eq!(result["span"], json!(expected)); + let status = s.execute("roto.status", json!({"layer": layer})).unwrap(); + assert_eq!(status["strokeFrames"], json!([0, 20])); +} + +#[test] +fn roto_span_rejects_strokes_before_trimmed_in_point() { + span_after_trimming_strokes(json!({"in": 0.2}), json!({"start": 0}), [0, 20]); +} + +#[test] +fn roto_span_rejects_strokes_after_trimmed_out_point() { + span_after_trimming_strokes(json!({"out": 0.5}), json!({"end": 24}), [0, 24]); +} + fn noise(x: f64, y: f64, seed: u32) -> f64 { fn h(i: i64, j: i64, s: u32) -> f64 { let mut v = (i.wrapping_mul(73856093) ^ j.wrapping_mul(19349663) ^ (s as i64).wrapping_mul(83492791)) as u64; diff --git a/docs/agents.md b/docs/agents.md index 01738edd..94294cb5 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -448,7 +448,10 @@ direction?: forward|backward|both, to?, wait?}` segments the span (in the backgr `wait`); strokes on any other frame correct it and propagation restarts from there. `roto.span {start?, end?}`, `roto.freeze {wait?}` / `roto.unfreeze`, `roto.clearStrokes {frame?, kind?}`, `roto.cancel` and `roto.options {diameter?, refineDiameter?, view?: alphaBoundary|alpha| -alphaOverlay|none}` complete the set; every edit is one undo step. `roto.status {layer, frame?, +alphaOverlay|none}` complete the set; every edit is one undo step. If trimming the layer or +shortening the comp leaves strokes outside the layer, `roto.span` returns an error without +changing the strokes: restore the timing or remove those strokes with `roto.clearStrokes` first. +`roto.status {layer, frame?, matte?, compute?, compareTo?}` reports the base frame, span, computed and stroked frames, frozen state, job progress and, for a frame, the matte's area, centroid, RLE matte and IoU against a reference. Matte settings are ordinary properties (`effects/#1/rotoBrushMatte/searchRadius`,