mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f (cherry picked from commit c82c9aeb685eab0cb36ca69c0b337781f4bad258)
This commit is contained in:
1 file changed
+120
-8
@@ -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<crate::ParamSpec>, 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<LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn params_at(&self, t: f64) -> Option<Params> {
|
||||
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<LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn params_at(&self, t: f64) -> Option<Params> {
|
||||
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]);
|
||||
|
||||
Reference in new issue
Block a user