From a7255f76085e31290d48c129e628c9d8db0a0abe Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 01:36:25 +0200 Subject: [PATCH 01/30] Text: box text's first baseline is the height of "d" below the box top, as After Effects After Effects puts the first baseline of paragraph (box) text the height of the font's lowercase "d" (in whole thousandths of an em) below the box top, at the text's nominal size; superscript keeps the line there and only raises its glyphs. EffectCraft used the font ascent of the drawn size plus the superscript rise. Measured in AE 2026 (case 818, box at y -200): Times New Roman 100 px -130.50 (EffectCraft -110.9), 50 px -165.25, superscript -163.80 (EffectCraft drew it 15.8 px lower), Arial 100 px -128.40, superscript -161.70; now within 0.15 px. 517 (four wrapped lines, Times New Roman superscript): the line bands now land on AE's rows exactly (336-388, 434-486, 532-584, 643-682; were 16 px lower). A face without a "d" outline, and vertical type (not measured), keep the font ascent. The paragraph-box test now expects the caret (full ascent) to start a little above the box. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 68 +++++++++++++++++++++++++++++++++++++-- crates/text/src/lib.rs | 4 ++- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 2e863d1ae..0ec5c7347 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -565,6 +565,22 @@ fn space_item(glyphs: &mut [ShapedGlyph], style: &TextStyle, text: &str, para_st } } +/// After Effects' first-baseline offset of paragraph (box) text for a face at nominal size +/// `px`: the height of its lowercase `d`, in whole thousandths of an em. Measured in After +/// Effects 2026 (case 818): the first baseline sits 69.50 px below the box top for Times New +/// Roman at 100 px (its `d` is 1422/2048 em, 694.3 thousandths, rounded 695) and 71.60 px for +/// Arial (1466/2048, 715.8, rounded 716), at 50 px half of that; superscript text keeps the +/// line where normal text puts it and only raises its glyphs. `None` without a `d` outline. +fn first_ascent(face: FaceId, px: f32) -> Option { + let f = fonts::face(face); + let gid = f.glyph('d')?; + let units = crate::outline_units(face, gid)?; + let top = kurbo::Shape::bounding_box(&*units).y1 as f32; + let em = (top / f.units_per_em() * 1000.0).round() / 1000.0; + let h = em * px; + (h.is_finite() && h > 0.0 && em < 4.0).then_some(h) +} + struct ParaLine { range: Range, glyphs: Vec, @@ -575,6 +591,9 @@ struct ParaLine { hang: (f32, f32), ascent: f32, descent: f32, + /// Where a box's first line puts its baseline below the box top ([`first_ascent`] of the + /// line's styles at their nominal size; the font ascent where a face has no `d`). + first: f32, /// Line advance (largest leading on the line). advance: f32, align: Align, @@ -611,6 +630,10 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar let st = &runs[si].1; fonts::face(primaries[si].face).metrics(st.glyph_size()) }; + let style_first = |si: usize| { + let st = &runs[si].1; + first_ascent(primaries[si].face, st.size).unwrap_or_else(|| fonts::face(primaries[si].face).metrics(st.size).ascent) + }; let eff_align = |last: bool| -> Align { if (last || width.is_none()) && para.align == Align::Justify { if width.is_none() && para.justify_last == Align::Justify { Align::Left } else { para.justify_last } @@ -630,6 +653,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar hang: (0.0, 0.0), ascent: pm.ascent, descent: pm.descent, + first: style_first(si), advance: runs[si].1.line_advance(), align: eff_align(true), avail: avail_for(true), @@ -899,7 +923,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar carets.sort_by_key(|c| c.0); carets.dedup_by_key(|c| c.0); // Line metrics: the tallest face and the largest leading on the line. - let (mut asc, mut desc, mut adv) = (0.0f32, 0.0f32, 0.0f32); + let (mut asc, mut desc, mut adv, mut first) = (0.0f32, 0.0f32, 0.0f32, 0.0f32); let mut seen: Vec = chars.iter().filter(|c| c.0 >= r.start && c.0 < r.end).map(|c| c.6).collect(); seen.sort_unstable(); seen.dedup(); @@ -912,6 +936,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar asc = asc.max(m.ascent + rise.max(0.0)); desc = desc.max(m.descent - rise.min(0.0)); adv = adv.max(runs[si].1.line_advance()); + first = first.max(style_first(si)); } for g in &glyphs { if g.face != primaries[g.run].face { @@ -930,6 +955,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar hang, ascent: asc, descent: desc, + first, advance: adv, align, avail, @@ -985,7 +1011,9 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], para for (i, (pi, pl)) in plines.into_iter().enumerate() { let para = para_at(pi); if i == 0 { - baseline = if width.is_some() { pl.ascent } else { 0.0 }; + // Vertical type keeps its first column at the font ascent (not measured in AE). + let vertical = runs.iter().any(|r| r.1.vertical); + baseline = if width.is_some() { if vertical { pl.ascent } else { pl.first } } else { 0.0 }; } else { let prev_para = lay.lines.last().map_or(pi, |l| l.para); let mut adv = pl.advance + para.leading; @@ -1213,6 +1241,42 @@ mod tests { assert!(left.lines.iter().all(|l| l.width <= 300.0 + 0.01)); } + /// After Effects 2026 (case 818): box text's first baseline is the `d` height (in whole + /// thousandths of an em) below the box top, at the nominal size also for superscript, whose + /// glyphs are then raised. EffectCraft used the font ascent of the drawn size (plus the + /// superscript rise): 89.1 px instead of 69.5 for Times New Roman at 100 px, and the + /// superscript glyphs 15.8 px low. + #[test] + fn box_text_first_baseline_is_the_d_height_as_after_effects() { + let boxed = ParagraphStyle { width: Some(600.0), ..Default::default() }; + // The rule on a bundled face (works everywhere). + for (family, size) in [("Inter", 100.0f32), ("Noto Serif", 50.0)] { + let s = TextStyle { family: family.into(), ..st(size) }; + let r = fonts::resolve(family, "Regular"); + let f = fonts::face(r.face); + let d = crate::outline_units(r.face, f.glyph('d').unwrap()).unwrap(); + let em = (kurbo::Shape::bounding_box(&*d).y1 as f32 / f.units_per_em() * 1000.0).round() / 1000.0; + let l = layout("Hxg", &s, &boxed); + assert!((l.lines[0].baseline - em * size).abs() < 1e-3, "{family}: {} vs {}", l.lines[0].baseline, em * size); + assert!(l.lines[0].baseline < l.lines[0].ascent, "{family}: below the full ascent"); + } + // After Effects' numbers, where the real fonts are installed. + for (family, ae_normal) in [("Times New Roman", 69.5008f32), ("Arial", 71.5988)] { + let r = fonts::resolve(family, "Regular"); + if fonts::face(r.face).info.family != family { + continue; + } + for (size, script, glyph_baseline) in + [(100.0, Script::Normal, ae_normal), (50.0, Script::Normal, ae_normal / 2.0), (100.0, Script::Super, ae_normal - 100.0 * SCRIPT_OFFSET)] + { + let s = TextStyle { family: family.into(), script, ..st(size) }; + let l = layout("Hxg", &s, &boxed); + let drawn = l.glyphs[0].y; + assert!((drawn - glyph_baseline).abs() < 0.15, "{family} {size} {script:?}: {drawn} vs AE {glyph_baseline}"); + } + } + } + #[test] fn newlines_make_paragraphs_and_leading_adds() { let l = layout("one\ntwo\n\nfour", &st(30.0), &ParagraphStyle::default()); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 187167527..71db4a9ea 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -659,7 +659,9 @@ mod tests { let doc = TextDoc { text: "ab cd".into(), size: 20.0, box_size: Some([200.0, 100.0]), box_pos: [-100.0, -50.0], ..Default::default() }; let l = layout_doc(&doc); let (a, _) = l.caret(0); - assert!((a.x + 100.0).abs() < 1e-6 && (a.y + 50.0).abs() < 1.0, "{a:?}"); + // The first baseline sits the `d` height below the box top (After Effects), so the + // caret, which spans the font's full ascent, starts a little above the box. + assert!((a.x + 100.0).abs() < 1e-6 && a.y + 50.0 < 0.0 && a.y + 50.0 > -0.35 * 20.0, "{a:?}"); // Vertical: characters run down a column, carets are horizontal. let v = layout_doc(&TextDoc { text: "abc\nde".into(), size: 20.0, vertical: true, ..Default::default() }); let ys: Vec = v.glyphs.iter().filter(|g| g.line_index == 0).map(|g| g.origin.y).collect(); From 8bc57bc7ad23d822ff3096fd20f3ed14b2616742 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 13:38:11 +0200 Subject: [PATCH 02/30] Text: Justify All balances the lines with the every-line composer, as After Effects With Justify All After Effects stretches the paragraph's last line too, and its every-line composer breaks the paragraph so that line is not a lone word: a 400 px box that the other justifications break into 6 / 6 / 1 words breaks 4 / 4 / 4 (AE 2026, EXPORTER read-back t6). EffectCraft's composer left the last line's slack out of its cost, so "twelve" stood alone on a stretched line; it now counts that slack for Justify All and every word starts within 1 px of AE's (what remains in that box is the known box-text baseline offset). Other justifications and the single-line composer are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 24 ++++++++++++++++++++++-- docs/fidelity/README.md | 9 +++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 0ec5c7347..d8bdee4fd 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -761,7 +761,10 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar let opps: Vec = unicode_linebreak::linebreaks(text).map(|(p, _)| p).collect(); let avail = |line: usize| avail_for(line == 0).unwrap_or(f32::MAX); if para.every_line && opps.len() > 1 && opps.len() <= 1500 { - // Minimise the summed squared slack of every line but the last. + // Minimise the summed squared slack of every line but the last; with Justify All + // the last line is stretched too, so its slack counts (After Effects then balances + // the lines: 4 / 4 / 4 words where the other modes break 6 / 6 / 1). + let last_counts = para.align == Align::Justify && para.justify_last == Align::Justify; let nodes: Vec = std::iter::once(0).chain(opps.iter().copied()).collect(); let m = nodes.len(); let mut best = vec![(f32::INFINITY, 0usize); m]; @@ -781,7 +784,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar let slack = maxw - w; let cost = if over { 1e12 + w - } else if j == m - 1 { + } else if j == m - 1 && !last_counts { 0.0 } else { slack * slack @@ -1464,6 +1467,23 @@ mod tests { assert!(every.lines.iter().all(|ln| ln.width <= 320.0 + 0.5)); } + /// After Effects 2026 (EXPORTER t6): with Justify All and the every-line composer, a box that + /// the other justifications break into lines of 6, 6 and 1 words breaks 4 / 4 / 4, so the + /// stretched last line is not a lone word; word positions then match AE to 1 px. + #[test] + fn justify_all_balances_the_lines_with_the_every_line_composer() { + let text = "one two three four five six seven eight nine ten eleven twelve"; + let s = st(30.0); + let words = |l: &Layout| -> Vec { l.lines.iter().map(|ln| text[ln.range.clone()].split_whitespace().count()).collect() }; + let p = |last: Align| ParagraphStyle { width: Some(450.0), align: Align::Justify, justify_last: last, every_line: true, ..Default::default() }; + let left = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Left)]); + let all = layout_rich_uncached(text, &[(0..text.len(), s)], &[p(Align::Justify)]); + let (wl, wa) = (words(&left), words(&all)); + assert!(wl.last().copied().unwrap_or(0) < *wl.first().unwrap_or(&0), "{wl:?}"); + let (lo, hi) = (wa.iter().min().copied().unwrap_or(0), wa.iter().max().copied().unwrap_or(0)); + assert!(hi - lo <= 1, "Justify All balances the lines: {wa:?} (left-justified: {wl:?})"); + } + #[test] fn hanging_punctuation_overhangs_the_box() { let s = st(40.0); diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index 5afafee41..6c59acd43 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -71,6 +71,15 @@ After the fix, all ten tangents equal AE's to the printed 0.001 px, and the two test `spatial_auto_bezier_tangents_follow_after_effects` checks the rule on other original keys, including a timing change. AE's output stays local; only the script and these scores are tracked. +## Justify All with the every-line composer + +Measured on 19 October 2026 with After Effects 2026 (26.2) on box text (EXPORTER read-back t6): +with Justify All, AE stretches the paragraph's last line too and breaks the lines so that it is not +a lone word (4 / 4 / 4 words where the other justifications break 6 / 6 / 1). EffectCraft's +every-line composer ignored the last line's slack and left "twelve" alone on a stretched line; it now +counts that slack for Justify All, and every word starts within 1 px of AE's. Regression test: +`justify_all_balances_the_lines_with_the_every_line_composer`. + ## Still unmeasured Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames` From 83b134cdc8650e0324ab125f9f139fb77e964eac Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 13:43:27 +0200 Subject: [PATCH 03/30] Text: right-to-left point text with a justified paragraph sits flush right, as After Effects After Effects 2026 lays out right-to-left point text whose paragraph carries a justified code (justify last left / centre / right, justify all) flush at its start, the right, like right-aligned text (EXPORTER read-back t7: point rows with codes 3-6 at -152..-8 from the anchor). EffectCraft placed it by the last-line side (left / centre / left). Left-to-right point text and box text are unchanged; AE's slightly wider right-to-left box wrapping (1-2 px narrower word gaps) stays open. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 25 ++++++++++++++++++++++++- docs/fidelity/README.md | 10 ++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index d8bdee4fd..3ce08b84f 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -635,7 +635,11 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar first_ascent(primaries[si].face, st.size).unwrap_or_else(|| fonts::face(primaries[si].face).metrics(st.size).ascent) }; let eff_align = |last: bool| -> Align { - if (last || width.is_none()) && para.align == Align::Justify { + if rtl_para && width.is_none() && para.align == Align::Justify { + // Right-to-left point text with a justified paragraph sits flush at its start, the + // right (After Effects 2026, every justify code; left-to-right not measured). + Align::Right + } else if (last || width.is_none()) && para.align == Align::Justify { if width.is_none() && para.justify_last == Align::Justify { Align::Left } else { para.justify_last } } else { para.align @@ -1499,6 +1503,25 @@ mod tests { assert!(r.lines[0].x + r.lines[0].width > 401.0, "closing quote hangs right"); } + /// After Effects 2026 (EXPORTER read-back t7): right-to-left point text with any justified + /// code (justify last left / centre / right, justify all) sits flush right at its anchor, as + /// right-aligned text does. + #[test] + fn rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start() { + let text = "Right to left"; + let s = st(30.0); + let at = |align: Align, last: Align| { + let p = ParagraphStyle { align, justify_last: last, rtl: Some(true), ..Default::default() }; + let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]); + (l.lines[0].x, l.lines[0].x + l.lines[0].width) + }; + let right = at(Align::Right, Align::Left); + for last in [Align::Left, Align::Center, Align::Right, Align::Justify] { + let got = at(Align::Justify, last); + assert!((got.0 - right.0).abs() < 1e-3 && (got.1 - right.1).abs() < 1e-3, "{last:?}: {got:?} vs right-aligned {right:?}"); + } + } + #[test] fn rtl_paragraph_carets_run_right_to_left() { let text = "\u{5d0}\u{5d1}\u{5d2}"; diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index 6c59acd43..d4c1bf788 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -80,6 +80,16 @@ every-line composer ignored the last line's slack and left "twelve" alone on a s counts that slack for Justify All, and every word starts within 1 px of AE's. Regression test: `justify_all_balances_the_lines_with_the_every_line_composer`. +## Right-to-left point text with a justified paragraph + +Measured on 19 October 2026 with After Effects 2026 (26.2) (EXPORTER read-back t7): right-to-left +point text whose paragraph has a justified code (justify last left / centre / right, justify all) +sits flush right at its anchor, like right-aligned text. EffectCraft placed it by the last-line +side (left / centre / left). Now flush right; left-to-right point text is unchanged (not measured). +Regression test: `rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start`. Still open: +AE wraps right-to-left box text slightly wider (its word gaps are 1–2 px narrower than in +left-to-right text with the same font), so a 300 px box fits a word more per line. + ## Still unmeasured Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames` From 8ea876914dfc0eb6c87bddecf7afa5dfd28dfdd8 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 14:22:04 +0200 Subject: [PATCH 04/30] Text animators: Tracking Amount in pixels, Subtract selectors stop at 0, as After Effects A text animator's Tracking Amount is in pixels in After Effects 2026, not in 1/1000 em like the Character panel's tracking: Amount 30 on every character adds 30 px between neighbours (half before, half after each) at 80 px and at 40 px. And a Subtract range selector over characters the first selector leaves out keeps them at 0 instead of selecting them negatively. With both, the EXPORTER's t8 layer D (Tracking 30 on characters 2-4 by index, Subtract over the last 20 %) puts every glyph where AE does (it ended 88 px short, 1553 vs 1641; now 1641). The "Tracking In" text preset animates 40 -> 0 px (was 400 -> 0 thousandths of an em, about the same at 100 px text); Lottie export writes a style run's tracking (1/1000 em) as pixels for its tracking animator, which Lottie players add in pixels. Co-Authored-By: Claude Opus 5.5 --- crates/engine/presets/text_animators.json | 2 +- crates/lottie/src/text.rs | 4 +-- crates/render/src/text.rs | 8 +++--- crates/render/src/text/tests.rs | 33 +++++++++++++++++++---- crates/text/src/selectors.rs | 7 +++++ docs/fidelity/README.md | 12 +++++++++ 6 files changed, 55 insertions(+), 11 deletions(-) diff --git a/crates/engine/presets/text_animators.json b/crates/engine/presets/text_animators.json index 20e142268..e82707a52 100644 --- a/crates/engine/presets/text_animators.json +++ b/crates/engine/presets/text_animators.json @@ -77,7 +77,7 @@ "name": "Tracking In", "properties": { "tracking": 0, "opacity": 100 }, "keys": { - "tracking": [[0, 400], [1.6, 0]], + "tracking": [[0, 40], [1.6, 0]], "opacity": [[0, 0], [0.8, 100]] }, "ease": true, diff --git a/crates/lottie/src/text.rs b/crates/lottie/src/text.rs index 125835c2b..e9de3d328 100644 --- a/crates/lottie/src/text.rs +++ b/crates/lottie/src/text.rs @@ -227,8 +227,8 @@ fn style_run_animators(ex: &mut Ex, d: &TextDoc, label: &str) -> Vec { props.insert("s".into(), k(json!([p, p, 100.0]))); } if (s.tracking - base.tracking).abs() > 1e-9 { - // Lottie tracking is in 1/1000 em like the Character panel's. - props.insert("t".into(), k(json!(s.tracking - base.tracking))); + // An animator's tracking is in pixels; the Character panel's in 1/1000 em. + props.insert("t".into(), k(json!((s.tracking - base.tracking) / 1000.0 * s.size))); } if (s.baseline_shift - base.baseline_shift).abs() > 1e-9 { props.insert("p".into(), k(json!([0.0, -(s.baseline_shift - base.baseline_shift), 0.0]))); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index fbb0feb24..302f31162 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -639,10 +639,12 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { let (mut la, mut la_k) = (50.0 * f64::from(align_index(justify)), 0.0); for gi in li_start..li_end { let x = &xfs[gi]; - let em = lay.styles.get(lay.glyphs[gi].run).map_or(doc.size, |s| s.size) / 1000.0; - pen += x.track_before * em; + // An animator's Tracking Amount is in pixels, whatever the font size (After Effects + // 2026: Amount 30 adds 30 px between selected characters at 80 px and at 40 px), unlike + // the Character panel's tracking in 1/1000 em. + pen += x.track_before; shift[gi] = pen; - pen += x.track_after * em + vadv[gi]; + pen += x.track_after + vadv[gi]; if x.line_anchor_k > la_k { la = x.line_anchor; la_k = x.line_anchor_k; diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 020e08517..760bd2fff 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -202,9 +202,9 @@ fn tracking_line_anchor_and_character_offset() { animator(&mut p, &mut l, &["tracking", "lineAnchor"], &["range"]); p.comp_mut(cid).unwrap().layers.push(l); let w0 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])); - set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(500.0)); + set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(20.0)); let w1 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])); - // 4 chars × 500/1000 em × 40 px: the last glyph moves ~ 3.5 × 20 px further (before & after). + // Tracking Amount 20 (pixels, as After Effects): the last glyph moves ~ 3.5 × 20 px further (before & after). assert!((w1.width() - w0.width() - 3.0 * 20.0).abs() < 1.5, "{} {}", w0.width(), w1.width()); assert!((w1.x0 - w0.x0 - 10.0).abs() < 1.0, "first char shifts by half its tracking: {} {}", w0.x0, w1.x0); // Line Anchor 100%: tracking grows to the left. @@ -231,6 +231,29 @@ fn tracking_line_anchor_and_character_offset() { assert_eq!(substitute('a', &CharXf { char_value: Some((66.0, 1.0)), ..Default::default() }), Some('B')); } +/// After Effects 2026: a text animator's Tracking Amount is in pixels: Amount 30 on every +/// character of an 80 px and of a 40 px text adds 30 px between neighbouring characters (half +/// before, half after each one). +#[test] +fn animator_tracking_is_in_pixels_as_in_after_effects() { + for size in [80.0, 40.0] { + let x_of = |amount: f64| { + let (mut p, cid, comp) = setup(900, 200); + let mut l = text_layer(&mut p, &comp, doc("HHHH", size)); + animator(&mut p, &mut l, &["tracking"], &["range"]); + set(&mut l, "text/animators/#1/properties/tracking", Value::Scalar(amount)); + p.comp_mut(cid).unwrap().layers.push(l); + let paths = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]); + paths.iter().map(|g| bounds_of(std::slice::from_ref(g)).x0).collect::>() + }; + let (a, b) = (x_of(0.0), x_of(30.0)); + for i in 1..a.len() { + let gap = (b[i] - b[i - 1]) - (a[i] - a[i - 1]); + assert!((gap - 30.0).abs() < 0.5, "{size} px: neighbour gap grows by {gap}"); + } + } +} + /// Without a Line Anchor, tracking grows each line from where its paragraph's alignment pins it: /// left text grows right, centred text around its centre, right text to the left (#146). #[test] @@ -244,10 +267,10 @@ fn tracking_grows_from_each_paragraphs_alignment() { p.comp_mut(cid).unwrap().layers.push(l); glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]) }; - // 4 chars × 500/1000 em × 40 px: 20 px of tracking each, half before and half after. + // Tracking Amount 20 (pixels): 20 px of tracking each, half before and half after. for (justify, x0, centre, x1) in [(Justify::Left, 10.0, 40.0, 70.0), (Justify::Center, -30.0, 0.0, 30.0), (Justify::Right, -70.0, -40.0, -10.0)] { let d = TextDoc { justify, ..doc("ABCD", 40.0) }; - let (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 500.0))); + let (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 20.0))); let got = [w1.x0 - w0.x0, w1.center().x - w0.center().x, w1.x1 - w0.x1]; assert!(got.iter().zip([x0, centre, x1]).all(|(g, w)| (g - w).abs() < 1.0), "{justify:?}: moved {got:?}, want {:?}", [x0, centre, x1]); } @@ -259,7 +282,7 @@ fn tracking_grows_from_each_paragraphs_alignment() { let (a, b): (Vec<_>, Vec<_>) = paths.into_iter().partition(|g| bounds_of(std::slice::from_ref(g)).center().y < 10.0); [bounds_of(&a), bounds_of(&b)] }; - let ([a0, b0], [a1, b1]) = (lines(tracked(d.clone(), 0.0)), lines(tracked(d, 500.0))); + let ([a0, b0], [a1, b1]) = (lines(tracked(d.clone(), 0.0)), lines(tracked(d, 20.0))); assert!((a1.x0 - a0.x0 - 10.0).abs() < 1.0, "the left paragraph grows right: {} {}", a0.x0, a1.x0); assert!((b1.x1 - b0.x1 + 10.0).abs() < 1.0, "the right paragraph grows left: {} {}", b0.x1, b1.x1); } diff --git a/crates/text/src/selectors.rs b/crates/text/src/selectors.rs index e3a422460..0306b7e09 100644 --- a/crates/text/src/selectors.rs +++ b/crates/text/src/selectors.rs @@ -71,6 +71,10 @@ impl Mode { pub fn combine(self, acc: f64, v: f64) -> f64 { let r = match self { Mode::Add => acc + v, + // Subtracting more than is selected leaves nothing, not a negative selection (After + // Effects 2026: a Subtract selector over unselected characters leaves their + // Tracking at 0, EXPORTER read-back t8). + Mode::Subtract if acc >= 0.0 => (acc - v).max(0.0), Mode::Subtract => acc - v, Mode::Intersect => acc * v, Mode::Min => acc.min(v), @@ -352,6 +356,9 @@ mod tests { // A lone Subtract inverts. assert_eq!(Mode::Subtract.combine(Mode::Subtract.initial(), 1.0), 0.0); assert_eq!(Mode::Subtract.combine(Mode::Subtract.initial(), 0.0), 1.0); + // Subtracting from what is not selected leaves 0, not a negative selection (AE t8). + assert_eq!(Mode::Subtract.combine(0.0, 1.0), 0.0); + assert_eq!(Mode::Subtract.combine(0.25, 1.0), 0.0); assert_eq!(Mode::from_index(5), Mode::Difference); } diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index d4c1bf788..22fbd4330 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -90,6 +90,18 @@ Regression test: `rtl_point_text_with_a_justified_paragraph_is_flush_at_its_star AE wraps right-to-left box text slightly wider (its word gaps are 1–2 px narrower than in left-to-right text with the same font), so a 300 px box fits a word more per line. +## Text animator tracking + +Measured on 19 October 2026 with After Effects 2026 (26.2): a text animator's Tracking Amount is in +pixels, not 1/1000 em like the Character panel's tracking (Amount 30 adds 30 px between neighbouring +characters at 80 px and at 40 px, half before and half after each), and a Subtract range selector +over characters the first selector leaves out keeps them at 0 instead of tracking them negatively. +With both, the EXPORTER's t8 layer D (Tracking 30 on characters 2–4 by index, a Subtract selector +over the last 20 %) puts every glyph where AE does (it ended 88 px short). The "Tracking In" text +preset now animates 40 → 0 px (was 400 → 0 thousandths of an em); Lottie export writes a style run's +tracking as pixels. Regression tests: `animator_tracking_is_in_pixels_as_in_after_effects`, +`modes_combine`. + ## Still unmeasured Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames` From 8b64074ce367b980eab881feb4910aa4ec5a699b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 14:56:52 +0200 Subject: [PATCH 05/30] Text: per-character 3D rotation turns Z first, as a 3D layer and After Effects With per-character 3D on, After Effects turns each character by Rx * Ry * Rz (Z first), as it turns a 3D layer's X / Y / Z Rotation (case 931, AE 2026: "H" at 400 px with animator X / Y / Z Rotation 40 / 30 / 20). EffectCraft composed Rz * Ry * Rx, so the glyph faced another way: 23 of 5184 grid points off (max 255), now 3 at the anti-aliased edge (mean 0.015/255). The same order fix-orientation uses for layers; texts with one rotation axis are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/text.rs | 4 ++-- crates/render/src/text/tests.rs | 31 +++++++++++++++++++++++++++++++ docs/fidelity/README.md | 8 ++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 302f31162..c16419af8 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -752,9 +752,9 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { }; // Outline relative to the pivot. let local = kurbo::Affine::translate((o[0] - pivot[0], o[1] - pivot[1])) * outline; - let char_m = Mat4::rotate_z(x.rotation) + let char_m = Mat4::rotate_x(x.rotation_x) * Mat4::rotate_y(x.rotation_y) - * Mat4::rotate_x(x.rotation_x) + * Mat4::rotate_z(x.rotation) * Mat4::from_mat3_affine(&Mat3::skew_deg(-x.skew, x.skew_axis)) * Mat4::scale(Vec3::from(x.scale) / 100.0) * Mat4::translate(-Vec3::from(x.anchor)); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 760bd2fff..98ef3ce14 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -484,6 +484,37 @@ fn per_character_3d_projects_characters() { assert!(b[2] - b[0] < flat[2] - flat[0] && b[2] > b[0] + 10, "{b:?} {flat:?}"); } +/// After Effects 2026 (case 931: "H", 400 px, per-character 3D, X / Y / Z Rotation 40 / 30 / +/// 20): a character turns like a 3D layer, Rx · Ry · Rz (Z first), so its matrix is the same +/// product as a layer's X / Y / Z Rotation. The render then matches AE (3 of 5184 grid points +/// off at edges; with Rz · Ry · Rx 23). +#[test] +fn per_character_rotation_turns_z_first_as_a_3d_layer() { + let (mut p, cid, comp) = setup(400, 300); + let mut l = text_layer(&mut p, &comp, doc("H", 80.0)); + l.switches.three_d = true; + animator(&mut p, &mut l, &["rotation"], &["range"]); + let mut next = p.next_id; + let mut extra = build::text_anim_props(&mut Ids(&mut next), "rotation", true); + p.next_id = next; + let props: &mut PropGroup = l.props.group_mut("text/animators/#1/properties").unwrap(); + props.children.retain(|c| c.match_id() != "rotation"); + props.children.extend(extra.drain(..).map(Into::into)); + for (id, v) in [("rotationX", 40.0), ("rotationY", 30.0), ("rotation", 20.0)] { + props.get_mut(id).unwrap().value = Value::Scalar(v); + } + l.props.sub_mut("text").unwrap().get_mut("perChar3d").unwrap().value = Value::Bool(true); + p.comp_mut(cid).unwrap().layers.push(l); + let geom = text_geom(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]).unwrap(); + let g = &geom.glyphs[0]; + let want = Mat4::rotate_x(40.0) * Mat4::rotate_y(30.0) * Mat4::rotate_z(20.0); + for r in 0..3 { + for c in 0..3 { + assert!((g.m.0[r][c] - want.0[r][c]).abs() < 1e-9, "row {r} col {c}: {:?} vs {:?}", g.m.0, want.0); + } + } +} + #[test] fn keyframed_selector_animates() { let (mut p, cid, comp) = setup(400, 200); diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index 22fbd4330..db579e257 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -102,6 +102,14 @@ preset now animates 40 → 0 px (was 400 → 0 thousandths of an em); Lottie exp tracking as pixels. Regression tests: `animator_tracking_is_in_pixels_as_in_after_effects`, `modes_combine`. +## Per-character 3D rotation order + +Measured on 19 October 2026 with After Effects 2026 (26.2) (case 931: "H" at 400 px, per-character +3D, animator X / Y / Z Rotation 40 / 30 / 20): a character turns as a 3D layer does, Rx · Ry · Rz +(Z first). EffectCraft composed Rz · Ry · Rx, so the glyph faced another way (23 of 5184 grid points +off, max 255); now 3 at the anti-aliased edge (mean 0.015). Regression test: +`per_character_rotation_turns_z_first_as_a_3d_layer`. + ## Still unmeasured Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames` From daedbd5ca1f0e727a54a16f7df2889067b4aebed Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 19:10:21 +0200 Subject: [PATCH 06/30] Review: short "d" falls back to the font ascent; tracking unit comment; Lottie hunk out - first_ascent: a "d" shorter than 0.3 x the font ascent (a symbol or icon font) no longer puts box text's first baseline almost at the box top; the font ascent is used, as before the "d" rule (CodeRabbit on #1875). Test: a_short_d_falls_back_to_the_font_ascent. - CharXf::track_before / track_after are pixels, not 1/1000 em (CodeRabbit). - The Lottie export of style-run tracking is back to what it was: the pixel conversion was not checked against a player, so it is left for a separate, verified change. - The Subtract clamp in Mode::combine is intended for every animator property: the selectors combine into one selection per character for the whole animator, as in After Effects; the read-back observed it through Tracking (now said in the code). - Per-character 3D rotation notes that #1876 adds Mat4::rotation_xyz for the same order. Co-Authored-By: Claude Opus 5.5 --- crates/lottie/src/text.rs | 4 ++-- crates/render/src/text.rs | 6 +++++- crates/text/src/layout.rs | 37 +++++++++++++++++++++++++++++++++++- crates/text/src/selectors.rs | 5 +++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/crates/lottie/src/text.rs b/crates/lottie/src/text.rs index e9de3d328..125835c2b 100644 --- a/crates/lottie/src/text.rs +++ b/crates/lottie/src/text.rs @@ -227,8 +227,8 @@ fn style_run_animators(ex: &mut Ex, d: &TextDoc, label: &str) -> Vec { props.insert("s".into(), k(json!([p, p, 100.0]))); } if (s.tracking - base.tracking).abs() > 1e-9 { - // An animator's tracking is in pixels; the Character panel's in 1/1000 em. - props.insert("t".into(), k(json!((s.tracking - base.tracking) / 1000.0 * s.size))); + // Lottie tracking is in 1/1000 em like the Character panel's. + props.insert("t".into(), k(json!(s.tracking - base.tracking))); } if (s.baseline_shift - base.baseline_shift).abs() > 1e-9 { props.insert("p".into(), k(json!([0.0, -(s.baseline_shift - base.baseline_shift), 0.0]))); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index c16419af8..47f5953fa 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -42,7 +42,8 @@ pub struct CharXf { pub stroke_hsb: [f64; 3], pub stroke_opacity: f64, pub stroke_width: f64, - /// Tracking (1/1000 em) added before / after the character. + /// Tracking (pixels: an animator's Tracking Amount, as After Effects) added before / after + /// the character. pub track_before: f64, pub track_after: f64, /// Line Anchor (percent) and the selection weight it was set with. @@ -752,6 +753,9 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { }; // Outline relative to the pivot. let local = kurbo::Affine::translate((o[0] - pivot[0], o[1] - pivot[1])) * outline; + // Rx · Ry · Rz (Z turns first): the order After Effects gives a 3D layer's X / Y / Z + // Rotation too. #1876 moves layers to it with a helper, `Mat4::rotation_xyz`; once both + // land, this should call that helper (one convention, one place). let char_m = Mat4::rotate_x(x.rotation_x) * Mat4::rotate_y(x.rotation_y) * Mat4::rotate_z(x.rotation) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 3ce08b84f..646a0377a 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -577,8 +577,20 @@ fn first_ascent(face: FaceId, px: f32) -> Option { let units = crate::outline_units(face, gid)?; let top = kurbo::Shape::bounding_box(&*units).y1 as f32; let em = (top / f.units_per_em() * 1000.0).round() / 1000.0; + plausible_first(em, px, f.metrics(px).ascent) +} + +/// A `d` at least this fraction of the font ascent is a Latin lowercase ascender; a shorter one +/// (a symbol or icon font's `d`) would put the first baseline almost at the box top, so the font +/// ascent is used instead (the rule before the `d` height). +const MIN_D_OF_ASCENT: f32 = 0.3; + +/// The first baseline `em * px` below the box top, if `em` (the `d` height in em) is plausible: +/// finite, positive, under 4 em and at least [`MIN_D_OF_ASCENT`] of the font `ascent` (px). +fn plausible_first(em: f32, px: f32, ascent: f32) -> Option { let h = em * px; - (h.is_finite() && h > 0.0 && em < 4.0).then_some(h) + let tall_enough = !(ascent.is_finite() && ascent > 0.0) || h >= MIN_D_OF_ASCENT * ascent; + (h.is_finite() && h > 0.0 && em < 4.0 && tall_enough).then_some(h) } struct ParaLine { @@ -1284,6 +1296,29 @@ mod tests { } } + /// CodeRabbit on #1875: a face whose `d` is very short (a symbol or icon font) would put the + /// first baseline almost at the box top; below 0.3 of the ascent the font ascent is used. + #[test] + fn a_short_d_falls_back_to_the_font_ascent() { + // Latin faces: the `d` is about 0.7 em, the ascent about 0.9 em. + let near = |v: Option, want: f32| v.is_some_and(|v| (v - want).abs() < 1e-3); + assert!(near(plausible_first(0.7, 100.0, 90.0), 70.0)); + // A `d` a tenth of the ascent: not a lowercase ascender. + assert_eq!(plausible_first(0.09, 100.0, 90.0), None); + assert_eq!(plausible_first(0.26, 100.0, 90.0), None); + assert!(near(plausible_first(0.28, 100.0, 90.0), 28.0)); + // Broken values stay rejected; an unusable ascent does not reject a sane `d`. + for (em, px) in [(0.0, 100.0), (-0.5, 100.0), (f32::NAN, 100.0), (5.0, 100.0), (0.7, f32::INFINITY)] { + assert_eq!(plausible_first(em, px, 90.0), None, "{em} {px}"); + } + assert!(near(plausible_first(0.7, 100.0, f32::NAN), 70.0)); + // The bundled faces keep the `d` rule. + for family in ["Inter", "Noto Serif"] { + let r = fonts::resolve(family, "Regular"); + assert!(first_ascent(r.face, 100.0).is_some(), "{family}"); + } + } + #[test] fn newlines_make_paragraphs_and_leading_adds() { let l = layout("one\ntwo\n\nfour", &st(30.0), &ParagraphStyle::default()); diff --git a/crates/text/src/selectors.rs b/crates/text/src/selectors.rs index 0306b7e09..fcecfd814 100644 --- a/crates/text/src/selectors.rs +++ b/crates/text/src/selectors.rs @@ -74,6 +74,11 @@ impl Mode { // Subtracting more than is selected leaves nothing, not a negative selection (After // Effects 2026: a Subtract selector over unselected characters leaves their // Tracking at 0, EXPORTER read-back t8). + // Intended for every animator property, not only Tracking: selectors combine into one + // selection per character for the whole animator (`render::text::animator_selection`), + // which then scales each of its properties, as in After Effects, where selectors + // belong to the animator. The read-back observed that selection through Tracking; + // Opacity, Position and the rest were not measured separately. Mode::Subtract if acc >= 0.0 => (acc - v).max(0.0), Mode::Subtract => acc - v, Mode::Intersect => acc * v, From 2fd7d933ee6b6f5c6d89c24168a3daa3d6ece248 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 19:10:41 +0200 Subject: [PATCH 07/30] Review: fidelity notes no longer claim the Lottie tracking export change Co-Authored-By: Claude Opus 5.5 --- docs/fidelity/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index db579e257..b2992749d 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -98,8 +98,8 @@ characters at 80 px and at 40 px, half before and half after each), and a Subtra over characters the first selector leaves out keeps them at 0 instead of tracking them negatively. With both, the EXPORTER's t8 layer D (Tracking 30 on characters 2–4 by index, a Subtract selector over the last 20 %) puts every glyph where AE does (it ended 88 px short). The "Tracking In" text -preset now animates 40 → 0 px (was 400 → 0 thousandths of an em); Lottie export writes a style run's -tracking as pixels. Regression tests: `animator_tracking_is_in_pixels_as_in_after_effects`, +preset now animates 40 → 0 px (was 400 → 0 thousandths of an em). Lottie export is unchanged (not +checked against a player). Regression tests: `animator_tracking_is_in_pixels_as_in_after_effects`, `modes_combine`. ## Per-character 3D rotation order From b813294fc8904af57b83c40f588e48fc1573fd5d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 24 Oct 2026 23:59:36 +0200 Subject: [PATCH 08/30] Avoid quadratic Schur assembly for long tracks Solve expensive camera systems with matrix-free Schur products and a block-preconditioned iterative solver. Check the true linear residual and fall back to the direct solve when convergence fails. Keep short systems on the existing path and compare reprojections, camera parameters and outlier decisions against direct solves. Fixes #2328 --- crates/track/src/camtrack/bundle.rs | 301 ++++++++++++++++++++++++---- crates/track/src/camtrack/linalg.rs | 78 +++++++ 2 files changed, 336 insertions(+), 43 deletions(-) diff --git a/crates/track/src/camtrack/bundle.rs b/crates/track/src/camtrack/bundle.rs index 077f04b71..048329a24 100644 --- a/crates/track/src/camtrack/bundle.rs +++ b/crates/track/src/camtrack/bundle.rs @@ -174,8 +174,107 @@ fn cam_jac(l: &Layout, ci: usize, c: &BaCam, xc: V3, a: &[[f64; 3]; 2], p: [f64; } } +struct PtSys { + v: M3, + bp: V3, + w: Vec<(usize, V3)>, +} + +struct PreconditionerBlock { + start: usize, + dim: usize, + inverse: Vec, +} + +fn camera_preconditioner(u: &[f64], l: &Layout) -> Option> { + let mut ranges = Vec::new(); + let pose_dim = if l.rot_only { 3 } else { 6 }; + for (&pose, &focal) in l.start.iter().zip(&l.fidx) { + if pose != usize::MAX { + ranges.push((pose, pose_dim + usize::from(focal != usize::MAX))); + } else if focal != usize::MAX { + ranges.push((focal, 1)); + } + } + if let Some(start) = l.shared.or(l.dist) { + ranges.push((start, l.n - start)); + } + ranges + .into_iter() + .map(|(start, dim)| { + let mut matrix = Vec::with_capacity(dim * dim); + for i in 0..dim { + for j in 0..dim { + matrix.push(*u.get((start + i) * l.n + start + j)?); + } + } + let mut inverse = vec![0.0; dim * dim]; + for col in 0..dim { + let mut a = matrix.clone(); + let mut b = vec![0.0; dim]; + *b.get_mut(col)? = 1.0; + if !cholesky_solve(&mut a, dim, &mut b) { + return None; + } + for (row, value) in b.into_iter().enumerate() { + inverse[row * dim + col] = value; + } + } + Some(PreconditionerBlock { start, dim, inverse }) + }) + .collect() +} + +/// Apply the point-eliminated system without materialising W V^-1 W^T. +fn iterative_schur(u: &[f64], rhs: &[f64], systems: &[PtSys], vinv: &[Option], l: &Layout) -> Option> { + if l.n == 0 || l.n.checked_mul(l.n) != Some(u.len()) || rhs.len() != l.n || systems.len() != vinv.len() { + return None; + } + let blocks = camera_preconditioner(u, l)?; + // U contains camera blocks, global parameters and the focal prior. Store its nonzeros + // once; all subsequent products are linear in these and the point-camera connections. + let rows: Vec> = + u.chunks_exact(l.n).map(|row| row.iter().enumerate().filter(|(_, v)| **v != 0.0).map(|(i, v)| (i, *v)).collect()).collect(); + let mut reduced_rhs = rhs.to_vec(); + for (ps, vi) in systems.iter().zip(vinv) { + let Some(vi) = vi else { continue }; + let vb = mv(vi, ps.bp); + for (i, w) in &ps.w { + *reduced_rhs.get_mut(*i)? -= dot(*w, vb); + } + } + conjugate_gradient( + &reduced_rhs, + |x, y| { + for (i, row) in rows.iter().enumerate() { + y[i] = row.iter().map(|(j, v)| v * x[*j]).sum(); + } + for (ps, vi) in systems.iter().zip(vinv) { + let Some(vi) = vi else { continue }; + let q = ps.w.iter().fold([0.0; 3], |q, (i, w)| add(q, scale(*w, x[*i]))); + let q = mv(vi, q); + for (i, w) in &ps.w { + y[*i] -= dot(*w, q); + } + } + }, + |r, z| { + z.fill(0.0); + for block in &blocks { + for i in 0..block.dim { + z[block.start + i] = (0..block.dim).map(|j| block.inverse[i * block.dim + j] * r[block.start + j]).sum(); + } + } + }, + ) +} + /// Run Levenberg–Marquardt. `cams` and `pts` are updated in place. pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> BaReport { + adjust_impl(cams, pts, obs, o, true) +} + +fn adjust_impl(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts, allow_iterative: bool) -> BaReport { let l = layout(cams.len(), o); let npts = pts.len(); // Observations by point. @@ -199,11 +298,6 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> iters += 1; // ---- Normal equations (Gauss–Newton with IRLS weights). // Per point: V (3×3), bp (3), W entries (cam-side index → 3). - struct PtSys { - v: [[f64; 3]; 3], - bp: V3, - w: Vec<(usize, V3)>, - } let build_point = |j: usize, s_acc: &mut Vec, bc: &mut Vec| -> PtSys { let mut ps = PtSys { v: [[0.0; 3]; 3], bp: [0.0; 3], w: vec![] }; let mut jc: Vec<(usize, [f64; 2])> = Vec::with_capacity(8); @@ -319,6 +413,10 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> let base_s = s_mat.clone(); let base_bc = bc.clone(); let mean_diag = ((0..n).map(|i| base_s[i * n + i]).sum::() / n.max(1) as f64).max(1e-12); + let schur_pairs = systems.iter().fold(0usize, |sum, ps| sum.saturating_add(ps.w.len().saturating_mul(ps.w.len().saturating_add(1)) / 2)); + // Reserve the iterative solve for expensive long-track reductions; small systems + // retain the direct path and its rounding. Failed solves use the direct path too. + let mut use_iterative = allow_iterative && !o.fix_points && n >= 120 && schur_pairs > 4_000_000; // ---- Try steps with increasing damping. let mut accepted = false; for _try in 0..12 { @@ -346,50 +444,62 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> }) .collect() }; - if !o.fix_points { - let reduce = |range: std::ops::Range, s: &mut [f64], rhs: &mut [f64]| { - for j in range { - let (Some(vi), ps) = (&vinv[j], &systems[j]) else { continue }; - let vb = mv(vi, ps.bp); - let wv: Vec = ps.w.iter().map(|(_, w)| mv(vi, *w)).collect(); - for (a, (ia, wa)) in ps.w.iter().enumerate() { - rhs[*ia] -= dot(*wa, vb); - for (ib, wb) in &ps.w[a..] { - let d = dot(wv[a], *wb); - s[ia * n + ib] -= d; - if ia != ib { - s[ib * n + ia] -= d; + let iterative_step = if use_iterative { iterative_schur(&s, &rhs, &systems, &vinv, &l) } else { None }; + if dbg && use_iterative { + eprintln!("ba iterative n={n} pairs={schur_pairs} solved={}", iterative_step.is_some()); + } + if use_iterative && iterative_step.is_none() { + use_iterative = false; + } + let dc = if let Some(dc) = iterative_step { + dc + } else { + if !o.fix_points { + let reduce = |range: std::ops::Range, s: &mut [f64], rhs: &mut [f64]| { + for j in range { + let (Some(vi), ps) = (&vinv[j], &systems[j]) else { continue }; + let vb = mv(vi, ps.bp); + let wv: Vec = ps.w.iter().map(|(_, w)| mv(vi, *w)).collect(); + for (a, (ia, wa)) in ps.w.iter().enumerate() { + rhs[*ia] -= dot(*wa, vb); + for (ib, wb) in &ps.w[a..] { + let d = dot(wv[a], *wb); + s[ia * n + ib] -= d; + if ia != ib { + s[ib * n + ia] -= d; + } } } } - } - }; - let chunks = rayon::current_num_threads().clamp(1, 8); - if n <= 1200 && npts >= 64 && chunks > 1 { - let per = npts.div_ceil(chunks); - let parts: Vec<(Vec, Vec)> = (0..chunks) - .into_par_iter() - .map(|t| { - let (mut ds, mut dr) = (vec![0.0; n * n], vec![0.0; n]); - reduce(t * per..((t + 1) * per).min(npts), &mut ds, &mut dr); - (ds, dr) - }) - .collect(); - for (ds, dr) in parts { - s.par_iter_mut().zip(ds.par_iter()).for_each(|(x, y)| *x += y); - for (x, y) in rhs.iter_mut().zip(dr) { - *x += y; + }; + let chunks = rayon::current_num_threads().clamp(1, 8); + if n <= 1200 && npts >= 64 && chunks > 1 { + let per = npts.div_ceil(chunks); + let parts: Vec<(Vec, Vec)> = (0..chunks) + .into_par_iter() + .map(|t| { + let (mut ds, mut dr) = (vec![0.0; n * n], vec![0.0; n]); + reduce(t * per..((t + 1) * per).min(npts), &mut ds, &mut dr); + (ds, dr) + }) + .collect(); + for (ds, dr) in parts { + s.par_iter_mut().zip(ds.par_iter()).for_each(|(x, y)| *x += y); + for (x, y) in rhs.iter_mut().zip(dr) { + *x += y; + } } + } else { + reduce(0..npts, &mut s, &mut rhs); } - } else { - reduce(0..npts, &mut s, &mut rhs); } - } - let mut dc = rhs; - if n > 0 && !cholesky_solve(&mut s, n, &mut dc) { - lambda *= 10.0; - continue; - } + let mut dc = rhs; + if n > 0 && !cholesky_solve(&mut s, n, &mut dc) { + lambda *= 10.0; + continue; + } + dc + }; // Apply. let mut nc: Vec = cams.to_vec(); for (i, c) in nc.iter_mut().enumerate() { @@ -464,6 +574,111 @@ mod tests { use super::*; use crate::camtrack::geometry::Rng; + #[test] + fn long_track_adjustment_matches_direct_reprojections_and_outliers() { + rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap().install(|| { + for (focal, fix_centers, distortion) in + [(FocalMode::Fixed, false, false), (FocalMode::Shared, false, true), (FocalMode::PerCamera(30.0), false, true), (FocalMode::Shared, true, true)] + { + let mut rng = Rng(71); + let mut noise = || (rng.next_u64() % 100_000) as f64 / 100_000.0 - 0.5; + let truth: Vec = (0..320).map(|_| [noise() * 8.0, noise() * 5.0, 10.0 + noise() * 5.0]).collect(); + let true_cams: Vec<_> = (0..60) + .map(|i| BaCam { + pose: Pose { + r: rodrigues([0.001 * i as f64, -0.003 * i as f64, 0.0]), + c: if fix_centers { [0.0; 3] } else { [0.03 * i as f64, 0.01 * (i as f64).sin(), 0.0] }, + }, + f: if matches!(focal, FocalMode::PerCamera(_)) { 900.0 * (1.0 + 0.05 * (i as f64 * 0.07).sin()) } else { 900.0 }, + dist: if distortion { Distortion { k1: 0.015, k2: -0.005, radius: 1100.0 } } else { Distortion::NONE }, + }) + .collect(); + let mut obs = Vec::new(); + for (ci, c) in true_cams.iter().enumerate() { + for (pi, p) in truth.iter().enumerate() { + let mut uv = residual(c, *p, [0.0; 2]).unwrap(); + uv[0] += noise() * 0.05; + uv[1] += noise() * 0.05; + if (ci * truth.len() + pi).is_multiple_of(37) { + uv[0] += 25.0; + uv[1] -= 30.0; + } + obs.push(BaObs { cam: ci as u32, pt: pi as u32, uv }); + } + } + let mut cams = true_cams.clone(); + for c in cams.iter_mut().skip(2) { + c.pose.r = mm(&rodrigues([0.001, -0.001, 0.0005]), &c.pose.r); + if !fix_centers { + c.pose.c = add(c.pose.c, [0.002, -0.003, 0.001]); + } + } + let mut pts: Vec<_> = truth.iter().map(|p| add(*p, [0.01, -0.01, 0.005])).collect(); + let (mut direct_cams, mut direct_pts) = (cams.clone(), pts.clone()); + let opts = BaOpts { focal, fix_centers, distortion, fixed: (0..60).map(|i| i < 2).collect(), max_iter: 10, ..Default::default() }; + let report = adjust(&mut cams, &mut pts, &obs, &opts); + let direct = adjust_impl(&mut direct_cams, &mut direct_pts, &obs, &opts, false); + assert!((report.cost - direct.cost).abs() < 1e-5 * direct.cost.max(1.0)); + for ob in &obs { + let a = residual(&cams[ob.cam as usize], pts[ob.pt as usize], ob.uv).unwrap(); + let b = residual(&direct_cams[ob.cam as usize], direct_pts[ob.pt as usize], ob.uv).unwrap(); + assert!((a[0] - b[0]).hypot(a[1] - b[1]) < 0.001); + assert_eq!(a[0].hypot(a[1]) <= 4.0, b[0].hypot(b[1]) <= 4.0); + } + for (a, b) in cams.iter().zip(direct_cams) { + assert!(norm(sub(a.pose.c, b.pose.c)) < 0.001); + assert!(norm(log_rot(&mm(&a.pose.r, &mt(&b.pose.r)))) < 1e-4); + assert!((a.f - b.f).abs() < 1e-4 * b.f); + assert!((a.dist.k1 - b.dist.k1).abs() < 1e-4 && (a.dist.k2 - b.dist.k2).abs() < 1e-4); + } + } + }); + } + + #[test] + fn matrix_free_schur_matches_explicit_system_and_rejects_bad_preconditioner() { + let l = layout(20, &BaOpts::default()); + let n = l.n; + let mut u = vec![0.0; n * n]; + for i in 0..n { + u[i * n + i] = 10.0; + } + let systems: Vec<_> = (0..80) + .map(|j| PtSys { + v: I3, + bp: [0.01, -0.02, 0.03], + w: (0..n).map(|i| (i, [((j * 13 + i * 7) % 19) as f64 * 0.001 - 0.009, (i % 7) as f64 * 0.001, (j % 11) as f64 * 0.001])).collect(), + }) + .collect(); + let vinv = vec![Some(I3); systems.len()]; + let rhs: Vec<_> = (0..n).map(|i| (i % 7) as f64 - 3.0).collect(); + let x = iterative_schur(&u, &rhs, &systems, &vinv, &l).unwrap(); + let mut explicit = u.clone(); + let mut reduced_rhs = rhs.clone(); + for ps in &systems { + for (i, a) in &ps.w { + reduced_rhs[*i] -= dot(*a, ps.bp); + for (j, b) in &ps.w { + explicit[i * n + j] -= dot(*a, *b); + } + } + } + let mut direct = reduced_rhs.clone(); + let mut factor = explicit.clone(); + assert!(cholesky_solve(&mut factor, n, &mut direct)); + for (a, b) in x.iter().zip(direct) { + assert!((a - b).abs() < 1e-9); + } + let mut error2 = 0.0; + for (i, row) in explicit.chunks_exact(n).enumerate() { + let ax: f64 = row.iter().zip(&x).map(|(a, x)| a * x).sum(); + error2 += (ax - reduced_rhs[i]).powi(2); + } + assert!(error2 < 1e-18 * reduced_rhs.iter().map(|v| v * v).sum::()); + u[0] = -1.0; + assert!(iterative_schur(&u, &rhs, &systems, &vinv, &l).is_none()); + } + #[test] fn bundle_adjustment_converges_from_noise() { let mut rng = Rng(99); diff --git a/crates/track/src/camtrack/linalg.rs b/crates/track/src/camtrack/linalg.rs index 2efc14556..1d53aef1d 100644 --- a/crates/track/src/camtrack/linalg.rs +++ b/crates/track/src/camtrack/linalg.rs @@ -353,6 +353,60 @@ pub fn cholesky_solve(a: &mut [f64], n: usize, b: &mut [f64]) -> bool { b.iter().all(|v| v.is_finite()) } +/// Preconditioned conjugate gradients with an independently recomputed residual check. +/// Failure to converge (or non-finite/non-positive curvature) requests the direct fallback. +pub(crate) fn conjugate_gradient(rhs: &[f64], apply: impl Fn(&[f64], &mut [f64]), precondition: impl Fn(&[f64], &mut [f64])) -> Option> { + let n = rhs.len(); + let norm2 = dot_n(rhs, rhs); + if !norm2.is_finite() { + return None; + } + if rhs.iter().all(|v| *v == 0.0) { + return Some(vec![0.0; n]); + } + let tolerance2 = norm2 * 1e-22; + if tolerance2 == 0.0 { + return None; + } + let mut x = vec![0.0; n]; + let mut r = rhs.to_vec(); + let mut z = vec![0.0; n]; + let mut ap = vec![0.0; n]; + precondition(&r, &mut z); + let mut p = z.clone(); + let mut rz = dot_n(&r, &z); + for _ in 0..n.saturating_mul(2).min(512) { + if !rz.is_finite() || rz <= 0.0 { + return None; + } + apply(&p, &mut ap); + let curvature = dot_n(&p, &ap); + if !curvature.is_finite() || curvature <= 0.0 { + return None; + } + let alpha = rz / curvature; + for i in 0..n { + x[i] += alpha * p[i]; + r[i] -= alpha * ap[i]; + } + if dot_n(&r, &r) <= tolerance2 { + apply(&x, &mut ap); + for i in 0..n { + ap[i] = rhs[i] - ap[i]; + } + return (x.iter().all(|v| v.is_finite()) && dot_n(&ap, &ap) <= 100.0 * tolerance2).then_some(x); + } + precondition(&r, &mut z); + let next_rz = dot_n(&r, &z); + let beta = next_rz / rz; + for i in 0..n { + p[i] = z[i] + beta * p[i]; + } + rz = next_rz; + } + None +} + /// A similarity transform `x ↦ s R x + t`. #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Similarity { @@ -428,6 +482,30 @@ pub fn from_euler_xyz(e: V3) -> M3 { mod tests { use super::*; + #[test] + fn conjugate_gradient_checks_residual_and_bad_curvature() { + let n = 64; + let rhs: Vec = (0..n).map(|i| (i % 11) as f64 - 5.0).collect(); + let apply = |x: &[f64], y: &mut [f64]| { + for i in 0..n { + y[i] = 4.0 * x[i] + if i > 0 { x[i - 1] } else { 0.0 } + if i + 1 < n { x[i + 1] } else { 0.0 }; + } + }; + let x = conjugate_gradient(&rhs, apply, |r, z| { + for (r, z) in r.iter().zip(z) { + *z = *r / 4.0; + } + }) + .unwrap(); + let mut product = vec![0.0; n]; + apply(&x, &mut product); + let residual: f64 = rhs.iter().zip(product).map(|(b, ax)| (b - ax).powi(2)).sum(); + assert!(residual < 1e-18 * dot_n(&rhs, &rhs)); + assert!(conjugate_gradient(&[1.0], |x, y| y[0] = -x[0], |r, z| z.copy_from_slice(r)).is_none()); + assert!(conjugate_gradient(&[f64::NAN], |_, _| {}, |_, _| {}).is_none()); + assert_eq!(conjugate_gradient(&[0.0], |_, _| {}, |_, _| {}).unwrap(), vec![0.0]); + } + #[test] fn svd_reconstructs() { let a = [3.0, 1.0, 2.0, -1.0, 4.0, 0.5, 2.0, 2.0, 7.0, 0.1, -3.0, 1.0]; From 2aecd57c28a666c5747896ab78de664c9fbb2eb5 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 26 Oct 2026 11:23:49 +0100 Subject: [PATCH 09/30] Text: justified RTL point text follows the resolved direction; only the first line decides the vertical baseline Review (CodeRabbit on #1875): with the paragraph direction unset, a right-to-left paragraph resolved by BidiInfo was still aligned as left-to-right justified point text, so it stayed left instead of flush right. eff_align now takes the resolved direction. The box-text first baseline used the font ascent whenever any run of the text was vertical; it now looks only at the runs of the first line. Two regression tests. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index b8e221274..a035bb21b 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -654,8 +654,9 @@ fn paragraph( let st = &runs[si].1; first_ascent(primaries[si].face, st.size).unwrap_or_else(|| fonts::face(primaries[si].face).metrics(st.size).ascent) }; - let eff_align = |last: bool| -> Align { - if rtl_para && width.is_none() && para.align == Align::Justify { + // `rtl` is the resolved paragraph direction (an unset direction follows the text). + let eff_align = |last: bool, rtl: bool| -> Align { + if rtl && width.is_none() && para.align == Align::Justify { // Right-to-left point text with a justified paragraph sits flush at its start, the // right (After Effects 2026, every justify code; left-to-right not measured). Align::Right @@ -679,7 +680,7 @@ fn paragraph( descent: pm.descent, first: style_first(si), advance: runs[si].1.line_advance(), - align: eff_align(true), + align: eff_align(true, rtl_para), avail: avail_for(true), margins: margins_for(true), underline: None, @@ -861,7 +862,7 @@ fn paragraph( let mut out = Vec::new(); for (li, r) in ranges.into_iter().enumerate() { let last = li + 1 == nlines; - let align = eff_align(last); + let align = eff_align(last, base_rtl); let avail = avail_for(li == 0); let (ce, tw) = trailing_ws(&r); let hang = hang_of(&r, ce); @@ -1084,7 +1085,8 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], para let para = para_at(pi); if i == 0 { // Vertical type keeps its first column at the font ascent (not measured in AE). - let vertical = runs.iter().any(|r| r.1.vertical); + let vertical = runs.get(run_at(runs, pl.range.start)).is_some_and(|r| r.1.vertical) + || runs.iter().any(|r| r.1.vertical && r.0.start < pl.range.end && r.0.end > pl.range.start); baseline = if width.is_some() { if vertical { pl.ascent } else { pl.first } } else { 0.0 }; } else { let prev_para = lay.lines.last().map_or(pi, |l| l.para); @@ -1613,6 +1615,33 @@ mod tests { } } + /// The same holds when the direction is unset and the text itself resolves right to left. + #[test] + fn detected_rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start() { + let text = "\u{5d0}\u{5d1}\u{5d2} \u{5d3}\u{5d4}"; + let s = st(30.0); + let at = |align: Align| { + let p = ParagraphStyle { align, justify_last: Align::Left, rtl: None, ..Default::default() }; + let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]); + (l.lines[0].x, l.lines[0].x + l.lines[0].width) + }; + let (right, got) = (at(Align::Right), at(Align::Justify)); + assert!((got.0 - right.0).abs() < 1e-3 && (got.1 - right.1).abs() < 1e-3, "{got:?} vs right-aligned {right:?}"); + } + + /// Only the first line's own runs decide whether box text starts at the font ascent: a + /// vertical run further down leaves a horizontal first line on its `d`-height baseline. + #[test] + fn a_later_vertical_run_keeps_the_first_line_baseline() { + let text = "Top\nside"; + let horiz = st(40.0); + let vert = TextStyle { vertical: true, ..horiz.clone() }; + let p = ParagraphStyle { width: Some(400.0), ..Default::default() }; + let plain = layout_rich_uncached(text, &[(0..text.len(), horiz.clone())], std::slice::from_ref(&p)); + let mixed = layout_rich_uncached(text, &[(0..4, horiz), (4..text.len(), vert)], &[p]); + assert!((mixed.lines[0].baseline - plain.lines[0].baseline).abs() < 1e-3, "{} vs {}", mixed.lines[0].baseline, plain.lines[0].baseline); + } + #[test] fn oversized_layout_requests_are_bounded_without_caching() { let text = "a".repeat(MAX_LAYOUT_BYTES + 1); From ba2023ac945f60bc4729896a252d1b011e8ecf99 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 26 Oct 2026 15:05:42 +0100 Subject: [PATCH 10/30] Text: indents follow the resolved paragraph direction too Review (CodeRabbit on #1875): with the direction unset, Hebrew text resolved to right-to-left took its start indent on the left. The paragraph direction is now resolved once from BidiInfo before the margin and alignment helpers, so both use it. Regression test. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index a035bb21b..533b897c4 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -637,7 +637,10 @@ fn paragraph( para: &ParagraphStyle, width: Option, ) -> Vec { - let rtl_para = para.rtl == Some(true); + let default_level = para.rtl.map(|r| if r { Level::rtl() } else { Level::ltr() }); + let bidi = BidiInfo::new(text, default_level); + // The resolved paragraph direction: an unset direction follows the text. + let rtl_para = bidi.paragraphs.first().map_or(para.rtl == Some(true), |p| p.level.is_rtl()); let margins_for = |first: bool| -> (f32, f32) { let fi = if first { para.indent_first } else { 0.0 }; if rtl_para { (para.indent_end, para.indent_start + fi) } else { (para.indent_start + fi, para.indent_end) } @@ -654,9 +657,8 @@ fn paragraph( let st = &runs[si].1; first_ascent(primaries[si].face, st.size).unwrap_or_else(|| fonts::face(primaries[si].face).metrics(st.size).ascent) }; - // `rtl` is the resolved paragraph direction (an unset direction follows the text). - let eff_align = |last: bool, rtl: bool| -> Align { - if rtl && width.is_none() && para.align == Align::Justify { + let eff_align = |last: bool| -> Align { + if rtl_para && width.is_none() && para.align == Align::Justify { // Right-to-left point text with a justified paragraph sits flush at its start, the // right (After Effects 2026, every justify code; left-to-right not measured). Align::Right @@ -680,16 +682,14 @@ fn paragraph( descent: pm.descent, first: style_first(si), advance: runs[si].1.line_advance(), - align: eff_align(true, rtl_para), + align: eff_align(true), avail: avail_for(true), margins: margins_for(true), underline: None, }]; } - let default_level = para.rtl.map(|r| if r { Level::rtl() } else { Level::ltr() }); - let bidi = BidiInfo::new(text, default_level); let pinfo = &bidi.paragraphs[0]; - let base_rtl = pinfo.level.is_rtl(); + let base_rtl = rtl_para; // per char: (byte, char, shaped char, face, rtl, form, run) let chars: Vec<(usize, char, char, FaceId, bool, Form, usize)> = text .char_indices() @@ -862,7 +862,7 @@ fn paragraph( let mut out = Vec::new(); for (li, r) in ranges.into_iter().enumerate() { let last = li + 1 == nlines; - let align = eff_align(last, base_rtl); + let align = eff_align(last); let avail = avail_for(li == 0); let (ce, tw) = trailing_ws(&r); let hang = hang_of(&r, ce); @@ -1629,6 +1629,21 @@ mod tests { assert!((got.0 - right.0).abs() < 1e-3 && (got.1 - right.1).abs() < 1e-3, "{got:?} vs right-aligned {right:?}"); } + /// Indents follow the resolved direction too: detected right-to-left text puts its start + /// indent on the right, as an explicit right-to-left paragraph does. + #[test] + fn detected_rtl_text_takes_its_indents_on_the_rtl_sides() { + let text = "\u{5d0}\u{5d1}\u{5d2} \u{5d3}\u{5d4}"; + let s = st(30.0); + let at = |rtl: Option| { + let p = ParagraphStyle { align: Align::Justify, justify_last: Align::Left, indent_start: 20.0, rtl, ..Default::default() }; + let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]); + (l.lines[0].x, l.lines[0].x + l.lines[0].width) + }; + let (explicit, detected) = (at(Some(true)), at(None)); + assert!((detected.0 - explicit.0).abs() < 1e-3 && (detected.1 - explicit.1).abs() < 1e-3, "{detected:?} vs {explicit:?}"); + } + /// Only the first line's own runs decide whether box text starts at the font ascent: a /// vertical run further down leaves a horizontal first line on its `d`-height baseline. #[test] From 891757e2f5ca01531c31493fe0e60217a33c920e Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 27 Oct 2026 22:39:35 +0000 Subject: [PATCH 11/30] #2465: use Linear interpolation for the Linear ease preset --- extensions/scriptui-panels/Ease Presets.jsx | 22 +++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/extensions/scriptui-panels/Ease Presets.jsx b/extensions/scriptui-panels/Ease Presets.jsx index 6f6eadae5..9f4ea2185 100644 --- a/extensions/scriptui-panels/Ease Presets.jsx +++ b/extensions/scriptui-panels/Ease Presets.jsx @@ -18,6 +18,8 @@ // segment on that side. So a selected pair gets the whole curve, and a single key works too. // Roving keys are skipped: they pass speed straight through (their sides stay linear), so an ease // set on them wouldn't play. +// The built-in Linear preset uses Linear interpolation, including on unused endpoint sides +// so a fully linear key has a diamond icon; the other presets use Bezier. // // User presets are kept with app.settings (section "Ease Presets", key "userPresets") as JSON: // {"version": 1, "presets": [{"name": …, "curve": {"outInfluence", "outSpeed", "inInfluence", @@ -29,6 +31,7 @@ var MAX_NAME = 64; var MIN_INFLUENCE = 0.1; var MAX_SPEED = 100; + var LINEAR = KeyframeInterpolationType.LINEAR; var BEZIER = KeyframeInterpolationType.BEZIER; var HOLD = KeyframeInterpolationType.HOLD; @@ -178,16 +181,16 @@ // Ease one side of key `k` with `eases`, leaving its other side as it plays (auto-Bezier and // linear sides keep their shape; a continuous key stops being continuous, its sides now differ). - function easeSide(p, k, out, eases) { + function easeSide(p, k, out, eases, interpolation) { var inType = p.keyInInterpolationType(k), outType = p.keyOutInterpolationType(k); var inEase = p.keyInTemporalEase(k), outEase = p.keyOutTemporalEase(k); if (p.keyTemporalContinuous(k)) p.setTemporalContinuousAtKey(k, false); if (out) { p.setTemporalEaseAtKey(k, inEase, eases); - p.setInterpolationTypeAtKey(k, inType, BEZIER); + p.setInterpolationTypeAtKey(k, inType, interpolation); } else { p.setTemporalEaseAtKey(k, eases, outEase); - p.setInterpolationTypeAtKey(k, BEZIER, outType); + p.setInterpolationTypeAtKey(k, interpolation, outType); } } @@ -201,7 +204,7 @@ // Ease the sides `mode` names ("In", "Out" or "In-Out") of every selected keyframe with `c`, in // one undo step: a side with a neighbouring key, scaled to the segment on that side. Returns // {eased, roving}: the number of keyframes eased, and of selected roving keyframes skipped. - function applyCurve(c, mode) { + function applyCurve(c, mode, interpolation) { var keyed = selectedKeyed(), sides = [], keys = 0, roving = 0; var doIn = mode !== "Out", doOut = mode !== "In"; // Every side and its eases first: the segments are measured before any key changes. @@ -214,16 +217,16 @@ continue; } var s = doIn && k > 1 ? slopes(p, k - 1) : null; - if (s) sides.push({ prop: p, key: k, out: false, eases: eases(s, c.inSpeed, c.inInfluence) }); + if (s || (doIn && interpolation === LINEAR)) sides.push({ prop: p, key: k, out: false, eases: s ? eases(s, c.inSpeed, c.inInfluence) : p.keyInTemporalEase(k) }); s = doOut && k < p.numKeys ? slopes(p, k) : null; - if (s) sides.push({ prop: p, key: k, out: true, eases: eases(s, c.outSpeed, c.outInfluence) }); + if (s || (doOut && interpolation === LINEAR)) sides.push({ prop: p, key: k, out: true, eases: s ? eases(s, c.outSpeed, c.outInfluence) : p.keyOutTemporalEase(k) }); if (sides.length > before) keys++; } } if (!sides.length) return { eased: 0, roving: roving }; app.beginUndoGroup("Apply Ease Preset"); try { - for (var e = 0; e < sides.length; e++) easeSide(sides[e].prop, sides[e].key, sides[e].out, sides[e].eases); + for (var e = 0; e < sides.length; e++) easeSide(sides[e].prop, sides[e].key, sides[e].out, sides[e].eases, interpolation); } finally { app.endUndoGroup(); } @@ -384,7 +387,10 @@ function apply(c, what) { var m = mode.selection ? mode.selection.text : "In-Out"; - var r = applyCurve(c, m), n = r.eased; + // Linear is an interpolation type too, so its keys get diamond icons. Edited speeds + // make a custom Bezier curve even while Linear remains selected in the list. + var interpolation = list.selection && list.selection.text === "Linear" && c.outSpeed === 1 && c.inSpeed === 1 ? LINEAR : BEZIER; + var r = applyCurve(c, m, interpolation), n = r.eased; var side = m === "In" ? "before it" : m === "Out" ? "after it" : "next to it"; var skipped = r.roving > 0 ? "skipped " + r.roving + " roving keyframe(s); turn off Roving to ease them" : ""; if (n > 0) status.text = "Eased " + n + " keyframe(s) (" + m + ") with " + what + (skipped ? "; " + skipped : ""); From 7a3bd9dcdf9ce177bfdd4762017d3e77fe77f783 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 29 Oct 2026 08:58:50 +0000 Subject: [PATCH 12/30] #2465: test the Linear ease preset's interpolation and edited speeds Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U --- crates/script/src/tests/ease_presets.rs | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/script/src/tests/ease_presets.rs b/crates/script/src/tests/ease_presets.rs index 009cc59c6..e9a9c8a0a 100644 --- a/crates/script/src/tests/ease_presets.rs +++ b/crates/script/src/tests/ease_presets.rs @@ -608,3 +608,31 @@ fn presets_saved_by_the_old_core_panel_carry_over() { let win = open_panel(&mut s); assert_eq!(control(&mut s, win, "presets")["items"].as_array().unwrap().len(), 12); } + +/// #2465: the Linear preset sets Linear interpolation on every side it applies to, including the +/// first key's out side and the last key's in side; editing its speeds makes a Bezier curve. +#[test] +fn the_linear_preset_sets_linear_interpolation_and_edited_speeds_give_bezier() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap(); + let win = open_panel(&mut s); + // Start from Bezier keys so Linear has something to restore. + set(&mut s, win, "presets", json!("Ease In-Out")); + let op = prop(&s, l, "transform/opacity"); + assert!(op.keys[0].out_interp == Interp::Bezier && op.keys[2].in_interp == Interp::Bezier); + let steps = s.history.undo.len(); + set(&mut s, win, "presets", json!("Linear")); + assert_eq!(s.history.undo.len(), steps + 1, "one undo step"); + let op = prop(&s, l, "transform/opacity"); + for (i, k) in op.keys.iter().enumerate() { + assert_eq!((k.in_interp, k.out_interp), (Interp::Linear, Interp::Linear), "key {i}"); + } + // Edited speeds make a custom Bezier curve even though Linear is still the selected preset. + select_keys(&mut s, l, &[("transform/opacity", 1.0)]); + apply_typed(&mut s, win, "In-Out", [33.0, 2.0, 33.0, 2.0]); + let k = &prop(&s, l, "transform/opacity").keys[1]; + assert_eq!((k.in_interp, k.out_interp), (Interp::Bezier, Interp::Bezier)); +} From c334c36645b7242aa43a2624c88e0ffc17651e59 Mon Sep 17 00:00:00 2001 From: "G.J.R. Timmer" Date: Thu, 29 Oct 2026 07:02:46 -0400 Subject: [PATCH 13/30] feat(web): startup option in effectcraft-config.json A startup key (last, empty or demo) chooses the project the page opens on load. empty restores the last visit's project and otherwise starts blank, never with the demo; demo always opens the demo project; last, or no key, is the current behaviour. ?empty and ?demo still win. The config file is fetched once per page load and the service worker never caches it. --- apps/effectcraft-web/Cargo.toml | 4 ++ apps/effectcraft-web/src/app.rs | 13 +++-- apps/effectcraft-web/src/config_file.rs | 44 +++++++++++++++++ apps/effectcraft-web/src/lib.rs | 5 +- apps/effectcraft-web/src/startup.rs | 49 +++++++++++++++++++ apps/effectcraft-web/tests/it/main.rs | 1 + .../effectcraft-web/tests/it/startup_tests.rs | 40 +++++++++++++++ apps/effectcraft-web/web/sw.js | 4 +- docs/web.md | 19 +++++++ packaging/web/README.md | 13 +++++ packaging/web/_headers | 3 ++ 11 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 apps/effectcraft-web/src/config_file.rs create mode 100644 apps/effectcraft-web/src/startup.rs create mode 100644 apps/effectcraft-web/tests/it/main.rs create mode 100644 apps/effectcraft-web/tests/it/startup_tests.rs diff --git a/apps/effectcraft-web/Cargo.toml b/apps/effectcraft-web/Cargo.toml index 28ea2f369..e7bb3c347 100644 --- a/apps/effectcraft-web/Cargo.toml +++ b/apps/effectcraft-web/Cargo.toml @@ -51,6 +51,10 @@ web-sys = { version = "0.3", features = [ "HtmlInputElement", "Location", "Navigator", + "Request", + "RequestCache", + "RequestInit", + "Response", "Url", "UrlSearchParams", "Window", diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 169529e5d..be7063f9f 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -6,7 +6,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use effectcraft_engine::Session; -use crate::{api, audio, browse, diskcache, files, frames, persist, storage, worker}; +use crate::{api, audio, browse, config_file, diskcache, files, frames, persist, storage, worker}; use effectcraft_ui_egui::EffectcraftApp; use serde_json::json; use wasm_bindgen::prelude::*; @@ -220,6 +220,8 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { }; api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": t0.elapsed().as_secs_f64() * 1000.0})); audio::install_unlock(); + // `./effectcraft-config.json`: what to open on load. + let startup = config_file::startup().await; browse::restore(); diskcache::init(); storage::refresh(); @@ -255,10 +257,11 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { let mut session = session(); session.load_settings(); let recovery = session.begin_recovery(); - // `?empty`: a blank project; `?demo`: the demo project; otherwise the last visit's - // project (the demo on a first visit). - let restored = !empty && !query_flag("demo") && persist::restore(&mut session); - if !restored && !empty { + // `?empty`: a blank project; `?demo`: the demo project; otherwise what the config's + // `startup` says (default: the last visit's project, the demo on a first visit). + let demo = query_flag("demo"); + let restored = !empty && !demo && startup.restores() && persist::restore(&mut session); + if !restored && !empty && (demo || startup.demo_when_nothing_restored()) { let _ = session.execute("file.openDemoProject", json!({})); } api::set_info("restored", json!(restored)); diff --git a/apps/effectcraft-web/src/config_file.rs b/apps/effectcraft-web/src/config_file.rs new file mode 100644 index 000000000..baeca3269 --- /dev/null +++ b/apps/effectcraft-web/src/config_file.rs @@ -0,0 +1,44 @@ +//! `./effectcraft-config.json`, fetched once per page load and shared by whoever reads a key. + +use std::cell::RefCell; + +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::JsFuture; + +use crate::startup::{self, Startup}; + +thread_local! { + /// `None`: not fetched yet; `Some(None)`: no usable file. + static FILE: RefCell>>> = const { RefCell::new(None) }; +} + +async fn fetch() -> Option> { + let init = web_sys::RequestInit::new(); + // Always ask the server: the file is the operator's switch, never a cached copy. + init.set_cache(web_sys::RequestCache::NoCache); + let req = web_sys::Request::new_with_str_and_init("effectcraft-config.json", &init).ok()?; + let resp: web_sys::Response = JsFuture::from(web_sys::window()?.fetch_with_request(&req)).await.ok()?.dyn_into().ok()?; + if resp.status() != 200 { + return None; + } + let buf = JsFuture::from(resp.array_buffer().ok()?).await.ok()?; + let a = js_sys::Uint8Array::new(&buf); + (a.length() as usize <= startup::CONFIG_MAX).then(|| a.to_vec()) +} + +/// The config file's bytes (`None` when there is none), fetched on the first call. +pub async fn bytes() -> Option> { + if let Some(done) = FILE.with(|f| f.borrow().clone()) { + return done; + } + let got = fetch().await; + FILE.with(|f| *f.borrow_mut() = Some(got.clone())); + got +} + +/// The configured startup behaviour. +pub async fn startup() -> Startup { + let s = bytes().await.map(|b| startup::parse(&b)).unwrap_or_default(); + log::info!("startup: {s:?}"); + s +} diff --git a/apps/effectcraft-web/src/lib.rs b/apps/effectcraft-web/src/lib.rs index 97d2e887f..e1bf85894 100644 --- a/apps/effectcraft-web/src/lib.rs +++ b/apps/effectcraft-web/src/lib.rs @@ -18,10 +18,11 @@ //! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown (`audio`) | //! | TCP control channel / MCP | `window.effectcraft` JavaScript API (`api`) | //! -//! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only. +//! [`store`] and [`startup`] are portable (unit-tested natively); everything else is `wasm32`-only. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod startup; pub mod store; #[cfg(target_arch = "wasm32")] @@ -33,6 +34,8 @@ pub mod audio; #[cfg(target_arch = "wasm32")] pub mod browse; #[cfg(target_arch = "wasm32")] +pub mod config_file; +#[cfg(target_arch = "wasm32")] pub mod diskcache; #[cfg(target_arch = "wasm32")] pub mod files; diff --git a/apps/effectcraft-web/src/startup.rs b/apps/effectcraft-web/src/startup.rs new file mode 100644 index 000000000..9d30cb609 --- /dev/null +++ b/apps/effectcraft-web/src/startup.rs @@ -0,0 +1,49 @@ +//! The `startup` key of `./effectcraft-config.json`: which project the page opens on load. +//! +//! | Value | On load | +//! |---|---| +//! | `"last"` (and absent) | the last visit's project; the demo project on a first visit | +//! | `"empty"` | the last visit's project; a blank project on a first visit, never the demo | +//! | `"demo"` | the demo project, whatever the last visit left | +//! +//! The `?empty` and `?demo` URL flags still win. Anything else (a missing key, another type, an +//! unknown value, a config that is not JSON) is the default, `"last"`. + +use serde_json::Value; + +/// What the page opens on load. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Startup { + #[default] + Last, + Empty, + Demo, +} + +/// The largest config file read (bytes). +pub const CONFIG_MAX: usize = 64 * 1024; + +/// The `startup` key of a config file's bytes. +pub fn parse(bytes: &[u8]) -> Startup { + if bytes.len() > CONFIG_MAX { + return Startup::default(); + } + let Ok(v) = serde_json::from_slice::(bytes) else { return Startup::default() }; + match v.get("startup").and_then(Value::as_str) { + Some("empty") => Startup::Empty, + Some("demo") => Startup::Demo, + _ => Startup::Last, + } +} + +impl Startup { + /// Whether the last visit's project is restored (off for the demo). + pub fn restores(self) -> bool { + self != Startup::Demo + } + + /// Whether the demo project opens when nothing was restored (off for a blank start). + pub fn demo_when_nothing_restored(self) -> bool { + self != Startup::Empty + } +} diff --git a/apps/effectcraft-web/tests/it/main.rs b/apps/effectcraft-web/tests/it/main.rs new file mode 100644 index 000000000..a4d242ba5 --- /dev/null +++ b/apps/effectcraft-web/tests/it/main.rs @@ -0,0 +1 @@ +mod startup_tests; diff --git a/apps/effectcraft-web/tests/it/startup_tests.rs b/apps/effectcraft-web/tests/it/startup_tests.rs new file mode 100644 index 000000000..f5a491368 --- /dev/null +++ b/apps/effectcraft-web/tests/it/startup_tests.rs @@ -0,0 +1,40 @@ +//! The `startup` key of the config file. + +use effectcraft_web::startup::{CONFIG_MAX, Startup, parse}; + +#[test] +fn the_three_values() { + assert_eq!(parse(br#"{"startup":"empty"}"#), Startup::Empty); + assert_eq!(parse(br#"{"startup":"demo"}"#), Startup::Demo); + assert_eq!(parse(br#"{"startup":"last"}"#), Startup::Last); +} + +#[test] +fn absent_or_invalid_is_the_default() { + for bytes in + [&b"{}"[..], b"", b"not json", b"[]", b"null", br#"{"startup":1}"#, br#"{"startup":"Empty"}"#, br#"{"startup":null}"#, br#"{"startup":["empty"]}"#] + { + assert_eq!(parse(bytes), Startup::Last, "{:?}", String::from_utf8_lossy(bytes)); + } +} + +#[test] +fn a_config_with_other_keys_still_has_its_startup() { + assert_eq!(parse(br#"{"remote":{"mounts":[]},"startup":"empty"}"#), Startup::Empty); + assert_eq!(parse(br#"{"startup":"demo","unknown":{"a":1}}"#), Startup::Demo); +} + +#[test] +fn an_oversized_file_is_the_default() { + let mut big = br#"{"startup":"empty","pad":""#.to_vec(); + big.extend(std::iter::repeat_n(b'x', CONFIG_MAX)); + big.extend_from_slice(br#""}"#); + assert_eq!(parse(&big), Startup::Last); +} + +#[test] +fn what_each_value_does_on_load() { + assert!(Startup::Last.restores() && Startup::Last.demo_when_nothing_restored()); + assert!(Startup::Empty.restores() && !Startup::Empty.demo_when_nothing_restored()); + assert!(!Startup::Demo.restores() && Startup::Demo.demo_when_nothing_restored()); +} diff --git a/apps/effectcraft-web/web/sw.js b/apps/effectcraft-web/web/sw.js index 4acbeeeeb..8f48a93bc 100644 --- a/apps/effectcraft-web/web/sw.js +++ b/apps/effectcraft-web/web/sw.js @@ -3,7 +3,8 @@ // `cargo xtask web` fills in the build's file list and version. Every file is precached into a // cache named after the version, served cache-first (responses keep the server's headers, so // the page stays cross-origin isolated: COOP/COEP), and old versions are dropped once the new -// one activates. Anything else same-origin is fetched from the network and cached for next time. +// one activates. Anything else same-origin is fetched from the network and cached for next time, +// except the config file, which must stay live. const VERSION = "__EC_VERSION__"; const FILES = __EC_FILES__; const CACHE = `effectcraft-${VERSION}`; @@ -30,6 +31,7 @@ self.addEventListener("fetch", (event) => { const req = event.request; const url = new URL(req.url); if (req.method !== "GET" || url.origin !== self.location.origin) return; + if (url.pathname.endsWith("/effectcraft-config.json")) return; event.respondWith( (async () => { const cache = await caches.open(CACHE).catch(() => null); diff --git a/docs/web.md b/docs/web.md index c18f7e869..3f9374caa 100644 --- a/docs/web.md +++ b/docs/web.md @@ -242,6 +242,25 @@ Window ▸ Media Browser browses a virtual tree (`src/browse.rs`): `mediaBrowser.list` reports the places and these actions; `mediaBrowser.action {action}` runs one. +### Startup project + +By default the page opens the last visit's project, and the demo project on a first visit. A +`startup` key in `effectcraft-config.json` (next to `index.html`) changes that: + +```json +{"startup":"empty"} +``` + +| Value | On load | +|---|---| +| `"last"` (or no key) | the last visit's project; the demo project on a first visit | +| `"empty"` | the last visit's project; a blank project on a first visit, never the demo | +| `"demo"` | the demo project, whatever the last visit left | + +The `?empty` and `?demo` URL flags still win. A missing, invalid or non-JSON file, or an unknown +value, is the default. The file is fetched once per page load; serve it with +`Cache-Control: no-cache` (the service worker never caches it). + ### Audio Audio preview goes through **Web Audio**: an AudioWorklet (`audio-worklet.js`; a diff --git a/packaging/web/README.md b/packaging/web/README.md index 0a1f56fe8..6642b2b30 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -58,6 +58,19 @@ location /effectcraft/ { Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/. +## Startup project (optional) + +To choose what the page opens on load, add an `effectcraft-config.json` next to `index.html`: + +```json +{"startup":"empty"} +``` + +`"empty"` restores the last visit's project and otherwise starts blank (never the demo); `"demo"` +always opens the demo project; `"last"` (the default) restores the last visit's project and shows +the demo on a first visit. Serve the file with `Cache-Control: no-cache`. Details: +[docs/web.md](../../docs/web.md#startup-project). + ## Embedding in a page (iframe) ```html diff --git a/packaging/web/_headers b/packaging/web/_headers index 013e1cf8a..e59592fb1 100644 --- a/packaging/web/_headers +++ b/packaging/web/_headers @@ -12,3 +12,6 @@ /index.html Cache-Control: no-cache + +/effectcraft-config.json + Cache-Control: no-cache From 2a6cece4ea6c4a9ffa6145d2e2b0a48a7a30b4ff Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Thu, 29 Oct 2026 22:04:20 +0900 Subject: [PATCH 14/30] =?UTF-8?q?i18n(ja):=20the=20Generate=20effect=20cat?= =?UTF-8?q?egory=20is=20=E6=8F=8F=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JAPANESE_CATEGORIES had 20 of the 21 the Simplified table carries, so the Generate group in the Effect menu drew its English name. AE calls it 描画. Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/i18n/effects.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 162300f9c..29a850260 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -490,6 +490,7 @@ const JAPANESE_CATEGORIES: &[(&str, &str)] = &[ ("Color Correction", "カラー補正"), ("Distort", "ディストーション"), ("Expression Controls", "式制御"), + ("Generate", "描画"), ("Immersive Video", "イマーシブビデオ"), ("Keying", "キーイング"), ("Matte", "マット"), From df9207d1bebd7c382156e9133f4a8213e47939da Mon Sep 17 00:00:00 2001 From: "G.J.R. Timmer" Date: Thu, 29 Oct 2026 09:25:48 -0400 Subject: [PATCH 15/30] feat(web): keep the saved session on a demo start, bound the config request An explicit demo startup no longer saves the session, so the demo cannot replace the last visit's project. The config request is aborted after 5 s, stops reading once the body passes the size limit, and is skipped for ?empty and ?demo. The packaged hosting notes link to the published docs. --- apps/effectcraft-web/Cargo.toml | 4 ++ apps/effectcraft-web/src/app.rs | 12 ++++-- apps/effectcraft-web/src/config_file.rs | 41 ++++++++++++++++--- apps/effectcraft-web/src/startup.rs | 6 +++ .../effectcraft-web/tests/it/startup_tests.rs | 12 ++++++ docs/web.md | 2 +- packaging/web/README.md | 4 +- 7 files changed, 68 insertions(+), 13 deletions(-) diff --git a/apps/effectcraft-web/Cargo.toml b/apps/effectcraft-web/Cargo.toml index e7bb3c347..7743347af 100644 --- a/apps/effectcraft-web/Cargo.toml +++ b/apps/effectcraft-web/Cargo.toml @@ -37,6 +37,8 @@ js-sys = "0.3" web-sys = { version = "0.3", features = [ "Blob", "BlobPropertyBag", + "AbortController", + "AbortSignal", "CssStyleDeclaration", "Document", "Element", @@ -51,6 +53,8 @@ web-sys = { version = "0.3", features = [ "HtmlInputElement", "Location", "Navigator", + "ReadableStream", + "ReadableStreamDefaultReader", "Request", "RequestCache", "RequestInit", diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index be7063f9f..bea794e82 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -6,6 +6,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use effectcraft_engine::Session; +use crate::startup::Startup; use crate::{api, audio, browse, config_file, diskcache, files, frames, persist, storage, worker}; use effectcraft_ui_egui::EffectcraftApp; use serde_json::json; @@ -81,7 +82,7 @@ pub(crate) fn open_or_import(app: &mut EffectcraftApp, paths: Vec) { /// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties. pub struct WebApp { pub app: EffectcraftApp, - /// Keep the session snapshot (off with `?empty`). + /// Keep the session snapshot (off with `?empty` and an explicit `"demo"` startup). pub persist: bool, } @@ -221,7 +222,10 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": t0.elapsed().as_secs_f64() * 1000.0})); audio::install_unlock(); // `./effectcraft-config.json`: what to open on load. - let startup = config_file::startup().await; + // `?empty` and `?demo` decide by themselves, so they skip the request. + let startup = if empty || query_flag("demo") { Startup::default() } else { config_file::startup().await }; + // An explicit `"demo"` start leaves the saved session alone: the demo must not overwrite it. + let keep_session = !empty && startup.keeps_session(); browse::restore(); diskcache::init(); storage::refresh(); @@ -247,7 +251,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { // The canvas can't present any more (#225): the page says so over it and // offers a reload, which brings the session back when browser storage keeps it. let lost = gpu_failures.clone(); - let kept = !empty && persist::backend() != "memory"; + let kept = keep_session && persist::backend() != "memory"; rs.device.set_device_lost_callback(move |reason, message| { let message = format!("GPU device lost ({reason:?}): {message}"); lost.report(&message, true); @@ -287,7 +291,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { app = app.with_control(rx); let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend)); api::set_info("backend", json!(backend)); - Ok(Box::new(WebApp { app, persist: !empty })) + Ok(Box::new(WebApp { app, persist: keep_session })) }), ) .await?; diff --git a/apps/effectcraft-web/src/config_file.rs b/apps/effectcraft-web/src/config_file.rs index baeca3269..614d7d4cb 100644 --- a/apps/effectcraft-web/src/config_file.rs +++ b/apps/effectcraft-web/src/config_file.rs @@ -2,28 +2,57 @@ use std::cell::RefCell; -use wasm_bindgen::JsCast; +use wasm_bindgen::{JsCast, closure::Closure}; use wasm_bindgen_futures::JsFuture; use crate::startup::{self, Startup}; +/// Longest the request and the body read may take (ms); a stalled server must not hold up start-up. +const TIMEOUT_MS: i32 = 5_000; + thread_local! { /// `None`: not fetched yet; `Some(None)`: no usable file. static FILE: RefCell>>> = const { RefCell::new(None) }; } +/// The config file's bytes, or `None` when it is missing, too large, unreadable or the request +/// takes longer than [`TIMEOUT_MS`] (the timer aborts the request and covers the body read). async fn fetch() -> Option> { + let win = web_sys::window()?; + let ctl = web_sys::AbortController::new().ok()?; let init = web_sys::RequestInit::new(); // Always ask the server: the file is the operator's switch, never a cached copy. init.set_cache(web_sys::RequestCache::NoCache); - let req = web_sys::Request::new_with_str_and_init("effectcraft-config.json", &init).ok()?; - let resp: web_sys::Response = JsFuture::from(web_sys::window()?.fetch_with_request(&req)).await.ok()?.dyn_into().ok()?; + init.set_signal(Some(&ctl.signal())); + let on_timeout = Closure::once(move || ctl.abort()); + let timer = win.set_timeout_with_callback_and_timeout_and_arguments_0(on_timeout.as_ref().unchecked_ref(), TIMEOUT_MS).ok()?; + let out = read(&win, &init).await; + win.clear_timeout_with_handle(timer); + out +} + +/// Fetch the file and read its body chunk by chunk, giving up as soon as it passes `CONFIG_MAX`. +async fn read(win: &web_sys::Window, init: &web_sys::RequestInit) -> Option> { + let req = web_sys::Request::new_with_str_and_init("effectcraft-config.json", init).ok()?; + let resp: web_sys::Response = JsFuture::from(win.fetch_with_request(&req)).await.ok()?.dyn_into().ok()?; if resp.status() != 200 { return None; } - let buf = JsFuture::from(resp.array_buffer().ok()?).await.ok()?; - let a = js_sys::Uint8Array::new(&buf); - (a.length() as usize <= startup::CONFIG_MAX).then(|| a.to_vec()) + let reader: web_sys::ReadableStreamDefaultReader = resp.body()?.get_reader().unchecked_into(); + let mut out = Vec::new(); + loop { + let chunk = JsFuture::from(reader.read()).await.ok()?; + if js_sys::Reflect::get(&chunk, &"done".into()).ok()?.as_bool() == Some(true) { + return Some(out); + } + let bytes: js_sys::Uint8Array = js_sys::Reflect::get(&chunk, &"value".into()).ok()?.dyn_into().ok()?; + if out.len().saturating_add(bytes.length() as usize) > startup::CONFIG_MAX { + // Stop the download: the limit is enforced while reading, not after the whole body arrived. + let _ = reader.cancel(); + return None; + } + out.extend_from_slice(&bytes.to_vec()); + } } /// The config file's bytes (`None` when there is none), fetched on the first call. diff --git a/apps/effectcraft-web/src/startup.rs b/apps/effectcraft-web/src/startup.rs index 9d30cb609..4759096f3 100644 --- a/apps/effectcraft-web/src/startup.rs +++ b/apps/effectcraft-web/src/startup.rs @@ -42,6 +42,12 @@ impl Startup { self != Startup::Demo } + /// Whether the session snapshot keeps being saved (off for the demo, so it never replaces the + /// project the last visit left). + pub fn keeps_session(self) -> bool { + self != Startup::Demo + } + /// Whether the demo project opens when nothing was restored (off for a blank start). pub fn demo_when_nothing_restored(self) -> bool { self != Startup::Empty diff --git a/apps/effectcraft-web/tests/it/startup_tests.rs b/apps/effectcraft-web/tests/it/startup_tests.rs index f5a491368..3ce90569c 100644 --- a/apps/effectcraft-web/tests/it/startup_tests.rs +++ b/apps/effectcraft-web/tests/it/startup_tests.rs @@ -2,6 +2,7 @@ use effectcraft_web::startup::{CONFIG_MAX, Startup, parse}; +/// The three accepted values parse. #[test] fn the_three_values() { assert_eq!(parse(br#"{"startup":"empty"}"#), Startup::Empty); @@ -9,6 +10,7 @@ fn the_three_values() { assert_eq!(parse(br#"{"startup":"last"}"#), Startup::Last); } +/// A missing key, a wrong type, an unknown value or a non-object file gives `Last`. #[test] fn absent_or_invalid_is_the_default() { for bytes in @@ -18,12 +20,14 @@ fn absent_or_invalid_is_the_default() { } } +/// Other keys do not hide `startup`. #[test] fn a_config_with_other_keys_still_has_its_startup() { assert_eq!(parse(br#"{"remote":{"mounts":[]},"startup":"empty"}"#), Startup::Empty); assert_eq!(parse(br#"{"startup":"demo","unknown":{"a":1}}"#), Startup::Demo); } +/// A file over `CONFIG_MAX` is ignored. #[test] fn an_oversized_file_is_the_default() { let mut big = br#"{"startup":"empty","pad":""#.to_vec(); @@ -32,9 +36,17 @@ fn an_oversized_file_is_the_default() { assert_eq!(parse(&big), Startup::Last); } +/// Which project each value restores or opens. #[test] fn what_each_value_does_on_load() { assert!(Startup::Last.restores() && Startup::Last.demo_when_nothing_restored()); assert!(Startup::Empty.restores() && !Startup::Empty.demo_when_nothing_restored()); assert!(!Startup::Demo.restores() && Startup::Demo.demo_when_nothing_restored()); } + +/// Only the explicit demo start stops saving the session. +#[test] +fn only_the_demo_start_leaves_the_saved_session_alone() { + assert!(Startup::Last.keeps_session() && Startup::Empty.keeps_session()); + assert!(!Startup::Demo.keeps_session()); +} diff --git a/docs/web.md b/docs/web.md index 3f9374caa..1cdecab00 100644 --- a/docs/web.md +++ b/docs/web.md @@ -255,7 +255,7 @@ By default the page opens the last visit's project, and the demo project on a fi |---|---| | `"last"` (or no key) | the last visit's project; the demo project on a first visit | | `"empty"` | the last visit's project; a blank project on a first visit, never the demo | -| `"demo"` | the demo project, whatever the last visit left | +| `"demo"` | the demo project, whatever the last visit left; the session is not saved, so the last visit's project stays as it was | The `?empty` and `?demo` URL flags still win. A missing, invalid or non-JSON file, or an unknown value, is the default. The file is fetched once per page load; serve it with diff --git a/packaging/web/README.md b/packaging/web/README.md index 6642b2b30..672d9ad67 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -67,9 +67,9 @@ To choose what the page opens on load, add an `effectcraft-config.json` next to ``` `"empty"` restores the last visit's project and otherwise starts blank (never the demo); `"demo"` -always opens the demo project; `"last"` (the default) restores the last visit's project and shows +always opens the demo project without saving the session (the last visit's project stays); `"last"` (the default) restores the last visit's project and shows the demo on a first visit. Serve the file with `Cache-Control: no-cache`. Details: -[docs/web.md](../../docs/web.md#startup-project). +[docs/web.md](https://github.com/storytold/effectcraft/blob/main/docs/web.md#startup-project). ## Embedding in a page (iframe) From 80d01f09d52b1f4eb29645380261709ab0699659 Mon Sep 17 00:00:00 2001 From: "G.J.R. Timmer" Date: Thu, 29 Oct 2026 10:57:00 -0400 Subject: [PATCH 16/30] fix(web): a demo startup keeps the session snapshot once the user opens or edits a project --- apps/effectcraft-web/src/app.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index bea794e82..5326c5ea5 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -82,8 +82,12 @@ pub(crate) fn open_or_import(app: &mut EffectcraftApp, paths: Vec) { /// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties. pub struct WebApp { pub app: EffectcraftApp, - /// Keep the session snapshot (off with `?empty` and an explicit `"demo"` startup). + /// Keep the session snapshot (off with `?empty`; off with an explicit `"demo"` startup until + /// the user's own work begins, see `demo_start`). pub persist: bool, + /// Started on the demo by the config: the snapshot stays off, so the demo never replaces the + /// last visit's project, until the user opens a project from a file or edits the demo. + pub demo_start: bool, } impl eframe::App for WebApp { @@ -116,6 +120,11 @@ impl eframe::App for WebApp { api::poll_replies(); // Render Queue outputs written this frame (here or by a worker) → downloads. files::flush_downloads(); + // The user's own work after a demo start is worth keeping too: turn the snapshot on. + if self.demo_start && (self.app.session.path.is_some() || self.app.session.is_dirty()) { + self.demo_start = false; + self.persist = true; + } // What a reload comes back to. if self.persist { // The app's monotonic clock: `ctx.input(|i| i.time)` is stale in a logic-only tick. @@ -224,7 +233,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { // `./effectcraft-config.json`: what to open on load. // `?empty` and `?demo` decide by themselves, so they skip the request. let startup = if empty || query_flag("demo") { Startup::default() } else { config_file::startup().await }; - // An explicit `"demo"` start leaves the saved session alone: the demo must not overwrite it. + // An explicit `"demo"` start leaves the saved session alone until the user opens or edits something. let keep_session = !empty && startup.keeps_session(); browse::restore(); diskcache::init(); @@ -291,7 +300,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { app = app.with_control(rx); let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend)); api::set_info("backend", json!(backend)); - Ok(Box::new(WebApp { app, persist: keep_session })) + Ok(Box::new(WebApp { app, persist: keep_session, demo_start: !empty && !keep_session })) }), ) .await?; From f262e39c639c2b8227dac2f95ca278d601c11809 Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Fri, 30 Oct 2026 00:31:37 +0900 Subject: [PATCH 17/30] fix(text): keep an empty Type-tool layer, as After Effects does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking away from a Type-tool layer without typing deleted it, so a click that was meant to place the text point lost the layer. AE keeps it in the timeline as <空白のテキストレイヤー>, and the layer came from the user's click rather than being a mistake to clean up. edit.deselectAll already routes through text.endEdit, so one change covers both paths. The tests that asserted the old cleanup now assert the layer survives. Co-Authored-By: DeepSeek V4.1 Flash --- crates/engine/src/cmd/edit.rs | 6 +++--- crates/engine/src/cmd/text_edit.rs | 18 +++++++----------- crates/engine/src/tests/text_edit.rs | 12 +++++------- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 0130aff70..9948d1a0c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -74,9 +74,9 @@ fn select_all(s: &mut Session, _: &Value) -> Result { Ok(json!(s.state.selected_layers.len())) } fn deselect_all(s: &mut Session, _: &Value) -> Result { - // A deselected layer never keeps an open text edit: commit the edit first, exactly as - // `text.endEdit` (an empty Type-tool layer included), so every route — menu, shortcut, - // script, MCP — leaves one final state. + // A deselected layer never keeps an open text edit: commit it first, exactly as `text.endEdit`, + // so every route — menu, shortcut, script, MCP — leaves one final state. An empty Type-tool + // layer survives, as it does in After Effects. super::text_edit::end_edit(s, &Value::Null)?; s.state.selected_layers.clear(); s.state.selected_props.clear(); diff --git a/crates/engine/src/cmd/text_edit.rs b/crates/engine/src/cmd/text_edit.rs index 0d9b25aec..ce1f6d831 100644 --- a/crates/engine/src/cmd/text_edit.rs +++ b/crates/engine/src/cmd/text_edit.rs @@ -372,18 +372,14 @@ fn edit(s: &mut Session, p: &Value) -> Result { Ok(state_json(s)) } -/// `text.endEdit`: stop editing (a Type-tool layer left empty is deleted). +/// `text.endEdit`: stop editing. +/// +/// A Type-tool layer the user clicked away from without typing is **kept**: After Effects leaves it in +/// the timeline as `<空白のテキストレイヤー>`, and the layer came from the user's click rather than from a +/// mistake to clean up. Deleting it took away something they could still type into. pub(crate) fn end_edit(s: &mut Session, _: &Value) -> Result { - let Some(e) = s.state.text_edit.take() else { return Ok(Value::Null) }; - if e.created && layer_doc(s, e.layer).is_some_and(|d| d.text.is_empty()) { - let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; - s.edit("Delete Empty Text Layer", None, |proj, st| { - if let Some(c) = proj.comp_mut(cid) { - c.layers.retain(|l| l.id != e.layer); - } - st.selected_layers.retain(|l| *l != e.layer); - Ok(()) - })?; + if s.state.text_edit.take().is_none() { + return Ok(Value::Null); } s.bump(); Ok(Value::Null) diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index 17d0345d8..ddab0d173 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -267,18 +267,18 @@ fn type_tool_layers_point_box_and_empty_cleanup() { assert_eq!(s.state.text_edit.as_ref().map(|e| e.layer.0), Some(t)); // Leaving an untouched Type-tool layer deletes it. s.execute("text.endEdit", json!({})).unwrap(); - assert!(s.active_comp().unwrap().layers.is_empty()); + assert_eq!(s.active_comp().unwrap().layers.len(), 1, "an empty Type-tool layer is kept, as in AE"); let b = s.execute("layer.newText", json!({"text": "", "box": [40, 50, 300, 120], "edit": true})).unwrap()["layer"].as_u64().unwrap(); s.execute("text.insert", json!({"text": "Boxed"})).unwrap(); let d = doc(&s, b); assert_eq!(d.box_size, Some([300.0, 120.0])); assert_eq!(d.box_pos, [-150.0, -60.0]); - let l = s.active_comp().unwrap().layers[0].clone(); + let l = s.active_comp().unwrap().layers.iter().find(|l| l.id.0 == b).expect("the boxed layer").clone(); assert_eq!(l.name, "Boxed", "named after its text"); let pos = l.props.prop("transform/position").unwrap().value.as_vec3(); assert_eq!([pos[0], pos[1]], [190.0, 110.0]); s.execute("text.endEdit", json!({})).unwrap(); - assert_eq!(s.active_comp().unwrap().layers.len(), 1); + assert_eq!(s.active_comp().unwrap().layers.len(), 2, "both text layers stay, the empty one included"); // Resizing the box (layer space) is an attribute too. s.execute("layer.setText", json!({"layer": b, "box": [-100, -60, 200, 120]})).unwrap(); assert_eq!(doc(&s, b).box_size, Some([200.0, 120.0])); @@ -1202,7 +1202,7 @@ fn deselect_all_ends_the_open_edit() { } #[test] -fn deselect_all_discards_an_empty_type_tool_layer() { +fn deselect_all_keeps_an_empty_type_tool_layer() { let (mut s, _keep) = session("Hello"); let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap(); assert!( @@ -1214,7 +1214,5 @@ fn deselect_all_discards_an_empty_type_tool_layer() { s.execute("edit.deselectAll", json!({})).unwrap(); assert!(s.state.text_edit.is_none()); assert!(s.state.selected_layers.is_empty()); - assert_eq!(count(&s), before - 1, "a Type-tool layer left empty is removed, as text.endEdit does"); - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(count(&s), before, "that removal is one undo step of its own, as on text.endEdit"); + assert_eq!(count(&s), before, "an empty Type-tool layer is kept, as in After Effects"); } From 197d6cd229dbb2658c5a6edea7b5249ce5b3d593 Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Fri, 30 Oct 2026 01:09:35 +0900 Subject: [PATCH 18/30] fix(text): the tests and docs follow the layer being kept Review of the first commit found three places that still asserted the old cleanup: the UI integration test (which would have failed CI), docs/agents.md, and the comments on TextEdit::created, layer.newText and the engine test itself. Co-Authored-By: DeepSeek V4.1 Flash --- crates/engine/src/cmd/layer/create.rs | 2 +- crates/engine/src/cmd/text_edit.rs | 3 ++- crates/engine/src/tests/text_edit.rs | 4 ++-- crates/ui-egui/tests/it/ui_text_edit.rs | 4 ++-- docs/agents.md | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 7962c2485..ccc4810a9 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -161,7 +161,7 @@ pub(super) fn text_range_p(p: &Value) -> Option> { /// It is left justified unless `justify` is given. The layer is named by `name`, else the first 40 /// characters of the first text line, else "Text". `edit: true` also opens a new editing /// session with anchor `0` and the caret after the text, selecting all initial text so the next -/// insertion replaces it. The session has `created: true`, so leaving it empty removes the layer. +/// insertion replaces it. The session has `created: true`; leaving it empty keeps the layer, as After Effects does. pub(super) fn new_text(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); diff --git a/crates/engine/src/cmd/text_edit.rs b/crates/engine/src/cmd/text_edit.rs index ce1f6d831..cdb650486 100644 --- a/crates/engine/src/cmd/text_edit.rs +++ b/crates/engine/src/cmd/text_edit.rs @@ -71,7 +71,8 @@ pub struct TextEdit { /// Character panel changes made with only a caret: the style the next typed text takes. #[serde(default)] pub pending: Option, - /// The layer was made by a Type tool click; leaving it empty deletes it. + /// The layer was made by a Type tool click. Kept when left empty, as After Effects does, so this + /// only records how the edit began. #[serde(default)] pub created: bool, /// Line position kept by repeated up / down moves (layout space). diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index ddab0d173..c48cff7e3 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -260,12 +260,12 @@ fn clipboard_and_paste_formatting() { } #[test] -fn type_tool_layers_point_box_and_empty_cleanup() { +fn type_tool_layers_point_box_and_empty_layer_is_kept() { let mut s = Session::default(); s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 4})).unwrap(); let t = s.execute("layer.newText", json!({"text": "", "position": [100, 200], "justify": "left", "edit": true})).unwrap()["layer"].as_u64().unwrap(); assert_eq!(s.state.text_edit.as_ref().map(|e| e.layer.0), Some(t)); - // Leaving an untouched Type-tool layer deletes it. + // Leaving an untouched Type-tool layer keeps it, as After Effects does. s.execute("text.endEdit", json!({})).unwrap(); assert_eq!(s.active_comp().unwrap().layers.len(), 1, "an empty Type-tool layer is kept, as in AE"); let b = s.execute("layer.newText", json!({"text": "", "box": [40, 50, 300, 120], "edit": true})).unwrap()["layer"].as_u64().unwrap(); diff --git a/crates/ui-egui/tests/it/ui_text_edit.rs b/crates/ui-egui/tests/it/ui_text_edit.rs index 84a218466..9f298ec19 100644 --- a/crates/ui-egui/tests/it/ui_text_edit.rs +++ b/crates/ui-egui/tests/it/ui_text_edit.rs @@ -171,7 +171,7 @@ fn type_tool_click_type_edit_and_commit() { /// #831: clicking another panel (a solid's row in the Timeline) ends text editing and gives the /// keyboard back, whether the edit began with the Type tool (which stays selected) or by command: /// typed characters change no text and Space plays. The Character panel keeps the edit, and a -/// text layer clicked into being and left empty goes away. +/// text layer clicked into being and left empty stays, as it does in After Effects. #[test] fn clicking_another_panel_stops_typing_into_text() { for type_tool in [false, true] { @@ -194,7 +194,7 @@ fn clicking_another_panel_stops_typing_into_text() { assert_eq!(layers(&h), 3, "the Type tool started a text layer"); let at_row = row(&h); click(&mut h, at_row, Modifiers::NONE); - assert_eq!(layers(&h), 2, "left empty, it is removed"); + assert_eq!(layers(&h), 3, "left empty, it stays, as in After Effects"); click(&mut h, at, Modifiers::NONE); } else { h.state_mut().session.execute("text.edit", json!({"layer": title})).unwrap(); diff --git a/docs/agents.md b/docs/agents.md index d2b691b18..60f2c9572 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -540,7 +540,7 @@ indices): character attributes 3. `layer.setText {"range":[6,11], "size":40, "fill":"#ff5500"}` styles the selection (the Character panel does the same); with an empty range it sets the style the next typed text takes. 4. `edit.copy`, `edit.paste`, `edit.pasteTextMatchFormatting` and `edit.pasteTextFormattingOnly` - work on the selected text; `text.endEdit` commits (an empty Type-tool layer is removed). + work on the selected text; `text.endEdit` commits (an empty Type-tool layer is kept, as After Effects does). Fonts: `text.fonts` (MCP `list_fonts`) lists every family a text layer can use, bundled and installed, with its styles, origin (`bundled`, `system`, `user`), `variable` (it has variation axes) and own-language name From 373112b38b274e84f5180a48cd1ca3512e16b5e5 Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Fri, 30 Oct 2026 01:26:44 +0900 Subject: [PATCH 19/30] i18n(ja): Paint and Generate must not draw the same category name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Generate row added here drew 描画, which Paint already used, so the Effect menu showed the same category name twice. 描画 is Adobe's name for Generate, so Paint becomes ペイント, and a test now rejects two categories sharing a Japanese name. Found by the review bot on this PR. Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/i18n/effects.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 29a850260..cbcc192da 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -496,7 +496,7 @@ const JAPANESE_CATEGORIES: &[(&str, &str)] = &[ ("Matte", "マット"), ("Noise & Grain", "ノイズ&グレイン"), ("Obsolete", "旧バージョン"), - ("Paint", "描画"), + ("Paint", "ペイント"), ("Perspective", "遠近"), ("Simulation", "シミュレーション"), ("Stylize", "スタイライズ"), @@ -1002,3 +1002,21 @@ mod tests { assert!(!matches("ec.blur.gaussian", "Gaussian Blur", "高斯")); } } + +#[cfg(test)] +mod japanese_category_tests { + use super::*; + use std::collections::HashMap; + + /// Two categories drawing the same Japanese name would make the Effect menu show one name twice and + /// leave the user unable to tell them apart. `描画` was both `Generate` and `Paint` before #2583. + #[test] + fn no_two_categories_share_a_japanese_name() { + let mut seen: HashMap<&str, &str> = HashMap::new(); + for (english, japanese) in JAPANESE_CATEGORIES { + if let Some(other) = seen.insert(japanese, english) { + panic!("`{english}` and `{other}` both draw as `{japanese}`"); + } + } + } +} From ac0188f315b5771ad718fe8f374a08b6980683c6 Mon Sep 17 00:00:00 2001 From: Kaedo17 Date: Sun, 25 Oct 2026 13:45:19 +0800 Subject: [PATCH 20/30] ci: name the missing ALSA package instead of dying at link time When the ALSA development package is missing, `cargo xtask ci`'s audio stage failed at link time with a bare linker error. The check now says which package installs ALSA's contents on each supported distro, the missing one is named in the error, and the stage's own tests pin both the present-ALSA pass and the per-distro message. --- CONTRIBUTING.md | 14 ++++++++++++ xtask/src/main.rs | 55 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfa28c9f0..16c969a72 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,20 @@ work apply to everyone, people and AI agents alike, and they are not negotiable. You need [Rust](https://rustup.rs/) 1.95 or newer. +On Linux the desktop app plays preview sound through `cpal`, so the build also needs the ALSA +development headers. Without them `alsa-sys` fails at link time with an unrelated-looking error; +`cargo xtask ci` checks for them up front and names the package: + +```sh +sudo dnf install alsa-lib-devel # Fedora +sudo apt install libasound2-dev # Debian / Ubuntu +sudo pacman -S alsa-lib # Arch +``` + +If you cannot install the package, point `PKG_CONFIG_PATH` at a directory holding an `alsa.pc` +whose `Libs` line names your system's `libasound` (usually `libasound.so.2`); every CI run needs +that variable set. + ```sh cargo run -p effectcraft -- --demo # the desktop app, with the demo project cargo test --workspace diff --git a/xtask/src/main.rs b/xtask/src/main.rs index da8026d78..e5c26cd2c 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -407,6 +407,40 @@ fn serve_dir(dir: &std::path::Path, port: u16) -> Result<(), String> { Ok(()) } +/// The guidance printed when the ALSA development package is missing. Split out so the test can +/// check the package names without needing a machine that is actually missing them. +fn audio_backend_hint(found: bool) -> Result<(), String> { + if found { + return Ok(()); + } + Err("Linux audio needs the ALSA development package, and pkg-config cannot find it.\n\ + The desktop app plays preview sound through cpal, whose alsa-sys dependency asks\n\ + pkg-config for `alsa`; without the package the build dies minutes later with an\n\ + unrelated-looking linker error.\n\ + \n\ + Fedora: sudo dnf install alsa-lib-devel\n\ + Debian/Ubuntu: sudo apt install libasound2-dev\n\ + Arch: sudo pacman -S alsa-lib\n\ + \n\ + Install one of those and run `cargo xtask ci` again. If you cannot install it, point\n\ + PKG_CONFIG_PATH at a directory holding an `alsa.pc` whose Libs line names your system's\n\ + libasound (usually `libasound.so.2`); every CI run will need that variable set." + .into()) +} + +/// `cargo xtask ci` builds the desktop app on the host, so on Linux it needs ALSA headers to be +/// present. Checking here turns a late, confusing link error into one line naming the package. +#[cfg(target_os = "linux")] +fn audio_backend() -> Result<(), String> { + let found = Command::new("pkg-config").args(["--exists", "alsa"]).status().map(|s| s.success()).unwrap_or(false); + audio_backend_hint(found) +} + +#[cfg(not(target_os = "linux"))] +fn audio_backend() -> Result<(), String> { + Ok(()) +} + /// Lib, bin and integration tests (examples aren't linked; clippy checked them), then doctests, /// which nextest doesn't run. /// @@ -438,7 +472,8 @@ type Stage = fn() -> Result<(), String>; /// Every stage runs even when an earlier one fails, so one run reports all the problems. fn ci() -> Result<(), String> { - let stages: [(&str, Stage); 7] = [ + let stages: [(&str, Stage); 8] = [ + ("audio backend", audio_backend), ("fmt", || run(Command::new(env!("CARGO")).args(["fmt", "--check"]))), ("clippy", || run(Command::new(env!("CARGO")).args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))), ("tests", tests), @@ -484,3 +519,21 @@ fn main() -> ExitCode { pub fn root() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf() } + +#[cfg(test)] +mod tests { + use super::audio_backend_hint; + + #[test] + fn alsa_present_is_not_an_error() { + assert!(audio_backend_hint(true).is_ok()); + } + + #[test] + fn alsa_missing_names_the_package_for_each_distro() { + let e = audio_backend_hint(false).expect_err("missing ALSA must fail CI"); + for want in ["alsa-lib-devel", "libasound2-dev", "alsa-lib", "pkg-config", "PKG_CONFIG_PATH"] { + assert!(e.contains(want), "hint lost `{want}`:\n{e}"); + } + } +} From ccc59cf55f8773391cc228f873eac601a4820c54 Mon Sep 17 00:00:00 2001 From: Kaedo17 Date: Sun, 25 Oct 2026 15:18:52 +0800 Subject: [PATCH 21/30] xtask: the ALSA probe reads PKG_CONFIG before PATH The probe ran pkg-config straight from PATH while the build's alsa-sys goes through the pkg-config crate, which reads PKG_CONFIG first: the variable may name a working pkg-config that is not on PATH, and then cargo xtask ci would report ALSA as missing over a build that finds it. The probe now selects the same executable the same way; no test stands over it because the probe needs a real pkg-config environment (the hint is split out for exactly that machine-dependence). --- xtask/src/main.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/xtask/src/main.rs b/xtask/src/main.rs index e5c26cd2c..7e136baa9 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -430,9 +430,12 @@ fn audio_backend_hint(found: bool) -> Result<(), String> { /// `cargo xtask ci` builds the desktop app on the host, so on Linux it needs ALSA headers to be /// present. Checking here turns a late, confusing link error into one line naming the package. +/// The probe reads `PKG_CONFIG` first, like the pkg-config crate alsa-sys goes through: the +/// variable may name a working pkg-config that is not on `PATH`, and the build would use it too. #[cfg(target_os = "linux")] fn audio_backend() -> Result<(), String> { - let found = Command::new("pkg-config").args(["--exists", "alsa"]).status().map(|s| s.success()).unwrap_or(false); + let program = std::env::var_os("PKG_CONFIG").unwrap_or_else(|| "pkg-config".into()); + let found = Command::new(program).args(["--exists", "alsa"]).status().map(|s| s.success()).unwrap_or(false); audio_backend_hint(found) } From 29e35794e19b9c832197cfdc485b372d181c7bd9 Mon Sep 17 00:00:00 2001 From: Kaedo17 Date: Sun, 25 Oct 2026 15:54:14 +0800 Subject: [PATCH 22/30] xtask: a pkg-config that will not launch is not 'ALSA missing' The probe's spawn failure (a PKG_CONFIG that names nothing runnable, or pkg-config absent from PATH) reported the ALSA-missing hint, sending the reader off to install alsa-lib they may well have: a failed launch now reports itself, naming the executable and pointing at installing pkg-config or correcting PKG_CONFIG, while a probe that runs and exits non-zero still gets the ALSA guidance. --- xtask/src/main.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 7e136baa9..47d40597f 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -432,11 +432,17 @@ fn audio_backend_hint(found: bool) -> Result<(), String> { /// present. Checking here turns a late, confusing link error into one line naming the package. /// The probe reads `PKG_CONFIG` first, like the pkg-config crate alsa-sys goes through: the /// variable may name a working pkg-config that is not on `PATH`, and the build would use it too. +/// A probe that could not launch is not "ALSA missing" — it reports the launch failure separately. #[cfg(target_os = "linux")] fn audio_backend() -> Result<(), String> { let program = std::env::var_os("PKG_CONFIG").unwrap_or_else(|| "pkg-config".into()); - let found = Command::new(program).args(["--exists", "alsa"]).status().map(|s| s.success()).unwrap_or(false); - audio_backend_hint(found) + match Command::new(&program).args(["--exists", "alsa"]).status() { + Ok(status) => audio_backend_hint(status.success()), + Err(e) => Err(format!( + "The {} executable could not be run: {e}. Install pkg-config, or point PKG_CONFIG at a working one — it is the same executable alsa-sys's pkg-config dependency asks.", + std::path::Path::new(&program).display() + )), + } } #[cfg(not(target_os = "linux"))] From ada2357a947eeedb441d2179034a2cca1cb6f0c5 Mon Sep 17 00:00:00 2001 From: Kaedo17 Date: Thu, 29 Oct 2026 16:30:22 +0800 Subject: [PATCH 23/30] review: the ALSA probe matches the pkg-config dependency's lookup CodeRabbit and FredApps both flagged that the preflight probed only the plain PKG_CONFIG variable, while the pkg-config crate alsa-sys pins resolves the target-scoped variants first and falls back pkg-config -> pkgconf; on a host with only pkgconf the ci stage failed a build that would succeed. The probe now goes through the same crate (0.3.34, already pinned via alsa-sys), so the semantics match by construction, and a probe that ran but said no stays distinct from one that could not run. Also from review: audio_backend_hint is gated to Linux (bflatastic: dead code on Windows/macOS under -D warnings), the fake-tool regression runs the real xtask audio-backend task in a child process with a controlled environment (pkgconf-only host, a probe that says no, one that cannot run, a HOST_PKG_CONFIG override), the rerun-if-env lines stay out of the ci log, and CONTRIBUTING.md asks for a usable alsa.pc and runtime library rather than development headers. --- CONTRIBUTING.md | 8 ++- Cargo.lock | 1 + xtask/Cargo.toml | 1 + xtask/src/main.rs | 69 +++++++++++-------- xtask/tests/it/audio_probe.rs | 126 ++++++++++++++++++++++++++++++++++ xtask/tests/it/main.rs | 3 + 6 files changed, 178 insertions(+), 30 deletions(-) create mode 100644 xtask/tests/it/audio_probe.rs create mode 100644 xtask/tests/it/main.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 16c969a72..8f6d8d462 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,9 +7,11 @@ work apply to everyone, people and AI agents alike, and they are not negotiable. You need [Rust](https://rustup.rs/) 1.95 or newer. -On Linux the desktop app plays preview sound through `cpal`, so the build also needs the ALSA -development headers. Without them `alsa-sys` fails at link time with an unrelated-looking error; -`cargo xtask ci` checks for them up front and names the package: +On Linux the desktop app plays preview sound through `cpal`, so the build needs a usable ALSA +pkg-config package: an `alsa.pc` that pkg-config can find, naming the linkable ALSA runtime +library (usually `libasound.so.2`). `alsa-sys` runs pkg-config at build time; it does not compile +ALSA's headers. Without a usable package the build fails at link time with an unrelated-looking +error; `cargo xtask ci` probes pkg-config up front and names the package: ```sh sudo dnf install alsa-lib-devel # Fedora diff --git a/Cargo.lock b/Cargo.lock index 52812471c..138147e00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7198,6 +7198,7 @@ name = "xtask" version = "0.18.0" dependencies = [ "chrono", + "pkg-config", "serde_json", "tempfile", ] diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index afc767de2..0f8072840 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -13,3 +13,4 @@ serde_json = { workspace = true } chrono = { workspace = true } tempfile = "3" +pkg-config = "0.3" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 47d40597f..ad03cada7 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -407,13 +407,12 @@ fn serve_dir(dir: &std::path::Path, port: u16) -> Result<(), String> { Ok(()) } -/// The guidance printed when the ALSA development package is missing. Split out so the test can +/// The guidance printed when the ALSA pkg-config package is missing. Split out so the tests can /// check the package names without needing a machine that is actually missing them. -fn audio_backend_hint(found: bool) -> Result<(), String> { - if found { - return Ok(()); - } - Err("Linux audio needs the ALSA development package, and pkg-config cannot find it.\n\ +#[cfg(target_os = "linux")] +fn audio_backend_missing_hint(pkg_config_stderr: Option<&str>) -> String { + let mut hint = String::from( + "Linux audio needs the ALSA development package, and pkg-config cannot find it.\n\ The desktop app plays preview sound through cpal, whose alsa-sys dependency asks\n\ pkg-config for `alsa`; without the package the build dies minutes later with an\n\ unrelated-looking linker error.\n\ @@ -424,23 +423,37 @@ fn audio_backend_hint(found: bool) -> Result<(), String> { \n\ Install one of those and run `cargo xtask ci` again. If you cannot install it, point\n\ PKG_CONFIG_PATH at a directory holding an `alsa.pc` whose Libs line names your system's\n\ - libasound (usually `libasound.so.2`); every CI run will need that variable set." - .into()) + libasound (usually `libasound.so.2`); every CI run will need that variable set.", + ); + if let Some(stderr) = pkg_config_stderr { + hint.push_str("\n\npkg-config said: "); + hint.push_str(stderr); + } + hint } -/// `cargo xtask ci` builds the desktop app on the host, so on Linux it needs ALSA headers to be -/// present. Checking here turns a late, confusing link error into one line naming the package. -/// The probe reads `PKG_CONFIG` first, like the pkg-config crate alsa-sys goes through: the -/// variable may name a working pkg-config that is not on `PATH`, and the build would use it too. -/// A probe that could not launch is not "ALSA missing" — it reports the launch failure separately. +/// `cargo xtask ci` builds the desktop app on the host, so on Linux it needs a usable ALSA +/// pkg-config package before the build dies minutes later at link time. The probe goes through +/// the same `pkg-config` crate alsa-sys's build script uses, so the executable and search-path +/// lookup match by construction: `PKG_CONFIG` and its target-scoped variants, then `pkg-config` +/// on `PATH` with a `pkgconf` fallback, plus the scoped `PKG_CONFIG_PATH`/`LIBDIR`/`SYSROOT_DIR`. +/// A probe that ran and said no is "ALSA missing"; one that could not run is reported separately, +/// so a machine with only `pkgconf` does not fail a build that would succeed. #[cfg(target_os = "linux")] fn audio_backend() -> Result<(), String> { - let program = std::env::var_os("PKG_CONFIG").unwrap_or_else(|| "pkg-config".into()); - match Command::new(&program).args(["--exists", "alsa"]).status() { - Ok(status) => audio_backend_hint(status.success()), + // cargo_metadata off: xtask is not a build script, so the crate's rerun-if-env lines would + // only clutter the ci log. + match pkg_config::Config::new().cargo_metadata(false).probe("alsa") { + Ok(_) => Ok(()), + Err(pkg_config::Error::ProbeFailure { output, .. }) | Err(pkg_config::Error::Failure { output, .. }) => { + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + Err(audio_backend_missing_hint((!stderr.is_empty()).then_some(stderr))) + } Err(e) => Err(format!( - "The {} executable could not be run: {e}. Install pkg-config, or point PKG_CONFIG at a working one — it is the same executable alsa-sys's pkg-config dependency asks.", - std::path::Path::new(&program).display() + "The pkg-config probe for `alsa` could not be run: {e}. Install pkg-config (or pkgconf), \ + or point PKG_CONFIG at a working one — it is the same lookup alsa-sys's pkg-config \ + dependency performs." )), } } @@ -503,6 +516,7 @@ fn main() -> ExitCode { "assets" => assets(), "pdf-deps" => pdf_deps::run(&root()), "ci" => ci(), + "audio-backend" => audio_backend(), "web" => web(&std::env::args().skip(2).collect::>()), "bench" => bench::run(&root(), &std::env::args().skip(2).collect::>()), "ico" => { @@ -513,7 +527,7 @@ fn main() -> ExitCode { let rest: Vec = std::env::args().skip(2).collect(); version::run(&root(), &rest.iter().map(String::as_str).collect::>()) } - _ => Err("usage: cargo xtask ".into()), + _ => Err("usage: cargo xtask ".into()), }; match r { Ok(()) => ExitCode::SUCCESS, @@ -529,20 +543,21 @@ pub fn root() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf() } -#[cfg(test)] +#[cfg(all(test, target_os = "linux"))] mod tests { - use super::audio_backend_hint; - - #[test] - fn alsa_present_is_not_an_error() { - assert!(audio_backend_hint(true).is_ok()); - } + use super::audio_backend_missing_hint; #[test] fn alsa_missing_names_the_package_for_each_distro() { - let e = audio_backend_hint(false).expect_err("missing ALSA must fail CI"); + let e = audio_backend_missing_hint(None); for want in ["alsa-lib-devel", "libasound2-dev", "alsa-lib", "pkg-config", "PKG_CONFIG_PATH"] { assert!(e.contains(want), "hint lost `{want}`:\n{e}"); } } + + #[test] + fn alsa_missing_appends_pkg_config_s_own_detail() { + let e = audio_backend_missing_hint(Some("Package 'alsa' not found")); + assert!(e.contains("pkg-config said: Package 'alsa' not found"), "detail lost:\n{e}"); + } } diff --git a/xtask/tests/it/audio_probe.rs b/xtask/tests/it/audio_probe.rs new file mode 100644 index 000000000..d984c7637 --- /dev/null +++ b/xtask/tests/it/audio_probe.rs @@ -0,0 +1,126 @@ +//! The Linux audio-backend preflight against fake pkg-config tools. +//! +//! The probe resolves its executable through the pkg-config crate, which reads the environment, +//! so the fake-tool scenarios run the real `xtask audio-backend` binary in a child process with a +//! controlled environment: `Command::env` changes only the child's environment, so the tests need +//! no global env mutation and stay parallel-safe. + +use std::ffi::OsStr; +use std::path::PathBuf; +use std::process::{Command, Output}; + +/// A scratch directory holding the fake tools for one scenario: `dir` is the child's `PATH`, +/// `off_path` holds executables reachable only by an explicit `*_PKG_CONFIG` override. +struct FakeTools { + dir: PathBuf, + off_path: PathBuf, +} + +impl FakeTools { + fn new(tag: &str) -> Self { + let base = std::env::temp_dir().join(format!("effectcraft-xtask-audio-{tag}-{}", std::process::id())); + let dir = base.join("bin"); + let off_path = base.join("off-path"); + std::fs::create_dir_all(&dir).expect("create PATH dir"); + std::fs::create_dir_all(&off_path).expect("create off-PATH dir"); + Self { dir, off_path } + } + + /// Write a plain (not executable) file. + fn file(&self, on_path: bool, name: &str, body: &str) -> PathBuf { + let path = if on_path { self.dir.clone() } else { self.off_path.clone() }.join(name); + std::fs::write(&path, body).expect("write fake tool"); + path + } + + /// Write an executable shell script. + fn script(&self, on_path: bool, name: &str, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let path = self.file(on_path, name, body); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod fake tool"); + path + } + + /// A fake `pkgconf` that answers as alsa 1.2.11 linking -lasound. + fn working_pkgconf(&self, on_path: bool) -> PathBuf { + self.script(on_path, "pkgconf", "#!/bin/sh\ncase \" $* \" in\n *\" --modversion \"*) echo 1.2.11 ;;\n *) echo -lasound ;;\nesac\n") + } +} + +impl Drop for FakeTools { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + let _ = std::fs::remove_dir_all(&self.off_path); + } +} + +/// Run the built `xtask audio-backend` with `dir` as the only `PATH`, the given variables set, +/// and every variable the probe reads otherwise cleared, so a developer's own environment +/// cannot leak into the scenario. +fn run_audio_backend(tools: &FakeTools, set: &[(&str, &OsStr)]) -> Output { + Command::new(env!("CARGO_BIN_EXE_xtask")) + .arg("audio-backend") + .env("PATH", &tools.dir) + .env_remove("PKG_CONFIG") + .env_remove("PKG_CONFIG_PATH") + .env_remove("PKG_CONFIG_LIBDIR") + .env_remove("PKG_CONFIG_SYSROOT_DIR") + .env_remove("PKG_CONFIG_ALLOW_CROSS") + .env_remove("ALSA_NO_PKG_CONFIG") + .env_remove("HOST_PKG_CONFIG") + .env_remove("TARGET_PKG_CONFIG") + .env_remove("HOST") + .env_remove("TARGET") + .envs(set.iter().copied()) + .output() + .expect("run xtask audio-backend") +} + +#[test] +fn a_host_with_only_pkgconf_still_finds_alsa() { + // The reviewed case: PKG_CONFIG unset, no pkg-config on PATH, only pkgconf. The pkg-config + // crate alsa-sys uses falls back to pkgconf, so the stage must stay green. + let tools = FakeTools::new("pkgconf-only"); + tools.working_pkgconf(true); + let out = run_audio_backend(&tools, &[]); + assert!(out.status.success(), "pkgconf-only host must pass:\n{}", String::from_utf8_lossy(&out.stderr)); +} + +#[test] +fn a_probe_that_runs_and_says_no_names_the_package() { + let tools = FakeTools::new("says-no"); + tools.script(true, "pkg-config", "#!/bin/sh\necho 'Package alsa was not found' >&2\nexit 1\n"); + let out = run_audio_backend(&tools, &[]); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + for want in ["alsa-lib-devel", "libasound2-dev", "alsa-lib", "pkg-config"] { + assert!(stderr.contains(want), "hint lost `{want}`:\n{stderr}"); + } + assert!(stderr.contains("not found"), "pkg-config's own detail should be appended:\n{stderr}"); +} + +#[test] +fn a_probe_that_cannot_run_is_not_alsa_missing() { + // PKG_CONFIG names a file that is not an executable; with PKG_CONFIG set the crate does not + // fall back to pkgconf, so the lookup fails to launch — reported separately from "missing". + let tools = FakeTools::new("cannot-run"); + let broken = tools.file(true, "not-an-executable", "this is not a program\n"); + let out = run_audio_backend(&tools, &[("PKG_CONFIG", broken.as_os_str())]); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("could not"), "expected the launch-failure error:\n{stderr}"); + assert!(!stderr.contains("alsa-lib-devel"), "must not read as ALSA missing:\n{stderr}"); +} + +#[test] +fn a_host_scoped_override_selects_that_executable() { + // With TARGET and HOST set (build-script style), HOST_PKG_CONFIG wins over the PATH lookup, + // and here it is the only working tool — so success proves the override was used. + let tools = FakeTools::new("scoped"); + let good = tools.working_pkgconf(false); + let out = run_audio_backend( + &tools, + &[("HOST", OsStr::new("x86_64-unknown-linux-gnu")), ("TARGET", OsStr::new("x86_64-unknown-linux-gnu")), ("HOST_PKG_CONFIG", good.as_os_str())], + ); + assert!(out.status.success(), "HOST_PKG_CONFIG override must be honored:\n{}", String::from_utf8_lossy(&out.stderr)); +} diff --git a/xtask/tests/it/main.rs b/xtask/tests/it/main.rs new file mode 100644 index 000000000..1f53da223 --- /dev/null +++ b/xtask/tests/it/main.rs @@ -0,0 +1,3 @@ +//! xtask's integration tests: one binary, modules in `tests/it/`. + +mod audio_probe; From e1861419045376b13d17440bb0b0f6b585ca95dc Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Thu, 29 Oct 2026 19:37:43 +0100 Subject: [PATCH 24/30] #2465: start Linear preset regression with Bezier endpoint sides --- crates/script/src/tests/ease_presets.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/script/src/tests/ease_presets.rs b/crates/script/src/tests/ease_presets.rs index e9a9c8a0a..f075250b2 100644 --- a/crates/script/src/tests/ease_presets.rs +++ b/crates/script/src/tests/ease_presets.rs @@ -610,7 +610,7 @@ fn presets_saved_by_the_old_core_panel_carry_over() { } /// #2465: the Linear preset sets Linear interpolation on every side it applies to, including the -/// first key's out side and the last key's in side; editing its speeds makes a Bezier curve. +/// unused first-in and last-out sides; editing its speeds makes a Bezier curve. #[test] fn the_linear_preset_sets_linear_interpolation_and_edited_speeds_give_bezier() { let store = Arc::new(MemoryConfig::default()); @@ -620,9 +620,15 @@ fn the_linear_preset_sets_linear_interpolation_and_edited_speeds_give_bezier() { s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap(); let win = open_panel(&mut s); // Start from Bezier keys so Linear has something to restore. - set(&mut s, win, "presets", json!("Ease In-Out")); + ok( + &mut s, + r#"var p = app.project.activeItem.layer(1).transform.opacity, K = KeyframeInterpolationType; + for (var i = 1; i <= p.numKeys; i++) p.setInterpolationTypeAtKey(i, K.BEZIER, K.BEZIER);"#, + ); let op = prop(&s, l, "transform/opacity"); - assert!(op.keys[0].out_interp == Interp::Bezier && op.keys[2].in_interp == Interp::Bezier); + for (i, k) in op.keys.iter().enumerate() { + assert_eq!((k.in_interp, k.out_interp), (Interp::Bezier, Interp::Bezier), "initial key {i}"); + } let steps = s.history.undo.len(); set(&mut s, win, "presets", json!("Linear")); assert_eq!(s.history.undo.len(), steps + 1, "one undo step"); From 07639b5f4bc077b5d9e622506e1d6e1bd83fe925 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:05:27 +0100 Subject: [PATCH 25/30] Settings: Save Compact Project Files writes .ecproj as one-line JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings ▸ Project ▸ Project Files ▸ Save Compact Project Files (off by default) saves .ecproj files as compact JSON: smaller, faster to save and open. Off, they stay indented, one value per line, so they read and diff cleanly in version control. XML copies are not affected; auto-saves were already compact. The project.ecproj inside exported template and Motion Graphics template packages is written compact. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/app_more.rs | 2 +- crates/engine/src/cmd/essential.rs | 2 +- crates/engine/src/cmd/file.rs | 3 ++- crates/engine/src/home/templates.rs | 6 ++++-- crates/engine/src/settings/prefs/dialog.rs | 5 +++++ crates/engine/src/settings/prefs/sections.rs | 3 +++ crates/engine/src/tests/settings.rs | 17 +++++++++++++++++ crates/project/src/item.rs | 10 ++++++---- crates/project/src/tests.rs | 4 ++-- docs/preferences.md | 1 + 10 files changed, 42 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/app_more.rs b/crates/engine/src/cmd/app_more.rs index f607c8288..5bd897ba9 100644 --- a/crates/engine/src/cmd/app_more.rs +++ b/crates/engine/src/cmd/app_more.rs @@ -152,7 +152,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result { fn save_copy_xml(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?; - let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view())?).map_err(EngineError::Other)?; + let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view(), false)?).map_err(EngineError::Other)?; s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; s.toast(format!("Saved an XML copy to {path}")); Ok(json!({"path": path, "bytes": xml.len()})) diff --git a/crates/engine/src/cmd/essential.rs b/crates/engine/src/cmd/essential.rs index cb6702660..819c905c9 100644 --- a/crates/engine/src/cmd/essential.rs +++ b/crates/engine/src/cmd/essential.rs @@ -865,7 +865,7 @@ fn export_template(s: &mut Session, p: &Value) -> Result { { entries.push(("poster.png".into(), png)); } - entries.insert(0, ("project.ecproj".into(), sub.to_json().into_bytes())); + entries.insert(0, ("project.ecproj".into(), sub.to_json_compact().into_bytes())); entries.insert(0, ("manifest.json".into(), serde_json::to_vec_pretty(&manifest).unwrap_or_default())); let bytes = effectcraft_lottie::zip::store(&entries); s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index b3922c7d6..3d9112b2b 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -87,7 +87,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { /// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project /// wouldn't read back. pub(crate) fn file_text(s: &Session, path: &str) -> Result { - let json = s.project.to_file_json_with_view(&s.saved_view())?; + let compact = s.prefs.project.compact_project_files && !crate::xml_project::is_xml_path(path); + let json = s.project.to_file_json_with_view(&s.saved_view(), compact)?; if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) } } diff --git a/crates/engine/src/home/templates.rs b/crates/engine/src/home/templates.rs index 0b15baaf5..7d4a578ad 100644 --- a/crates/engine/src/home/templates.rs +++ b/crates/engine/src/home/templates.rs @@ -893,8 +893,10 @@ fn save_as(s: &mut Session, p: &Value) -> Result { manifest["linkedFootage"] = json!(e.skipped); } let t = s.time_of(cid); - let mut entries: Vec<(String, Vec)> = - vec![("manifest.json".into(), serde_json::to_vec_pretty(&manifest).unwrap_or_default()), ("project.ecproj".into(), proj.to_json().into_bytes())]; + let mut entries: Vec<(String, Vec)> = vec![ + ("manifest.json".into(), serde_json::to_vec_pretty(&manifest).unwrap_or_default()), + ("project.ecproj".into(), proj.to_json_compact().into_bytes()), + ]; entries.extend(media_entries); if let Ok((w, h, rgba)) = s.render_rgba8(cid, t, 320) { if let Ok(png) = crate::commands::comp_more::encode_png(&rgba, w, h) { diff --git a/crates/engine/src/settings/prefs/dialog.rs b/crates/engine/src/settings/prefs/dialog.rs index e3f2006d8..d330ce190 100644 --- a/crates/engine/src/settings/prefs/dialog.rs +++ b/crates/engine/src/settings/prefs/dialog.rs @@ -122,6 +122,11 @@ pub fn pages() -> Vec { Section("New Project"), s("project.useTemplate", "New Project Loads Template", B, true), s("project.templatePath", "Template Project", Kind::Path, true), + Section("Project Files"), + s("project.compactProjectFiles", "Save Compact Project Files", B, true), + Note( + "Saves .ecproj files as compact JSON on one line: smaller and faster to save and open, but hard to read or diff in version control. Off, they are indented, one value per line.", + ), Section("Auto-Save"), s("autoSave.enabled", "Automatically Save Projects", B, true), s("autoSave.intervalMinutes", "Save Every", Kind::Int(1, 240, "Minutes"), true), diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index e0bea8b85..aa683e2b8 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -62,6 +62,9 @@ page!(ProjectPrefs { /// Open a template project for File ▸ New ▸ New Project. use_template: bool = false, template_path: String = String::new(), + /// Write `.ecproj` files as compact JSON on one line (smaller, faster to save and open). Off: + /// indented, one value per line, so they read and diff cleanly in version control. + compact_project_files: bool = false, }); page!(AutoSave { diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index 81bed3c43..03003a77b 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -389,6 +389,23 @@ fn new_project_template_and_default_renderer() { let _ = std::fs::remove_dir_all(d); } +#[test] +fn project_files_are_indented_unless_compact_is_on() { + let d = tmp("compact-project-files"); + let path = d.join("p.ecproj"); + let mut s = session_with_comp(); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let pretty = std::fs::read_to_string(&path).unwrap(); + assert!(pretty.starts_with("{\n \"savedBy\""), "indented by default"); + s.execute("prefs.set", json!({"key": "project.compactProjectFiles", "value": true})).unwrap(); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let compact = std::fs::read_to_string(&path).unwrap(); + assert!(!compact.contains('\n'), "one line when Save Compact Project Files is on"); + let open = |text: &str| effectcraft_project::Project::from_json(text).unwrap(); + assert_eq!(open(&compact), open(&pretty), "both read back as the same project"); + let _ = std::fs::remove_dir_all(d); +} + // ---------------------------------------------------------------- shortcuts #[test] diff --git a/crates/project/src/item.rs b/crates/project/src/item.rs index 22759e27d..b2b64ed2c 100644 --- a/crates/project/src/item.rs +++ b/crates/project/src/item.rs @@ -267,10 +267,11 @@ impl Project { /// (NaN or infinity, written as `null`, and nesting too deep to parse) are refused on every /// save; debug builds also read the text back. pub fn to_file_json(&self) -> Result { - self.to_file_json_with_view(&SavedView::default()) + self.to_file_json_with_view(&SavedView::default(), false) } - /// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]). - pub fn to_file_json_with_view(&self, view: &SavedView) -> Result { + /// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]), on one + /// line when `compact` (Settings ▸ Project ▸ Save Compact Project Files). + pub fn to_file_json_with_view(&self, view: &SavedView, compact: bool) -> Result { if self.schema > SCHEMA_VERSION { return Err(ProjectError::Invalid(format!( "the project can't be saved: it holds a value a project file can't store (project schema {} is newer than this version supports ({SCHEMA_VERSION}))", @@ -289,7 +290,8 @@ impl Project { let file = File { saved_by: APP_VERSION, view, project: self }; file_check::check(&file) .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; - let text = serde_json::to_string_pretty(&file).map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; + let text = if compact { serde_json::to_string(&file) } else { serde_json::to_string_pretty(&file) } + .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; #[cfg(debug_assertions)] Project::from_json(&text) .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index f9ee16167..c0bdb012f 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -183,7 +183,7 @@ fn project_files_refuse_newer_schemas() { crate::SCHEMA_VERSION ); assert_eq!(p.to_file_json().unwrap_err().to_string(), expected); - assert_eq!(p.to_file_json_with_view(&crate::SavedView::default()).unwrap_err().to_string(), expected); + assert_eq!(p.to_file_json_with_view(&crate::SavedView::default(), false).unwrap_err().to_string(), expected); } #[test] @@ -197,7 +197,7 @@ fn saved_files_reopen_in_pretty_and_compact_form() { fn project_files_remember_the_open_comps() { let (p, cid) = project_with_layer(); let view = crate::SavedView { active_comp: Some(cid), open_comps: vec![cid], comp_times: [(cid, effectcraft_time::Tick(4_237_833_600))].into() }; - let text = p.to_file_json_with_view(&view).unwrap(); + let text = p.to_file_json_with_view(&view, false).unwrap(); assert_eq!(crate::saved_view(&text), view); assert_eq!(Project::from_json(&text).unwrap(), p, "the view is not part of the project"); // Files from before #748 have no view; a garbled one is ignored, not an error. diff --git a/docs/preferences.md b/docs/preferences.md index ce96da0bf..2402b9a32 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -42,6 +42,7 @@ Commands (CLI, MCP, control channel): - `startup.windowGraphics`: Window Graphics (`auto` / `gl`), what the desktop window draws with from the next launch. `auto` lets the platform pick (DirectX 12, Vulkan or Metal); `gl` uses OpenGL, with CPU compositing, for graphics drivers that crash with the others. A launch leaves a `launch-pending` marker in the settings folder until its window has been drawing for a few seconds (or the app quits normally before that); a launch that stops earlier (a graphics driver crashing as the window opens or as its surface is re-created) leaves the marker behind, and the next launch switches to `gl` and says so (not on macOS, which has no OpenGL backend). `WGPU_BACKEND` overrides both. - `project.useTemplate`: New Project Loads Template - `project.templatePath`: Template Project +- `project.compactProjectFiles`: Save Compact Project Files (default off). Off: File ▸ Save writes `.ecproj` files as indented JSON, one value per line, easy to read and diff in version control. On: compact JSON on one line, smaller and faster to save and open. Either opens the same; auto-saves, XML copies and template packages are not affected. - `autoSave.enabled`: Automatically Save Projects - `autoSave.intervalMinutes`: Save Every - `autoSave.maxVersions`: Maximum Project Versions From 31a2716a19e2db70ea1691b61487286ef2d7cee3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 03:40:12 +0700 Subject: [PATCH 26/30] Templates: packages keep indented project JSON, as the docs say; only Save Compact applies Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/essential.rs | 2 +- crates/engine/src/home/templates.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/essential.rs b/crates/engine/src/cmd/essential.rs index 819c905c9..cb6702660 100644 --- a/crates/engine/src/cmd/essential.rs +++ b/crates/engine/src/cmd/essential.rs @@ -865,7 +865,7 @@ fn export_template(s: &mut Session, p: &Value) -> Result { { entries.push(("poster.png".into(), png)); } - entries.insert(0, ("project.ecproj".into(), sub.to_json_compact().into_bytes())); + entries.insert(0, ("project.ecproj".into(), sub.to_json().into_bytes())); entries.insert(0, ("manifest.json".into(), serde_json::to_vec_pretty(&manifest).unwrap_or_default())); let bytes = effectcraft_lottie::zip::store(&entries); s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; diff --git a/crates/engine/src/home/templates.rs b/crates/engine/src/home/templates.rs index 7d4a578ad..b668e3b6f 100644 --- a/crates/engine/src/home/templates.rs +++ b/crates/engine/src/home/templates.rs @@ -895,7 +895,7 @@ fn save_as(s: &mut Session, p: &Value) -> Result { let t = s.time_of(cid); let mut entries: Vec<(String, Vec)> = vec![ ("manifest.json".into(), serde_json::to_vec_pretty(&manifest).unwrap_or_default()), - ("project.ecproj".into(), proj.to_json_compact().into_bytes()), + ("project.ecproj".into(), proj.to_json().into_bytes()), ]; entries.extend(media_entries); if let Ok((w, h, rgba)) = s.render_rgba8(cid, t, 320) { From aa97fcbee8d9ac145d3fb3431d34fb5478525eb8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 03:40:14 +0700 Subject: [PATCH 27/30] xtask tests: the ALSA probe test runs on Linux only, where the stage does Co-Authored-By: Claude Opus 5.5 --- xtask/tests/it/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/xtask/tests/it/main.rs b/xtask/tests/it/main.rs index 1f53da223..f2c41c738 100644 --- a/xtask/tests/it/main.rs +++ b/xtask/tests/it/main.rs @@ -1,3 +1,4 @@ //! xtask's integration tests: one binary, modules in `tests/it/`. +#[cfg(target_os = "linux")] mod audio_probe; From bb7fda41bb54684e7d341f5781f711ba85ed66b6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 03:40:17 +0700 Subject: [PATCH 28/30] Text tests: drop the tracking test #1822 already landed on main Co-Authored-By: Claude Opus 5.5 --- crates/render/src/text/tests.rs | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index d17bfbdb3..4bf459142 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -249,29 +249,6 @@ fn tracking_line_anchor_and_character_offset() { assert_eq!(substitute('a', &CharXf { char_value: Some((66.0, 1.0)), ..Default::default() }), Some('B')); } -/// After Effects 2026: a text animator's Tracking Amount is in pixels: Amount 30 on every -/// character of an 80 px and of a 40 px text adds 30 px between neighbouring characters (half -/// before, half after each one). -#[test] -fn animator_tracking_is_in_pixels_as_in_after_effects() { - for size in [80.0, 40.0] { - let x_of = |amount: f64| { - let (mut p, cid, comp) = setup(900, 200); - let mut l = text_layer(&mut p, &comp, doc("HHHH", size)); - animator(&mut p, &mut l, &["tracking"], &["range"]); - set(&mut l, "text/animators/#1/properties/tracking", Value::Scalar(amount)); - p.comp_mut(cid).unwrap().layers.push(l); - let paths = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]); - paths.iter().map(|g| bounds_of(std::slice::from_ref(g)).x0).collect::>() - }; - let (a, b) = (x_of(0.0), x_of(30.0)); - for i in 1..a.len() { - let gap = (b[i] - b[i - 1]) - (a[i] - a[i - 1]); - assert!((gap - 30.0).abs() < 0.5, "{size} px: neighbour gap grows by {gap}"); - } - } -} - /// Without a Line Anchor, tracking grows each line from where its paragraph's alignment pins it: /// left text grows right, centred text around its centre, right text to the left (#146). #[test] From 054d884cdfcee7ce132db39e61d46e6f3e2539b7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 03:41:14 +0700 Subject: [PATCH 29/30] Templates: rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/home/templates.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/home/templates.rs b/crates/engine/src/home/templates.rs index b668e3b6f..0b15baaf5 100644 --- a/crates/engine/src/home/templates.rs +++ b/crates/engine/src/home/templates.rs @@ -893,10 +893,8 @@ fn save_as(s: &mut Session, p: &Value) -> Result { manifest["linkedFootage"] = json!(e.skipped); } let t = s.time_of(cid); - let mut entries: Vec<(String, Vec)> = vec![ - ("manifest.json".into(), serde_json::to_vec_pretty(&manifest).unwrap_or_default()), - ("project.ecproj".into(), proj.to_json().into_bytes()), - ]; + let mut entries: Vec<(String, Vec)> = + vec![("manifest.json".into(), serde_json::to_vec_pretty(&manifest).unwrap_or_default()), ("project.ecproj".into(), proj.to_json().into_bytes())]; entries.extend(media_entries); if let Ok((w, h, rgba)) = s.render_rgba8(cid, t, 320) { if let Ok(png) = crate::commands::comp_more::encode_png(&rgba, w, h) { From 4c787734fdfc817eb05f4603aef756d284bf4e1d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 04:04:04 +0700 Subject: [PATCH 30/30] Tests: QA scenarios ask for image sequences explicitly (#2018) Since #2604 a picked numbered still imports as itself unless the import asks for a sequence, as in After Effects. The compositing and tracking QA scenarios import a PNG run, so they pass `sequence: true`; docs/agents.md describes the new default. Co-Authored-By: Claude Opus 5.5 --- crates/automation/tests/qa/compositing.rs | 2 +- crates/automation/tests/qa/tracking.rs | 2 +- docs/agents.md | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/automation/tests/qa/compositing.rs b/crates/automation/tests/qa/compositing.rs index da78ae116..d5de8f687 100644 --- a/crates/automation/tests/qa/compositing.rs +++ b/crates/automation/tests/qa/compositing.rs @@ -35,7 +35,7 @@ fn import_footage(qa: &mut Qa) -> (Value, Value) { // Footage: the sequence, and a layered PSD written by the app itself (Save Frame As ▸ // Photoshop Layers) from a small comp. let seq = write_sequence(qa); - let imp = qa.exec("file.import", json!({"paths": [seq]})); + let imp = qa.exec("file.import", json!({"paths": [seq], "sequence": true})); let seq_item = imp["items"][0].clone(); let project = qa.tool("get_project", json!({})); let it = project["items"].as_array().unwrap().iter().find(|i| i["id"] == seq_item).unwrap().clone(); diff --git a/crates/automation/tests/qa/tracking.rs b/crates/automation/tests/qa/tracking.rs index 837872658..ee6bbb2e9 100644 --- a/crates/automation/tests/qa/tracking.rs +++ b/crates/automation/tests/qa/tracking.rs @@ -52,7 +52,7 @@ fn shake_offset(f: u32) -> (i64, i64) { /// Import a sequence at 12 fps and make a comp of it; returns (comp, layer). fn plate_comp(qa: &mut Qa, first: &str, name: &str) -> (Value, Value) { - let item = qa.exec("file.import", json!({"paths": [first]}))["items"][0].clone(); + let item = qa.exec("file.import", json!({"paths": [first], "sequence": true}))["items"][0].clone(); qa.exec("file.interpretFootage", json!({"items": [item.clone()], "frameRate": 12})); let comp = qa.exec("comp.new", json!({"name": name, "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone(); let layer = qa.exec("layer.addItem", json!({"item": item}))["layer"].clone(); diff --git a/docs/agents.md b/docs/agents.md index d84442a84..9ba2eed44 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -272,10 +272,10 @@ build real projects through these interfaces; they are worked examples of everyt then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`. * **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or `{"colors":[[0,"#0080ff"],[1,[1,1,0]]],"opacities":[[0,1],[1,0.5]]}`. -* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"]}` imports the file's whole - numbered run as one item (several picked frames: that range; a folder: each run in it; - `"sequence": false`: one still; dropped files, `"drag": true`, import as stills unless - `"sequence": true`, as in After Effects; `"alphabetical": true`: Force Alphabetical Order, the files +* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"], "sequence": true}` imports the + file's whole numbered run as one item (several picked frames: that range; a folder: each run in + it, also without `sequence`). Without `"sequence": true` a picked or dropped numbered file imports + as one still, as in After Effects; `"alphabetical": true`: Force Alphabetical Order, the files play one after another) at `frameRate` or 30 fps (Settings ▸ Import). Missing frames show colour bars, as in After Effects. `file.interpretFootage {"items":[id], "frameRate":12, "startTimecode":1001}` conforms them and sets Override Start (the item and layers that ran to its end get the new