Merge pull request #1175 from storybored379/fix/roto-trim-span

# Conflicts:
#	ROADMAP.md
This commit is contained in:
Brandon Thomas committed 2026-10-13 04:29:08 -04:00
commit 3aac824976
4 files changed
+45 -4

No files matched your search

+3
View File
@@ -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,
@@ -149,6 +151,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md |
| 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) |
+9 -3
View File
@@ -268,16 +268,22 @@ fn span(s: &mut Session, p: &Value) -> Result<Value> {
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}))
+29
View File
@@ -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;
+4 -1
View File
@@ -452,7 +452,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`,