From 9b7f8cf005a9055538b992889e27c0eee49fcc89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:30:54 +0000 Subject: [PATCH] Effects: Write-on draws a stroke along the animated brush, honouring Stroke Length and Brush Spacing (#536) Cause: write_on_plan drew a single dab at the current Brush Position. Stroke Length, Brush Spacing and the Paint/Brush Time Properties were declared but unused, so an animated brush showed a moving dot that dropped every earlier mark. Fix: as After Effects describes Write-on, a dab is laid every Brush Spacing seconds from the layer's start up to the current time, each lasting Stroke Length seconds (0: for ever). Their positions come from the host's parameters at their times (EffectHost::params_at, which the renderer implements). Paint Time Properties > Opacity and Brush Time Properties > Size (or Size & Hardness) keep each dab's own opacity / size; otherwise the current values apply to all. One frame draws at most 4096 dabs (longer strokes space them out and join them). Without a host the current dab draws, as before. Colour and hardness stay one per stroke (the paint plan has one colour and hardness), so Paint Time Properties > Color and per-dab hardness aren't modelled. The GPU path uses the same plan. Test: write_on_accumulates_a_stroke_along_the_animated_brush (the report's fixture: start and midpoint marks stay at 0.5 s with the end still clear, all marks at 1 s, Stroke Length 0.25 keeps only the recent trail, Brush Spacing 0.5 gives three separate marks, per-dab opacity). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f (cherry picked from commit c82c9aeb685eab0cb36ca69c0b337781f4bad258) --- crates/effects/src/generate3.rs | 128 ++++++++++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 8 deletions(-) diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs index 926846a9..eaa92411 100644 --- a/crates/effects/src/generate3.rs +++ b/crates/effects/src/generate3.rs @@ -15,7 +15,7 @@ use effectcraft_raster::{Image, Px}; use rayon::prelude::*; use crate::util::{Plane, gauss_plane, hash1, layer_or_self, morph_frac, poly_length, premul, smoothstep, unpremul}; -use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; +use crate::{Buf, EffectCtx, EffectSpec, Params, col, num, p, popup, slider}; fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { EffectSpec { id, name, category: "Generate", params, render, gpu: false, float: true } @@ -1074,23 +1074,66 @@ fn vegas_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { // ---------------------------------------------------------------- Write-on / Glue Gun -/// Brush dab history: only the brush position at the current time is known to an effect -/// (parameter histories are not passed in), so the stroke is drawn as the dabs laid along -/// the current position — a single dab when the brush is static. Deterministic. +/// Write-on draws a stroke along the animated Brush Position, as in After Effects: a dab every +/// Brush Spacing seconds from the layer's start up to now, each one lasting Stroke Length +/// seconds (0: for ever). The dabs' positions come from the host's parameters at their times; +/// with Paint Time Properties ▸ Opacity or Brush Time Properties ▸ Size each dab keeps its own +/// opacity or size, else the current ones apply to every dab. (Colour and hardness are one per +/// stroke: the current ones.) Without a host (or dabs) the current dab draws. Deterministic. fn write_on(ctx: &EffectCtx, mut b: Buf) -> Buf { let plan = write_on_plan(ctx, &b); draw_paint(&mut b, &plan); b } +/// The most dabs one Write-on frame draws; longer strokes lay them further apart, joined. +const WRITE_ON_MAX_DABS: i64 = 4096; + fn write_on_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { let pr = ctx.params; - let q = b.to_px(pr.v2("brushPosition")); - let r = (pr.f("brushSize") * b.scale * 0.5).max(0.05); + let own_opacity = pr.e("paintTimeProperties") == 1; + let own_size = matches!(pr.e("brushTimeProperties"), 1 | 3); + let opacity = |p: &Params| (p.f("brushOpacity") / 100.0).clamp(0.0, 1.0) as f32; + let radius = |p: &Params| (p.f("brushSize") * b.scale * 0.5).max(0.05); + let dab = |p: &Params| { + let q = b.to_px(p.v2("brushPosition")); + ([q.0, q.1], radius(if own_size { p } else { pr }), if own_opacity { opacity(p) } else { 1.0 }) + }; + let mut segs = vec![]; + let t = ctx.time; + let spacing = pr.f("brushSpacing").max(0.001); + let life = pr.f("strokeLength").max(0.0); + if let Some(host) = ctx.env.host + && t.is_finite() + && t >= 0.0 + { + let start = if life > 0.0 { (t - life).max(0.0) } else { 0.0 }; + let k0 = (start / spacing - 1e-9).ceil().max(0.0) as i64; + let k1 = (t / spacing + 1e-9).floor().max(0.0) as i64; + let n = k1.saturating_sub(k0).saturating_add(1); + let stride = (n / WRITE_ON_MAX_DABS).saturating_add(1).max(1); + let mut prev: Option<[f64; 2]> = None; + let mut k = k0; + while k <= k1 { + let tk = k as f64 * spacing; + let at = if (tk - t).abs() < 1e-9 { Some(dab(pr)) } else { host.params_at(tk).map(|p| dab(&p)) }; + if let Some((q, r, v)) = at { + // Dabs laid further apart than the brush spacing are joined into a line. + let a = if stride > 1 { prev.unwrap_or(q) } else { q }; + segs.push(Seg { a, b: q, r, v }); + prev = Some(q); + } + k = k.saturating_add(stride); + } + } + if segs.is_empty() { + let (q, r, v) = dab(pr); + segs.push(Seg { a: q, b: q, r, v }); + } PaintPlan { - cov: Coverage::Segs { segs: vec![Seg { a: [q.0, q.1], b: [q.0, q.1], r, v: 1.0 }], hardness: pr.f("brushHardness") / 100.0 }, + cov: Coverage::Segs { segs, hardness: pr.f("brushHardness") / 100.0 }, color: pr.color("color"), - opacity: (pr.f("brushOpacity") / 100.0) as f32, + opacity: if own_opacity { 1.0 } else { opacity(pr) }, style: pr.e("paintStyle"), } } @@ -2204,6 +2247,75 @@ mod tests { assert_eq!(g.img.get(0, 0), src.get(0, 0)); } + /// #536: an animated Brush Position draws a stroke that stays (Stroke Length 0), expires + /// after Stroke Length, and lays its dabs Brush Spacing seconds apart. + #[test] + fn write_on_accumulates_a_stroke_along_the_animated_brush() { + // Brush Position from [8, 16] at 0 s to [56, 16] at 1 s; red, hard, 6 px. + struct Brush(Vec<(&'static str, Value)>); + impl Brush { + fn at(&self, t: f64) -> Vec<(&'static str, Value)> { + let mut v = self.0.clone(); + v.push(("brushPosition", Value::Vec2([8.0 + 48.0 * t.clamp(0.0, 1.0), 16.0]))); + v + } + } + impl EffectHost for Brush { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn params_at(&self, t: f64) -> Option { + let s = crate::find("ec.generate.writeon").unwrap(); + let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in self.at(t) { + p.values.insert(k.to_string(), v); + } + Some(p) + } + } + let render = |extra: &[(&'static str, Value)], t: f64| { + let mut set = vec![("color", col(1.0, 0.0, 0.0)), ("brushSize", num(6.0)), ("brushHardness", num(100.0)), ("brushSpacing", num(0.05))]; + set.extend(extra.iter().cloned()); + let host = Brush(set); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let at = host.at(t); + run_env("ec.generate.writeon", &at, Image::new(64, 32), t, env).img + }; + let on = |img: &Image, x: i64| img.get(x, 16)[3] > 0.9; + let half = render(&[], 0.5); + assert!(on(&half, 8) && on(&half, 20) && on(&half, 32) && !on(&half, 44) && !on(&half, 56)); + let end = render(&[], 1.0); + assert!([8, 20, 32, 44, 56].iter().all(|x| on(&end, *x))); + // Stroke Length 0.25 s: only the last quarter second's trail. + let trail = render(&[("strokeLength", num(0.25))], 1.0); + assert!(!on(&trail, 8) && !on(&trail, 32) && on(&trail, 48) && on(&trail, 56)); + // Brush Spacing 0.5 s: three separate marks. + let spaced = render(&[("brushSpacing", num(0.5))], 1.0); + assert!(on(&spaced, 8) && on(&spaced, 32) && on(&spaced, 56) && !on(&spaced, 20) && !on(&spaced, 44)); + // Paint Time Properties ▸ Opacity: each dab keeps the opacity it was laid with. + struct Fade; + impl EffectHost for Fade { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn params_at(&self, t: f64) -> Option { + let mut p = Brush(vec![("brushHardness", num(100.0)), ("brushSpacing", num(0.05)), ("paintTimeProperties", Value::Enum(1))]).params_at(t)?; + p.values.insert("brushOpacity".into(), num(if t < 0.5 { 100.0 } else { 20.0 })); + Some(p) + } + } + let p1 = Fade.params_at(1.0).unwrap(); + let set: Vec<(&str, Value)> = p1.values.iter().map(|(k, v)| (k.as_str(), v.clone())).collect(); + let faded = run_env("ec.generate.writeon", &set, Image::new(64, 32), 1.0, EffectEnv { host: Some(&Fade), ..Default::default() }).img; + assert!(faded.get(8, 16)[3] > 0.9 && (faded.get(56, 16)[3] - 0.2).abs() < 0.05, "{:?} {:?}", faded.get(8, 16), faded.get(56, 16)); + } + #[test] fn paint_bucket_fills_enclosed_region_only() { let mut img = Image::filled(32, 32, [1.0, 1.0, 1.0, 1.0]);